Skip to content

Gooey Nav

Capsules joined by liquid necks that stretch as you hover.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/gooey-nav.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/nav/mobile-menu.tsx
    "use client";
    
    import { useEffect, useId, useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ChevronDown } from "lucide-react";
    import { cn } from "@/lib/utils";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    
    export interface MobileMenuProps {
      items: NavItem[];
      actions?: NavAction[];
      /** Colors for light or dark navbars. @default "light" */
      tone?: "light" | "dark";
      /** Classes for the toggle button. */
      className?: string;
      /** Classes for the panel, to place it under narrow navbars. */
      panelClassName?: string;
    }
    
    /**
     * The compact menu every navbar falls back to on narrow widths: a hamburger that morphs into an X,
     * and a panel with expandable groups and the actions. Escape closes it and returns focus.
     */
    export function MobileMenu({ items, actions = [], tone = "light", className, panelClassName }: MobileMenuProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [open, setOpen] = useState(false);
      const [group, setGroup] = useState<string | null>(null);
      const dark = tone === "dark";
    
      useEffect(() => {
        if (!open) return;
        const onKey = (e: KeyboardEvent) => {
          if (e.key !== "Escape") return;
          setOpen(false);
          document.getElementById(`${id}-toggle`)?.focus();
        };
        window.addEventListener("keydown", onKey);
        return () => window.removeEventListener("keydown", onKey);
      }, [open, id]);
    
      const line = cn("absolute left-1/2 h-[1.5px] w-4 -translate-x-1/2 rounded-full transition-transform duration-300 motion-reduce:transition-none", dark ? "bg-white" : "bg-[#0b0b0c]");
    
      return (
        <>
          <button
            id={`${id}-toggle`}
            type="button"
            aria-expanded={open}
            aria-controls={`${id}-panel`}
            aria-label={open ? "Close menu" : "Open menu"}
            onClick={() => setOpen((o) => !o)}
            className={cn("relative grid size-9 shrink-0 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/10" : "hover:bg-black/5", className)}
          >
            <span aria-hidden="true" className={cn(line, open ? "top-1/2 rotate-45" : "top-[13px]")} />
            <span aria-hidden="true" className={cn(line, open ? "top-1/2 -rotate-45" : "top-[21px]")} />
          </button>
          <AnimatePresence>
            {open ? (
              <motion.div
                id={`${id}-panel`}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, transition: { duration: 0.12 } }}
                transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                className={cn("absolute inset-x-2 top-full z-50 mt-2 origin-top rounded-2xl border p-2 shadow-xl", dark ? "border-white/10 bg-[#111113] text-white" : "border-black/[0.06] bg-white text-[#0b0b0c]", panelClassName)}
              >
                <ul>
                  {items.map((item) =>
                    item.items?.length ? (
                      <li key={item.label}>
                        <button
                          type="button"
                          aria-expanded={group === item.label}
                          onClick={() => setGroup((g) => (g === item.label ? null : item.label))}
                          className={cn("flex h-11 w-full cursor-pointer items-center justify-between rounded-xl px-3 text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/5" : "hover:bg-black/[0.03]")}
                        >
                          {item.label}
                          <ChevronDown className={cn("size-4 opacity-60 transition-transform", group === item.label && "rotate-180")} aria-hidden="true" />
                        </button>
                        <div className={cn("grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none", group === item.label ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
                          <ul className="min-h-0 overflow-hidden" inert={group !== item.label}>
                            {item.items.map((link) => (
                              <li key={link.label}>
                                <a href={link.href} className={cn("flex items-center gap-2.5 rounded-lg py-2 pr-3 pl-6 text-[14px] outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "text-white/75 hover:text-white" : "text-[#52525b] hover:text-[#0b0b0c]")}>
                                  {link.icon ? <span aria-hidden="true" className="opacity-70 [&_svg]:size-4">{link.icon}</span> : null}
                                  {link.label}
                                </a>
                              </li>
                            ))}
                          </ul>
                        </div>
                      </li>
                    ) : (
                      <li key={item.label}>
                        <a href={item.href ?? "#"} className={cn("flex h-11 items-center rounded-xl px-3 text-[15px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", dark ? "hover:bg-white/5" : "hover:bg-black/[0.03]")}>
                          {item.label}
                        </a>
                      </li>
                    ),
                  )}
                </ul>
                {actions.length ? (
                  <div className={cn("mt-2 grid gap-2 border-t pt-3", dark ? "border-white/10" : "border-black/[0.06]")}>
                    {actions.map((a, i) => (
                      <a
                        key={a.label}
                        href={a.href}
                        className={cn(
                          "flex h-11 items-center justify-center rounded-xl text-[14px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring",
                          i === actions.length - 1 ? (dark ? "bg-white text-[#0b0b0c]" : "bg-[#0b0b0c] text-white") : dark ? "border border-white/15" : "border border-black/10",
                        )}
                      >
                        {a.label}
                      </a>
                    ))}
                  </div>
                ) : null}
              </motion.div>
            ) : null}
          </AnimatePresence>
        </>
      );
    }
    lib/nav/types.ts
    import type { ReactNode } from "react";
    
    /** A link in a menu or panel. */
    export interface NavLink {
      label: string;
      href: string;
      /** A short line under the label in rich menus. */
      description?: string;
      /** An icon shown beside the label in rich menus. */
      icon?: ReactNode;
    }
    
    /** A top-level navbar entry: a plain link, or a trigger with links under it. */
    export interface NavItem {
      label: string;
      /** Destination when the item is a plain link. */
      href?: string;
      /** Links shown in the item's menu; makes the item a menu trigger. */
      items?: NavLink[];
    }
    
    /** A call-to-action button. */
    export interface NavAction {
      label: string;
      href: string;
    }
  3. Copy the source

    components/ui/gooey-nav/gooey-nav.tsx
    "use client";
    
    import { Fragment, useId, useState, type ReactNode } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { MobileMenu } from "@/lib/nav/mobile-menu";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    
    export interface GooeyNavProps {
      /** Short mark shown in the round badge, e.g. a letter or an icon. */
      brand: ReactNode;
      /** Where the badge links. @default "/" */
      brandHref?: string;
      /** Accessible name for the badge link. @default "Home" */
      brandLabel?: string;
      /** Links in the middle capsule. */
      items: NavItem[];
      /** The call to action in the last capsule. */
      cta?: NavAction;
      /** Dark capsules on light pages, or light capsules on dark ones. @default "dark" */
      tone?: "dark" | "light";
      /** Accessible name for the navigation. @default "Main" */
      label?: string;
      /** Classes for the header. */
      className?: string;
    }
    
    /**
     * Three capsules (a round badge, the links and a call to action) joined by liquid necks, as if
     * poured from one drop. The necks stretch while you hover a capsule, and an outline pill glides
     * between the links.
     */
    export function GooeyNav({ brand, brandHref = "/", brandLabel = "Home", items, cta, tone = "dark", label = "Main", className }: GooeyNavProps) {
      const id = useId().replace(/:/g, "");
      const reduceMotion = useReducedMotion();
      const [hover, setHover] = useState<string | null>(null);
      const [stretch, setStretch] = useState<number | null>(null);
      const dark = tone === "dark";
      const ink = dark ? "#0a0a0a" : "#f5f5f4";
      const text = dark ? "text-white" : "text-[#0a0a0a]";
      const line = dark ? "border-white/35" : "border-black/25";
      const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 30 };
    
      /** The same row twice: once as filtered blobs, once as crisp content on top, so both line up. */
      const row = (blob: boolean) => {
        const gap = (i: number) => (
          <motion.span
            aria-hidden="true"
            className="relative flex shrink-0 items-center"
            initial={false}
            animate={{ width: stretch === i ? 24 : 14 }}
            transition={spring}
          >
            {blob ? <span className="h-5 w-[calc(100%+28px)] shrink-0 -translate-x-3.5 rounded-full" style={{ background: ink }} /> : null}
          </motion.span>
        );
        const shell = (neck: number, content: ReactNode, cls: string) => (
          <div className={cn("relative flex items-center rounded-full", cls)} style={blob ? { background: ink } : undefined} onPointerEnter={blob ? undefined : () => setStretch(neck)} onPointerLeave={blob ? undefined : () => setStretch(null)}>
            <div className={cn("contents", blob && "invisible")}>{content}</div>
          </div>
        );
    
        return (
          <div className="flex items-center" aria-hidden={blob || undefined}>
            {shell(
              0,
              <a href={brandHref} aria-label={brandLabel} tabIndex={blob ? -1 : undefined} className="grid size-[46px] place-items-center rounded-full bg-[radial-gradient(circle_at_35%_30%,#ffffff,#d4d4d8)] text-[17px] font-bold tracking-tight text-[#0a0a0a] shadow-[inset_0_-2px_4px_rgb(0_0_0/0.15)] outline-none focus-visible:ring-2 focus-visible:ring-ring">
                {brand}
              </a>,
              "p-2",
            )}
            {gap(0)}
            {shell(
              1,
              <ul className={cn("flex h-12 items-center rounded-full border px-2", line)} onPointerLeave={() => setHover(null)}>
                {items.map((item, i) => (
                  <Fragment key={item.label}>
                    {i > 0 ? <li aria-hidden="true" className={cn("mx-0.5 h-4 w-px", dark ? "bg-white/30" : "bg-black/20")} /> : null}
                    <li className="relative">
                      {!blob && hover === item.label ? <motion.span layoutId={`${id}-pill`} className={cn("absolute inset-y-1 inset-x-0 rounded-full border", line)} transition={spring} /> : null}
                      <a
                        href={item.href ?? item.items?.[0]?.href ?? "#"}
                        tabIndex={blob ? -1 : undefined}
                        onPointerEnter={() => setHover(item.label)}
                        onFocus={() => setHover(item.label)}
                        className={cn("relative flex h-10 items-center rounded-full px-3.5 text-[15px] font-medium whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-ring", text)}
                      >
                        {item.label}
                      </a>
                    </li>
                  </Fragment>
                ))}
              </ul>,
              "hidden p-2 @xl:flex",
            )}
            {cta ? (
              <>
                <span className="hidden @xl:contents">{gap(1)}</span>
                {shell(
                  1,
                  <a
                    href={cta.href}
                    tabIndex={blob ? -1 : undefined}
                    className={cn("flex h-12 items-center rounded-full border px-5 text-[15px] font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", text, line, dark ? "hover:bg-white hover:text-[#0a0a0a]" : "hover:bg-[#0a0a0a] hover:text-white")}
                  >
                    {cta.label}
                  </a>,
                  "hidden p-2 @xl:flex",
                )}
              </>
            ) : null}
            <span className="contents @xl:hidden">
              {gap(3)}
              {shell(3, <div className="grid size-[46px] place-items-center">{blob ? null : <MobileMenu items={items} actions={cta ? [cta] : []} tone={dark ? "dark" : "light"} panelClassName="inset-x-auto left-1/2 w-64 -translate-x-1/2" />}</div>, "static p-2")}
            </span>
          </div>
        );
      };
    
      return (
        <header className={cn("@container sticky top-0 z-40 flex justify-center px-4 pt-4", className)}>
          <svg aria-hidden="true" className="absolute size-0">
            <filter id={`${id}-goo`}>
              <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
              <feColorMatrix in="blur" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10" />
            </filter>
          </svg>
          <nav aria-label={label} className="relative">
            <div className="absolute inset-0" style={{ filter: `url(#${id}-goo)` }}>
              {row(true)}
            </div>
            <div className="relative">{row(false)}</div>
          </nav>
        </header>
      );
    }

Usage

import { GooeyNav } from "@/components/ui/gooey-nav/gooey-nav";

<GooeyNav brand="S." items={items} cta={{ label: "Download app", href: "/download" }} />

API reference

PropTypeDefaultDescription
brand*ReactNode–Short mark shown in the round badge, e.g. a letter or an icon.
brandHrefstring"/"Where the badge links.
brandLabelstring"Home"Accessible name for the badge link.
items*NavItem[]–Links in the middle capsule.
ctaNavAction–The call to action in the last capsule.
tone"dark" | "light""dark"Dark capsules on light pages, or light capsules on dark ones.
labelstring"Main"Accessible name for the navigation.
classNamestring–Classes for the header.
  • The liquid necks come from an SVG goo filter (blur plus an alpha threshold) on the blob layer only, so text stays crisp.
  • Use `tone="light"` for light capsules on dark pages.
  • Narrow containers keep the badge and swap the rest for a menu capsule.

Accessibility

  • The blob layer is a hidden copy of the row (`aria-hidden`, not focusable), so screen readers and the keyboard only meet the real links once.
  • Hover and focus both move the outline pill; necks only stretch for a mouse.
  • Neck and pill motion is instant under reduced motion.