A modern dock navigation with fluid hover animations and interactive tooltips.
import FluidDock, { FluidDockItem } from "@/registry/components/fluid-dock/fluid-dock";
const FluidDockPreview = () => {
const items = [
{ icon: FilesIcon, name: "Files", },
{ icon: SearchIcon, name: "Search", },
{ icon: MessageIcon, name: "Message", },
{ icon: CalendarIcon, name: "Calendar", },
{ icon: MusicIcon, name: "Music", },
{ icon: NotesIcon, name: "Notes", },
{ icon: AppsIcon, name: "Apps", },
{ icon: SettingsIcon, name: "Settings", },
];
return (
<FluidDock>
{items.map(item => {
const Icon = item.icon;
return (
<FluidDockItem
key={item.name}
tooltip={item.name}
>
<Icon className="text-xl" />
</FluidDockItem>
);
})}
</FluidDock>
);
};
/** ICONS */
const FilesIcon = (props: React.ComponentProps<"svg">) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M15 3v4a1 1 0 0 0 1 1h4" />
<path d="M18 17h-7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4l5 5v7a2 2 0 0 1-2 2" />
<path d="M16 17v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h2" />
</g>
</svg>
);
const SearchIcon = (props: React.ComponentProps<"svg">) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" fill-rule="evenodd" d="M11 2a9 9 0 1 0 5.618 16.032l3.675 3.675a1 1 0 0 0 1.414-1.414l-3.675-3.675A9 9 0 0 0 11 2m-6 9a6 6 0 1 1 12 0a6 6 0 0 1-12 0" clip-rule="evenodd" />
</svg>
);
const MessageIcon = (props: React.ComponentProps<"svg">) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" d="M18 3a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4h-4.724l-4.762 2.857a1 1 0 0 1-1.508-.743L7 21v-2H6a4 4 0 0 1-3.995-3.8L2 15V7a4 4 0 0 1 4-4zm-4 9H8a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2m2-4H8a1 1 0 1 0 0 2h8a1 1 0 0 0 0-2" />
</svg>
);
const CalendarIcon = (props: React.ComponentProps<"svg">) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<g fill="currentColor">
<path d="M16 2a1 1 0 0 1 .993.883L17 3v1h1a3 3 0 0 1 2.995 2.824L21 7v12a3 3 0 0 1-2.824 2.995L18 22H6a3 3 0 0 1-2.995-2.824L3 19V7a3 3 0 0 1 2.824-2.995L6 4h1V3a1 1 0 0 1 1.993-.117L9 3v1h6V3a1 1 0 0 1 1-1m3 7H5v9.625c0 .705.386 1.286.883 1.366L6 20h12c.513 0 .936-.53.993-1.215l.007-.16z" />
<path d="M12 12a1 1 0 0 1 .993.883L13 13v3a1 1 0 0 1-1.993.117L11 16v-2a1 1 0 0 1-.117-1.993L11 12z" />
</g>
</svg>
);
const MusicIcon = (props: React.ComponentProps<"svg">) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0-6 0m10 0a3 3 0 1 0 6 0a3 3 0 0 0-6 0" />
<path d="M9 17V4h10v13M9 8h10" />
</g>
</svg>
);
const AppsIcon = (props: React.ComponentProps<"svg">) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm0 10a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1zm0-8h6m-3-3v6" />
</svg>
);
const NotesIcon = (props: React.ComponentProps<"svg">) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2zm4 2h6m-6 4h6m-6 4h4" />
</svg>
);
const SettingsIcon = (props: React.ComponentProps<"svg">) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path fill="currentColor" fill-rule="evenodd" d="M14.208 4.83q.68.21 1.3.54l1.833-1.1a1 1 0 0 1 1.221.15l1.018 1.018a1 1 0 0 1 .15 1.221l-1.1 1.833q.33.62.54 1.3l2.073.519a1 1 0 0 1 .757.97v1.438a1 1 0 0 1-.757.97l-2.073.519q-.21.68-.54 1.3l1.1 1.833a1 1 0 0 1-.15 1.221l-1.018 1.018a1 1 0 0 1-1.221.15l-1.833-1.1q-.62.33-1.3.54l-.519 2.073a1 1 0 0 1-.97.757h-1.438a1 1 0 0 1-.97-.757l-.519-2.073a7.5 7.5 0 0 1-1.3-.54l-1.833 1.1a1 1 0 0 1-1.221-.15L4.42 18.562a1 1 0 0 1-.15-1.221l1.1-1.833a7.5 7.5 0 0 1-.54-1.3l-2.073-.519A1 1 0 0 1 2 12.72v-1.438a1 1 0 0 1 .757-.97l2.073-.519q.21-.68.54-1.3L4.27 6.66a1 1 0 0 1 .15-1.221L5.438 4.42a1 1 0 0 1 1.221-.15l1.833 1.1q.62-.33 1.3-.54l.519-2.073A1 1 0 0 1 11.28 2h1.438a1 1 0 0 1 .97.757zM12 16a4 4 0 1 0 0-8a4 4 0 0 0 0 8" />
</svg>
);
export default FluidDockPreview; Install the following packages before using this component.
import { type ReactNode, type MouseEvent, memo, useState, useRef, useContext, createContext, useCallback, useMemo } from "react";
import { type HTMLMotionProps, type MotionValue, motion, AnimatePresence, useTransform, useSpring, useMotionValue} from "motion/react";
import cn from "@/utils/cn";
export type FluidDockProps = {
children: ReactNode;
itemSize?: number;
magnificationScale?: number;
padding?: number;
} & HTMLMotionProps<"div">;
export type FluidDockItemProps = {
tooltip?: string;
tooltipClassName?: string;
} & HTMLMotionProps<"div">;
type DockContextValue = {
mouseX: MotionValue<number> | null,
itemSize: number;
magnificationScale: number;
}
const DockContext = createContext<DockContextValue>({
mouseX: null,
itemSize: 40,
magnificationScale: 1.5,
});
const FluidDock = (
props: FluidDockProps
) => {
const {
children,
className,
itemSize = 40,
magnificationScale = 1.5,
padding = 8,
style,
...restProps
} = props;
const mouseX = useMotionValue<number>(Infinity);
const mouseMoveHandler = useCallback((e: MouseEvent) => {
mouseX.set(e.pageX);
}, []);
const mouseLeaveHandler = useCallback(() => {
mouseX.set(Infinity);
}, []);
const dockContextValue: DockContextValue = useMemo(() => ({
mouseX,
itemSize,
magnificationScale
}), [mouseX, itemSize, magnificationScale]);
return (
<DockContext.Provider
value={dockContextValue}
>
<motion.div
{...restProps}
className={cn(
"flex items-center gap-2 rounded-lg backdrop-blur-sm",
"border border-zinc-200/70 dark:border-zinc-700/70",
"bg-white/75 dark:bg-zinc-900/75",
"shadow-sm shadow-black/5 dark:shadow-black/10",
"[padding:var(--dock-padding)]",
"[height:var(--dock-height)]",
"[max-height:var(--dock-height)]",
className,
)}
style={{
...style,
"--dock-padding": `${padding}px`,
"--dock-height": `${itemSize + (padding * 2)}px`,
} as React.CSSProperties}
onMouseMove={mouseMoveHandler}
onMouseLeave={mouseLeaveHandler}
>
{children}
</motion.div>
</DockContext.Provider>
)
};
export const FluidDockItem = memo((
props: FluidDockItemProps
) => {
const {
tooltip,
className,
tooltipClassName,
style,
children,
...restProps
} = props;
const [showTooltip, setShowTooltip] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { mouseX, itemSize, magnificationScale} = useContext(DockContext);
const pos = useTransform(mouseX as MotionValue, (val) => {
if (!ref.current || !Number.isFinite(val)) {
return Infinity;
}
const { x, width } = ref.current.getBoundingClientRect();
const center = (x + (width / 2));
return val - center;
});
const offsetRange = [-128, 0, 128];
const springConfig = {
stiffness: 200,
damping: 20,
};
const width = useSpring(
useTransform(
pos,
offsetRange,
[itemSize, itemSize * magnificationScale, itemSize],
),
springConfig,
);
const y = useSpring(
useTransform(
pos,
offsetRange,
[0, itemSize * -1 , 0],
),
springConfig,
);
const scale = useSpring(
useTransform(
pos,
offsetRange,
[1, 2, 1],
),
springConfig,
);
const mouseEnterHandler = useCallback(() => {
setShowTooltip(true);
}, []);
const mouseLeaveHandler = useCallback(() => {
setShowTooltip(false);
}, []);
return (
<motion.div
{...restProps}
ref={ref}
className={cn(
"relative [aspect-ratio:1] grid place-items-center rounded-lg cursor-pointer",
"border border-zinc-200/70 dark:border-zinc-700/70",
"bg-zinc-50 dark:bg-zinc-900",
"text-zinc-700 dark:text-zinc-300",
className,
)}
style={{
...style,
width,
y,
}}
onMouseEnter={mouseEnterHandler}
onMouseLeave={mouseLeaveHandler}
>
<AnimatePresence>
{tooltip && showTooltip && (
<motion.span
className={cn(
"py-1 px-2 text-xs absolute top-[0] left-[50%] rounded-sm",
"border border-zinc-200 dark:border-zinc-700",
"bg-white dark:bg-zinc-900",
"text-zinc-900 dark:text-zinc-100",
tooltipClassName,
)}
style={{
x: "-50%",
y: "-100%",
opacity: 0,
}}
animate={{
x: "-50%",
y: "-140%",
opacity: 1,
}}
exit={{
x: "-50%",
y: "-100%",
opacity: 0,
}}
>
{tooltip}
</motion.span>
)}
</AnimatePresence>
<motion.span
style={{
scale,
}}
>
{children}
</motion.span>
</motion.div>
)
});
export default memo(FluidDock); | Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| children | React.ReactNode | Yes | — | The FluidDockItem components rendered inside the dock. |
| className | string | No | — | Additional CSS classes applied to the dock container. |
| itemSize | number | No | 40 | Base size (in pixels) of each dock item before magnification. |
| magnificationScale | number | No | 1.5 | Maximum scale applied to a dock item when hovered. |
| padding | number | No | 8 | Inner padding (in pixels) of the dock container. |
| style | React.CSSProperties | No | — | Inline styles applied to the dock container. |
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| children | React.ReactNode | Yes | — | The content of the dock item, typically an icon or image. |
| tooltip | React.ReactNode | No | — | Content displayed in the tooltip when the item is hovered. |
| className | string | No | — | Additional CSS classes applied to the dock item. |
| tooltipClassName | string | No | — | Additional CSS classes applied to the tooltip. |
| style | React.CSSProperties | No | — | Inline styles applied to the dock item. |
If you find this component useful, consider starring the repository on GitHub. Found a bug or have a suggestion? Open an issue to help improve it.