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