shadimage
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 · medium
animationframer-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";

More from nexus-labs

Browse all components in this library →