nexus-labs
componentfloating-docksource: ok · hit
Nexus floating dock
Bottom-centered macOS-style dock with pointer-driven magnification, neighbor scaling, glass pill chrome, tooltips, and a touch-friendly expandable mobile menu.
npx shadcn add "https://nexus-ui.com/r/floating-dock.json"free
Visual style
glass-blur
Interaction
tooltipdropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
iconslucide-react
frameworknext
Registry dependencies
https://www.nexus-ui.com/api/registry/nexus-fontutils
Files7 · source cached 10/8/2026, 5:59:09 PM
next-ui/floating-dock/floating-dock-desktop.tsx
"use client";
import * as React from "react";
import { motion, useMotionValue, useReducedMotion } from "framer-motion";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { IconContainer } from "./icon-container";
import type { FloatingDockItem } from "./types";
function routeActive(pathname: string | null, href: string): boolean {
if (!pathname) return false;
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export type FloatingDockDesktopProps = {
items: FloatingDockItem[];
className?: string;
disableLinks?: boolean;
};
export function FloatingDockDesktop({ items, className, disableLinks = false }: FloatingDockDesktopProps) {
const pathname = usePathname();
const reduceMotion = useReducedMotion() === true;
const mouseX = useMotionValue(Number.POSITIVE_INFINITY);
function onMove(e: React.MouseEvent<HTMLDivElement>) {
mouseX.set(e.clientX);
}
function onLeave() {
mouseX.set(Number.POSITIVE_INFINITY);
}
return (
<div
className={cn("pointer-events-none fixed inset-x-0 bottom-7 z-[100] hidden justify-center md:flex", className)}
>
<motion.div
role="navigation"
aria-label="Primary"
onMouseMove={onMove}
onMouseLeave={onLeave}
initial={reduceMotion ? false : { opacity: 0, y: 14, scale: 0.98 }}
animate={reduceMotion ? undefined : { opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 280, damping: 24, mass: 0.4 }}
className={cn(
"relative pointer-events-auto flex items-end justify-center gap-3 overflow-visible rounded-[999px] border border-border/60 bg-background/80 px-4 py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.3)] backdrop-blur-xl before:pointer-events-none before:absolute before:inset-x-4 before:top-1 before:h-px before:bg-gradient-to-r before:from-transparent before:via-white/40 before:to-transparent dark:border-white/10 dark:bg-zinc-950/85 dark:shadow-black/55",
)}
>
{items.map((item) => (
<IconContainer
key={item.href}
mouseX={mouseX}
title={item.title}
href={item.href}
icon={item.icon}
active={routeActive(pathname, item.href)}
disableLink={disableLinks}
/>
))}
</motion.div>
</div>
);
}
next-ui/floating-dock/floating-dock-mobile.tsx
"use client";
import * as React from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { useReducedMotion } from "framer-motion";
import { Menu, X } from "lucide-react";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import type { FloatingDockItem } from "./types";
function routeActive(pathname: string | null, href: string): boolean {
if (!pathname) return false;
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export type FloatingDockMobileProps = {
items: FloatingDockItem[];
className?: string;
};
export function FloatingDockMobile({ items, className }: FloatingDockMobileProps) {
const pathname = usePathname();
const reduceMotion = useReducedMotion() === true;
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const transition = reduceMotion
? { duration: 0.12 }
: ({ type: "spring", stiffness: 420, damping: 34, mass: 0.28 } as const);
return (
<div className={cn("fixed inset-x-0 bottom-4 z-[100] flex justify-center md:hidden", className)}>
<AnimatePresence>
{open ? (
<motion.button
type="button"
aria-label="Close navigation"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 bg-black/35 backdrop-blur-[2px]"
onClick={() => setOpen(false)}
/>
) : null}
</AnimatePresence>
<div className="relative flex flex-col items-center gap-2">
<AnimatePresence>
{open ? (
<motion.nav
id="floating-dock-mobile-panel"
role="menu"
aria-label="Site navigation"
initial={reduceMotion ? { opacity: 0, y: 8 } : { opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0, y: 6 } : { opacity: 0, y: 10, scale: 0.96 }}
transition={transition}
className="mb-1 flex min-w-[220px] flex-col gap-1.5 rounded-2xl border border-border/70 bg-background/95 p-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-zinc-950/95"
>
{items.map((item) => {
const active = routeActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
role="menuitem"
onClick={() => setOpen(false)}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium outline-none ring-offset-2 ring-offset-background transition-[background-color,color,box-shadow]",
"focus-visible:ring-2 focus-visible:ring-primary/60",
active
? "bg-primary/12 text-primary shadow-[0_8px_20px_-14px_rgba(14,165,233,0.6)]"
: "text-foreground hover:bg-muted/80",
)}
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg border border-border/60 bg-muted/40 text-muted-foreground dark:bg-white/5 [&_svg]:size-5">
{item.icon}
</span>
<span>{item.title}</span>
</Link>
);
})}
</motion.nav>
) : null}
</AnimatePresence>
<motion.button
type="button"
aria-expanded={open}
aria-controls={open ? "floating-dock-mobile-panel" : undefined}
aria-haspopup="true"
onClick={() => setOpen((v) => !v)}
className={cn(
"pointer-events-auto flex size-14 items-center justify-center rounded-full border border-border/70 bg-background/90 text-foreground shadow-lg backdrop-blur-xl outline-none ring-offset-2 ring-offset-background transition-[transform,box-shadow]",
"focus-visible:ring-2 focus-visible:ring-primary/60 dark:border-white/10 dark:bg-zinc-950/90",
)}
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
>
{open ? <X className="size-6" aria-hidden /> : <Menu className="size-6" aria-hidden />}
</motion.button>
</div>
</div>
);
}
next-ui/floating-dock/floating-dock.tsx
"use client";
import { FloatingDockDesktop } from "./floating-dock-desktop";
import { FloatingDockMobile } from "./floating-dock-mobile";
import type { FloatingDockItem } from "./types";
export type FloatingDockProps = {
items: FloatingDockItem[];
desktopClassName?: string;
mobileClassName?: string;
};
export function FloatingDock({ items, desktopClassName, mobileClassName }: FloatingDockProps) {
return (
<>
<FloatingDockDesktop items={items} className={desktopClassName} />
<FloatingDockMobile items={items} className={mobileClassName} />
</>
);
}
export type { FloatingDockItem } from "./types";
export { FloatingDockDesktop } from "./floating-dock-desktop";
export { FloatingDockMobile } from "./floating-dock-mobile";
export { IconContainer } from "./icon-container";
next-ui/floating-dock/icon-container.tsx
"use client";
import * as React from "react";
import Link from "next/link";
import type { MotionValue } from "framer-motion";
import {
AnimatePresence,
motion,
useMotionValueEvent,
useReducedMotion,
useSpring,
} from "framer-motion";
import { cn } from "@/lib/utils";
/** Hit target (px); visual grows around bottom origin like macOS dock. */
const BASE = 46;
/** Peak scale at pointer over icon center (1 + MAX_EXTRA). */
const MAX_EXTRA = 0.62;
/** Horizontal distance (px) at which magnification falls to baseline (wider = stronger neighbor lift). */
const INFLUENCE_PX = 104;
/** Standalone spring MV: call `.set()` so every pointer update animates (follower-on-MV can feel inert in some trees). */
const DOCK_SPRING = { stiffness: 520, damping: 30, mass: 0.08 } as const;
const LIFT_SPRING = { stiffness: 420, damping: 32, mass: 0.15 } as const;
function scaleFromPointer(clientX: number, el: HTMLElement | null): number {
if (el == null || !Number.isFinite(clientX)) return 1;
const { left, width } = el.getBoundingClientRect();
const center = left + width / 2;
const dist = Math.abs(clientX - center);
const u = Math.min(1, dist / INFLUENCE_PX);
// Softer shoulder than pure quadratic so neighbors lift more like the reference dock.
const falloff = (1 - u) ** 1.35;
return 1 + MAX_EXTRA * falloff;
}
export type IconContainerProps = {
mouseX: MotionValue<number>;
title: string;
icon: React.ReactNode;
href: string;
active?: boolean;
disableLink?: boolean;
};
export function IconContainer({ mouseX, title, icon, href, active, disableLink = false }: IconContainerProps) {
const reduceMotion = useReducedMotion() === true;
const ref = React.useRef<HTMLDivElement>(null);
const [hovered, setHovered] = React.useState(false);
const scale = useSpring(1, DOCK_SPRING);
const y = useSpring(0, LIFT_SPRING);
useMotionValueEvent(mouseX, "change", (latest) => {
if (reduceMotion) {
scale.set(1);
y.set(0);
return;
}
if (latest === Number.POSITIVE_INFINITY || latest === Number.NEGATIVE_INFINITY) {
scale.set(1);
y.set(0);
return;
}
const nextScale = scaleFromPointer(latest, ref.current);
scale.set(nextScale);
y.set(-(nextScale - 1) * 18);
});
React.useEffect(() => {
if (reduceMotion) {
scale.set(1);
y.set(0);
}
}, [reduceMotion, scale, y]);
return (
<div className="relative mx-1 flex flex-col items-center pt-2.5 pb-1">
<AnimatePresence>
{hovered && !reduceMotion ? (
<motion.span
initial={{ opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ type: "spring", stiffness: 360, damping: 28, mass: 0.2 }}
className="pointer-events-none absolute bottom-full z-10 mb-2 whitespace-nowrap rounded-lg border border-border/80 bg-background/95 px-2.5 py-1 text-[11px] font-medium text-foreground shadow-lg backdrop-blur-md dark:bg-zinc-900/95 dark:text-zinc-100"
role="tooltip"
>
{title}
</motion.span>
) : null}
</AnimatePresence>
<motion.div
ref={ref}
className="flex flex-col items-center will-change-[transform]"
style={
reduceMotion
? undefined
: {
scale,
y,
transformOrigin: "50% 100%",
}
}
>
{disableLink ? (
<div
title={title}
aria-label={title}
role="img"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={cn(
"relative grid place-items-center rounded-full border border-border/70 bg-background/85 text-muted-foreground shadow-sm",
active && "border-primary/40 bg-primary/12 text-primary shadow-[0_8px_22px_-12px_rgba(14,165,233,0.55)]",
)}
style={{
width: BASE,
height: BASE,
}}
>
<span className="grid size-[2.15rem] place-items-center [&_svg]:size-[1.15rem] [&_svg]:shrink-0">{icon}</span>
</div>
) : (
<Link
href={href}
title={title}
aria-label={title}
aria-current={active ? "page" : undefined}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
onPointerDown={() => setHovered(true)}
className={cn(
"relative grid place-items-center rounded-full border border-border/70 bg-background/85 text-muted-foreground shadow-sm outline-none ring-offset-2 ring-offset-background transition-[color,background-color,border-color,box-shadow]",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/60",
active && "border-primary/40 bg-primary/12 text-primary shadow-[0_8px_22px_-12px_rgba(14,165,233,0.55)]",
)}
style={{
width: BASE,
height: BASE,
}}
>
<span className="grid size-[2.15rem] place-items-center [&_svg]:size-[1.15rem] [&_svg]:shrink-0">{icon}</span>
</Link>
)}
</motion.div>
</div>
);
}
next-ui/floating-dock/index.ts
export type { FloatingDockItem, FloatingDockProps } from "./floating-dock";
export {
FloatingDock,
FloatingDockDesktop,
FloatingDockMobile,
IconContainer,
} from "./floating-dock";
next-ui/floating-dock/types.ts
import type * as React from "react";
export type FloatingDockItem = {
title: string;
icon: React.ReactNode;
href: string;
};
next-ui/floating-dock.tsx
/** Re-export shim for `@/components/floating-dock`. */
export type { FloatingDockItem, FloatingDockProps } from "./floating-dock/floating-dock";
export {
FloatingDock,
FloatingDockDesktop,
FloatingDockMobile,
IconContainer,
} from "./floating-dock/floating-dock";