A versatile UI element that provides a flexible and customizable way to organize and display menu items within your application.
1'use client';
2
3import {
4 motion,
5 MotionValue,
6 useMotionValue,
7 useSpring,
8 useTransform,
9 type SpringOptions,
10 AnimatePresence,
11} from 'motion/react';
12import {
13 Children,
14 cloneElement,
15 createContext,
16 useContext,
17 useEffect,
18 useMemo,
19 useRef,
20 useState,
21} from 'react';
22import { cn } from '@/lib/utils';
23
24const DOCK_HEIGHT = 128;
25const DEFAULT_MAGNIFICATION = 80;
26const DEFAULT_DISTANCE = 150;
27const DEFAULT_PANEL_HEIGHT = 64;
28
29export type DockProps = {
30 children: React.ReactNode;
31 className?: string;
32 distance?: number;
33 panelHeight?: number;
34 magnification?: number;
35 spring?: SpringOptions;
36};
37
38export type DockItemProps = {
39 className?: string;
40 children: React.ReactNode;
41};
42
43export type DockLabelProps = {
44 className?: string;
45 children: React.ReactNode;
46};
47
48export type DockIconProps = {
49 className?: string;
50 children: React.ReactNode;
51};
52
53export type DocContextType = {
54 mouseX: MotionValue;
55 spring: SpringOptions;
56 magnification: number;
57 distance: number;
58};
59
60export type DockProviderProps = {
61 children: React.ReactNode;
62 value: DocContextType;
63};
64
65const DockContext = createContext<DocContextType | undefined>(undefined);
66
67function DockProvider({ children, value }: DockProviderProps) {
68 return <DockContext.Provider value={value}>{children}</DockContext.Provider>;
69}
70
71function useDock() {
72 const context = useContext(DockContext);
73 if (!context) {
74 throw new Error('useDock must be used within an DockProvider');
75 }
76 return context;
77}
78
79function Dock({
80 children,
81 className,
82 spring = { mass: 0.1, stiffness: 150, damping: 12 },
83 magnification = DEFAULT_MAGNIFICATION,
84 distance = DEFAULT_DISTANCE,
85 panelHeight = DEFAULT_PANEL_HEIGHT,
86}: DockProps) {
87 const mouseX = useMotionValue(Infinity);
88 const isHovered = useMotionValue(0);
89
90 const maxHeight = useMemo(() => {
91 return Math.max(DOCK_HEIGHT, magnification + magnification / 2 + 4);
92 }, [magnification]);
93
94 const heightRow = useTransform(isHovered, [0, 1], [panelHeight, maxHeight]);
95 const height = useSpring(heightRow, spring);
96
97 return (
98 <motion.div
99 style={{
100 height: height,
101 scrollbarWidth: 'none',
102 }}
103 className='mx-2 flex max-w-full items-end overflow-x-auto'
104 >
105 <motion.div
106 onMouseMove={({ pageX }) => {
107 isHovered.set(1);
108 mouseX.set(pageX);
109 }}
110 onMouseLeave={() => {
111 isHovered.set(0);
112 mouseX.set(Infinity);
113 }}
114 className={cn(
115 'mx-auto flex w-fit gap-4 rounded-2xl bg-gray-50 px-4 dark:bg-neutral-900',
116 className
117 )}
118 style={{ height: panelHeight }}
119 role='toolbar'
120 aria-label='Application dock'
121 >
122 <DockProvider value={{ mouseX, spring, distance, magnification }}>
123 {children}
124 </DockProvider>
125 </motion.div>
126 </motion.div>
127 );
128}
129
130function DockItem({ children, className }: DockItemProps) {
131 const ref = useRef<HTMLDivElement>(null);
132
133 const { distance, magnification, mouseX, spring } = useDock();
134
135 const isHovered = useMotionValue(0);
136
137 const mouseDistance = useTransform(mouseX, (val) => {
138 const domRect = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
139 return val - domRect.x - domRect.width / 2;
140 });
141
142 const widthTransform = useTransform(
143 mouseDistance,
144 [-distance, 0, distance],
145 [40, magnification, 40]
146 );
147
148 const width = useSpring(widthTransform, spring);
149
150 return (
151 <motion.div
152 ref={ref}
153 style={{ width }}
154 onHoverStart={() => isHovered.set(1)}
155 onHoverEnd={() => isHovered.set(0)}
156 onFocus={() => isHovered.set(1)}
157 onBlur={() => isHovered.set(0)}
158 className={cn(
159 'relative inline-flex items-center justify-center',
160 className
161 )}
162 tabIndex={0}
163 role='button'
164 aria-haspopup='true'
165 >
166 {Children.map(children, (child) =>
167 cloneElement(child as React.ReactElement, { width, isHovered })
168 )}
169 </motion.div>
170 );
171}
172
173function DockLabel({ children, className, ...rest }: DockLabelProps) {
174 const restProps = rest as Record<string, unknown>;
175 const isHovered = restProps['isHovered'] as MotionValue<number>;
176 const [isVisible, setIsVisible] = useState(false);
177
178 useEffect(() => {
179 const unsubscribe = isHovered.on('change', (latest) => {
180 setIsVisible(latest === 1);
181 });
182
183 return () => unsubscribe();
184 }, [isHovered]);
185
186 return (
187 <AnimatePresence>
188 {isVisible && (
189 <motion.div
190 initial={{ opacity: 0, y: 0 }}
191 animate={{ opacity: 1, y: -10 }}
192 exit={{ opacity: 0, y: 0 }}
193 transition={{ duration: 0.2 }}
194 className={cn(
195 'absolute -top-6 left-1/2 w-fit whitespace-pre rounded-md border border-gray-200 bg-gray-100 px-2 py-0.5 text-xs text-neutral-700 dark:border-neutral-900 dark:bg-neutral-800 dark:text-white',
196 className
197 )}
198 role='tooltip'
199 style={{ x: '-50%' }}
200 >
201 {children}
202 </motion.div>
203 )}
204 </AnimatePresence>
205 );
206}
207
208function DockIcon({ children, className, ...rest }: DockIconProps) {
209 const restProps = rest as Record<string, unknown>;
210 const width = restProps['width'] as MotionValue<number>;
211
212 const widthTransform = useTransform(width, (val) => val / 2);
213
214 return (
215 <motion.div
216 style={{ width: widthTransform }}
217 className={cn('flex items-center justify-center', className)}
218 >
219 {children}
220 </motion.div>
221 );
222}
223
224export { Dock, DockIcon, DockItem, DockLabel };
225