Navbars & Headersv0.3.0
Liquid Tab Nav
A bar whose bottom edge swells under the current section and flows to the next.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/liquid-tab-nav.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand 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.tsimport 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; }Copy the source
components/ui/liquid-tab-nav/liquid-tab-nav.tsx"use client"; import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { cn } from "@/lib/utils"; import { MobileMenu } from "@/lib/nav/mobile-menu"; import type { NavAction } from "@/lib/nav/types"; export interface LiquidTab { label: string; href: string; /** Icon shown in the bubble when this tab is current. */ icon: ReactNode; } export interface LiquidTabNavProps { /** Logo and name, on the left. */ brand: ReactNode; /** Sections; the current one rises into the bubble. */ tabs: LiquidTab[]; /** Call to action on the right. */ cta?: NavAction; /** Label of the current section. Uncontrolled when left out. */ active?: string; /** Initial section when `active` is left out. @default the first tab */ defaultActive?: string; /** Called with the label of the chosen tab. */ onActiveChange?: (label: string) => void; /** Light bar with a dark bubble, or the reverse. @default "light" */ tone?: "light" | "dark"; /** Accessible name for the navigation. @default "Main" */ label?: string; /** Classes for the header. */ className?: string; } const H = 60; const DIP = 26; const HALF = 50; const R = 20; /** The bar's outline: rounded corners and a soft bump along the bottom edge centered on `cx` (flat when `depth` is 0). */ function barPath(w: number, cx: number, depth: number) { const x = Math.max(R + HALF, Math.min(w - R - HALF, cx)); const y = H + depth; return [ `M${R} 0H${w - R}Q${w} 0 ${w} ${R}V${H - R}Q${w} ${H} ${w - R} ${H}`, `H${x + HALF}C${x + 26} ${H} ${x + 30} ${y} ${x} ${y}C${x - 30} ${y} ${x - 26} ${H} ${x - HALF} ${H}`, `H${R}Q0 ${H} 0 ${H - R}V${R}Q0 0 ${R} 0Z`, ].join(""); } /** * A website bar whose bottom edge swells into a soft bump under the current section. Pick another * tab and the bump flows over to it while the section's icon rises into a bubble inside it. */ export function LiquidTabNav({ brand, tabs, cta, active, defaultActive, onActiveChange, tone = "light", label = "Main", className }: LiquidTabNavProps) { const id = useId(); const reduceMotion = useReducedMotion(); const bar = useRef<HTMLDivElement>(null); const links = useRef(new Map<string, HTMLElement>()); const [own, setOwn] = useState(defaultActive ?? tabs[0]?.label ?? ""); const current = active ?? own; const [width, setWidth] = useState(0); const [centers, setCenters] = useState<Record<string, number>>({}); const cx = useSpring(0, reduceMotion ? { duration: 0 } : { stiffness: 380, damping: 32 }); const w = useMotionValue(0); const depth = useSpring(0, reduceMotion ? { duration: 0 } : { stiffness: 300, damping: 26 }); const d = useTransform(() => barPath(w.get(), cx.get(), depth.get())); const dark = tone === "dark"; useEffect(() => { const el = bar.current; if (!el) return; const measure = () => { const box = el.getBoundingClientRect(); // Work in layout pixels, so a scaled ancestor (a zoomed preview) doesn't skew the shape. const width = el.offsetWidth; const scale = box.width / (width || 1) || 1; setWidth(width); w.set(width); const next: Record<string, number> = {}; links.current.forEach((node, key) => { const r = node.getBoundingClientRect(); // Hidden links (the narrow layout) have no box; the bar stays flat then. if (r.width) next[key] = (r.left - box.left + r.width / 2) / scale; }); setCenters(next); }; const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [w]); // Follow the current tab; jump there on the first measurement instead of sliding in from 0. const placed = useRef(false); useEffect(() => { const x = centers[current]; depth.set(x === undefined ? 0 : DIP); if (x === undefined) return; if (placed.current) cx.set(x); else { cx.jump(x); placed.current = true; } }, [centers, current, cx, depth]); const choose = (key: string) => { if (active === undefined) setOwn(key); onActiveChange?.(key); }; const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 380, damping: 32 }; return ( <header className={cn("@container sticky top-0 z-40 px-4 pt-4", className)}> <nav aria-label={label} ref={bar} className="relative mx-auto max-w-5xl" style={{ height: H + DIP }}> <svg aria-hidden="true" className="absolute inset-x-0 top-0 overflow-visible drop-shadow-[0_8px_16px_rgb(0_0_0/0.08)]" width="100%" height={H + DIP}> {width ? <motion.path d={d} fill={dark ? "#0b0b0c" : "#ffffff"} stroke={dark ? "rgb(255 255 255/0.08)" : "rgb(0 0 0/0.05)"} /> : null} </svg> <div className="relative flex items-center gap-2 px-4" style={{ height: H }}> <div className="mr-2 shrink-0">{brand}</div> <ul className="hidden flex-1 items-center justify-center gap-1 @2xl:flex"> {tabs.map((tab) => { const on = current === tab.label; return ( <li key={tab.label}> <a ref={(el) => { if (el) links.current.set(tab.label, el); else links.current.delete(tab.label); }} href={tab.href} aria-current={on ? "page" : undefined} onClick={() => choose(tab.label)} className={cn( "relative flex h-11 min-w-[92px] flex-col items-center justify-center rounded-xl px-3 text-[13.5px] whitespace-nowrap outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", on ? (dark ? "font-semibold text-white" : "font-semibold text-[#0b0b0c]") : dark ? "text-white/55 hover:text-white" : "text-[#71717a] hover:text-[#0b0b0c]", )} > <motion.span initial={false} animate={{ y: on ? -6 : 0 }} transition={spring}> {tab.label} </motion.span> {on ? ( <motion.span layoutId={`${id}-bubble`} aria-hidden="true" className={cn("absolute top-[calc(100%-4px)] left-1/2 grid size-8 -translate-x-1/2 place-items-center rounded-full shadow-[0_6px_14px_-6px_rgb(0_0_0/0.5)] [&_svg]:size-4", dark ? "bg-white text-[#0b0b0c]" : "bg-[#0b0b0c] text-white")} transition={spring} > <motion.span key={tab.label} initial={reduceMotion ? false : { scale: 0.4, rotate: -30, opacity: 0 }} animate={{ scale: 1, rotate: 0, opacity: 1 }} transition={{ delay: reduceMotion ? 0 : 0.08, ...spring }}> {tab.icon} </motion.span> </motion.span> ) : null} </a> </li> ); })} </ul> {cta ? ( <a href={cta.href} className={cn( "ml-auto hidden h-10 shrink-0 items-center rounded-full px-5 text-[14px] font-medium whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 @2xl:flex", dark ? "bg-white text-[#0b0b0c] hover:bg-white/85" : "bg-[#0b0b0c] text-white hover:bg-[#27272a]", )} > {cta.label} </a> ) : null} <div className="ml-auto @2xl:hidden"> <MobileMenu items={tabs} actions={cta ? [cta] : []} tone={tone} /> </div> </div> </nav> </header> ); }
Usage
import { LiquidTabNav } from "@/components/ui/liquid-tab-nav/liquid-tab-nav";
<LiquidTabNav brand={<Logo />} tabs={[{ label: "Home", href: "/", icon: <House /> }]} cta={signup} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
brand* | ReactNode | – | Logo and name, on the left. |
tabs* | LiquidTab[] | – | Sections; the current one rises into the bubble. |
cta | NavAction | – | Call to action on the right. |
active | string | – | Label of the current section. Uncontrolled when left out. |
defaultActive | string | the first tab | Initial section when `active` is left out. |
onActiveChange | (label: string) => void | – | Called with the label of the chosen tab. |
tone | "light" | "dark" | "light" | Light bar with a dark bubble, or the reverse. |
label | string | "Main" | Accessible name for the navigation. |
className | string | – | Classes for the header. |
- The bar is one SVG path whose bump position is a spring, so it flows rather than slides; it measures the tabs with a ResizeObserver.
- When the tabs collapse into the menu, the bump flattens out.
- `tone` switches between a white bar with a black bubble and the reverse.
Accessibility
- Tabs are links; the current one has `aria-current="page"`, and its icon bubble is decorative.
- The bump follows keyboard and pointer choices alike.
- Under reduced motion the bump and bubble jump instead of flowing.