Skip to content

Center Pill Nav

Links in a centered pill with a sliding hover, actions in their own pill.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/center-pill-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;
    }
    lib/nav/use-menu.ts
    "use client";
    
    import { useEffect, useRef, useState, type RefObject } from "react";
    
    /**
     * Open/close state for a navbar's menus: one menu open at a time, opened by click or by hover with a
     * short intent delay, closed by Escape (focus returns to its trigger), an outside click, or the
     * pointer leaving the navbar.
     */
    export function useMenu(root: RefObject<HTMLElement | null>) {
      const [open, setOpen] = useState<string | null>(null);
      const timer = useRef(0);
      const triggers = useRef(new Map<string, HTMLElement>());
    
      useEffect(() => {
        if (!open) return;
        const onKey = (e: KeyboardEvent) => {
          if (e.key !== "Escape") return;
          triggers.current.get(open)?.focus();
          setOpen(null);
        };
        const onDown = (e: PointerEvent) => {
          if (root.current && !root.current.contains(e.target as Node)) setOpen(null);
        };
        window.addEventListener("keydown", onKey);
        window.addEventListener("pointerdown", onDown);
        return () => {
          window.removeEventListener("keydown", onKey);
          window.removeEventListener("pointerdown", onDown);
        };
      }, [open, root]);
    
      useEffect(() => () => window.clearTimeout(timer.current), []);
    
      const hoverOpen = (id: string) => {
        window.clearTimeout(timer.current);
        timer.current = window.setTimeout(() => setOpen(id), open ? 0 : 90);
      };
      const hoverClose = () => {
        window.clearTimeout(timer.current);
        timer.current = window.setTimeout(() => setOpen(null), 160);
      };
      const cancelClose = () => window.clearTimeout(timer.current);
    
      /** Props for a menu trigger button. */
      const triggerProps = (id: string, panelId: string) => ({
        ref: (el: HTMLElement | null) => {
          if (el) triggers.current.set(id, el);
          else triggers.current.delete(id);
        },
        "aria-expanded": open === id,
        "aria-controls": panelId,
        onClick: () => setOpen((o) => (o === id ? null : id)),
        onPointerEnter: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverOpen(id),
        onPointerLeave: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverClose(),
      });
    
      /** Props for a menu panel, so moving onto it keeps it open. */
      const panelProps = {
        onPointerEnter: cancelClose,
        onPointerLeave: (e: React.PointerEvent) => e.pointerType === "mouse" && hoverClose(),
      };
    
      return { open, setOpen, triggerProps, panelProps, close: () => setOpen(null) };
    }
  3. Copy the source

    components/ui/center-pill-nav/center-pill-nav.tsx
    "use client";
    
    import { useId, useRef, useState, type ReactNode } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ChevronDown } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { MobileMenu } from "@/lib/nav/mobile-menu";
    import type { NavAction, NavItem } from "@/lib/nav/types";
    import { useMenu } from "@/lib/nav/use-menu";
    
    export interface CenterPillNavProps {
      /** Logo and name, on the left. */
      brand: ReactNode;
      /** Links inside the centered pill; entries with `items` open a dropdown. */
      items: NavItem[];
      /** Actions in the right pill; the last one is the accent button. */
      actions?: NavAction[];
      /** Accent button color. @default "#d4400a" */
      accent?: string;
      /** Accessible name for the navigation. @default "Main" */
      label?: string;
      /** Classes for the header. */
      className?: string;
    }
    
    /**
     * Brand on the left, the links in a centered pill with a soft highlight that glides between them,
     * and a second pill on the right holding the quiet actions and a bright accent button.
     */
    export function CenterPillNav({ brand, items, actions = [], accent = "#d4400a", label = "Main", className }: CenterPillNavProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const root = useRef<HTMLElement>(null);
      const menu = useMenu(root);
      const [hovered, setHovered] = useState<string | null>(null);
      const quiet = actions.slice(0, -1);
      const primary = actions.at(-1);
      const active = menu.open ?? hovered;
      const link = "relative flex h-9 whitespace-nowrap items-center gap-1 rounded-full px-3.5 text-[14px] font-medium text-[#27272a] outline-none focus-visible:ring-2 focus-visible:ring-ring";
    
      const highlight = (key: string) =>
        active === key ? (
          <motion.span layoutId={`${id}-pill`} aria-hidden="true" className="absolute inset-0 -z-10 rounded-full bg-black/[0.06]" transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 38 }} />
        ) : null;
    
      return (
        <header ref={root} className={cn("@container sticky top-0 z-40 px-4 pt-4", className)}>
          <nav aria-label={label} className="relative mx-auto grid max-w-6xl grid-cols-[1fr_auto] items-center gap-3 @2xl:grid-cols-[1fr_auto_1fr]">
            <div className="flex items-center pl-1">{brand}</div>
            <ul className="isolate hidden items-center rounded-full border border-black/[0.06] bg-white/90 p-1 shadow-[0_6px_20px_-12px_rgb(0_0_0/0.25)] backdrop-blur-xl @2xl:flex" onPointerLeave={() => setHovered(null)}>
              {items.map((item) => {
                const panelId = `${id}-${item.label}`;
                if (!item.items?.length) {
                  return (
                    <li key={item.label} className="relative isolate" onPointerEnter={() => setHovered(item.label)}>
                      {highlight(item.label)}
                      <a href={item.href ?? "#"} onFocus={() => setHovered(item.label)} onBlur={() => setHovered(null)} className={link}>
                        {item.label}
                      </a>
                    </li>
                  );
                }
                const open = menu.open === item.label;
                return (
                  <li key={item.label} className="relative isolate" onPointerEnter={() => setHovered(item.label)}>
                    {highlight(item.label)}
                    <button type="button" {...menu.triggerProps(item.label, panelId)} className={cn(link, "cursor-pointer")}>
                      {item.label}
                      <ChevronDown className={cn("size-3.5 text-[#71717a] transition-transform duration-200", open && "rotate-180")} aria-hidden="true" />
                    </button>
                    <AnimatePresence>
                      {open ? (
                        <motion.div
                          id={panelId}
                          {...menu.panelProps}
                          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
                          animate={{ opacity: 1, y: 0, scale: 1 }}
                          exit={{ opacity: 0, transition: { duration: 0.1 } }}
                          transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                          className="absolute top-full left-1/2 z-50 mt-3 w-[340px] origin-top -translate-x-1/2 rounded-3xl border border-black/[0.06] bg-white p-2 shadow-[0_24px_50px_-20px_rgb(0_0_0/0.3)]"
                        >
                          <ul className="grid gap-0.5">
                            {item.items.map((l) => (
                              <li key={l.label}>
                                <a href={l.href} onClick={menu.close} className="group flex items-start gap-3 rounded-2xl p-2.5 outline-none hover:bg-[#f6f6f7] focus-visible:bg-[#f6f6f7]">
                                  {l.icon ? (
                                    <span aria-hidden="true" className="grid size-9 shrink-0 place-items-center rounded-xl bg-[#f4f4f5] text-[#3f3f46] transition-colors group-hover:bg-white [&_svg]:size-[18px]">
                                      {l.icon}
                                    </span>
                                  ) : null}
                                  <span>
                                    <span className="block text-[14px] font-medium text-[#18181b]">{l.label}</span>
                                    {l.description ? <span className="block text-[12.5px] leading-snug text-[#71717a]">{l.description}</span> : null}
                                  </span>
                                </a>
                              </li>
                            ))}
                          </ul>
                        </motion.div>
                      ) : null}
                    </AnimatePresence>
                  </li>
                );
              })}
            </ul>
            <div className="hidden justify-self-end @2xl:block">
              {actions.length ? (
                <div className="flex items-center gap-1 rounded-full border border-black/[0.06] bg-white/90 p-1 shadow-[0_6px_20px_-12px_rgb(0_0_0/0.25)] backdrop-blur-xl">
                  {quiet.map((a, i) => (
                    // Only the first quiet action shows until there is room for more.
                    <a
                      key={a.label}
                      href={a.href}
                      className={cn(
                        "relative after:absolute after:inset-x-0 after:-inset-y-0.5 flex h-9 items-center rounded-full px-3.5 text-[14px] font-medium whitespace-nowrap text-[#27272a] outline-none hover:bg-black/[0.05] focus-visible:ring-2 focus-visible:ring-ring",
                        i > 0 && "hidden @4xl:flex",
                      )}
                    >
                      {a.label}
                    </a>
                  ))}
                  {primary ? (
                    <a
                      href={primary.href}
                      className="relative after:absolute after:inset-x-0 after:-inset-y-0.5 flex h-9 whitespace-nowrap items-center rounded-full px-4 text-[14px] font-semibold text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.3)] outline-none transition-[filter] hover:brightness-95 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
                      style={{ background: accent }}
                    >
                      {primary.label}
                    </a>
                  ) : null}
                </div>
              ) : null}
            </div>
            <div className="justify-self-end rounded-full border border-black/[0.06] bg-white p-0.5 shadow-sm @2xl:hidden">
              <MobileMenu items={items} actions={actions} />
            </div>
          </nav>
        </header>
      );
    }

Usage

import { CenterPillNav } from "@/components/ui/center-pill-nav/center-pill-nav";

<CenterPillNav brand={<Logo />} items={items} actions={[{ label: "Log in", href: "/login" }, { label: "Sign up", href: "/signup" }]} accent="#d4400a" />

API reference

PropTypeDefaultDescription
brand*ReactNode–Logo and name, on the left.
items*NavItem[]–Links inside the centered pill; entries with `items` open a dropdown.
actionsNavAction[]–Actions in the right pill; the last one is the accent button.
accentstring"#d4400a"Accent button color.
labelstring"Main"Accessible name for the navigation.
classNamestring–Classes for the header.
  • The last action becomes the accent button; the rest stay quiet.
  • The three-column grid keeps the link pill centered whatever the brand and actions widths.

Accessibility

  • The highlight follows both hover and keyboard focus.
  • Dropdown triggers expose `aria-expanded` and `aria-controls`; Escape closes and returns focus.
  • Highlight and panel motion are instant under reduced motion.