Pill Navbar

A landing page header: a mark on the left, a short row of links in the middle, and the actions on the right. As the pointer crosses the links, one soft pill slides under them. When the pointer leaves, the pill fades out where it stopped. The last link opens a small dark panel of resources.

Move across the links slowly, then quickly, then leave and come back from the other side. Switch to Mobile for the collapsed menu, and use Full screen to see the whole bar with its actions at page width.

One pill, not five

Each link has no hover background of its own. There is a single pill behind the row, and it moves to whichever link is hovered or focused. It measures the link's offsetLeft and offsetWidth, then springs its x and width to match, with a short spring and very little bounce.

  • It leaves in place. On pointer leave only the opacity animates. The pill keeps its position, so it fades out on the link you left instead of snapping back to a default.
  • It returns from where it was. Come back on a different link and it fades in while sliding across from its last spot. That makes the row feel like one control. The very first appearance jumps into place so it never grows out of the left edge.
  • Keyboard gets the same thing. Focus drives the pill exactly like hover, and the link text darkens alongside it, so the pill is never the only signal.
  • Leaving is forgiving. Closing waits 140ms, so crossing the gap between the row and the panel does not flicker the menu shut.

The panel

The Resources panel is dark in both themes: primary in light, and the lighter secondary in dark so it still lifts off a card. It opens with a small blur, a 3% scale, and a 6px drop over 220ms, all on the snappy ease. Items in the panel use a plain fade on hover rather than a second sliding pill. Two pills moving at once reads as noise; one moving pill and quiet items keep the attention on the row.

  • A mouse opens it on hover. Touch and keyboard open it with a click, and ↓ moves focus into it. Esc, an outside press, or tabbing away closes it and returns focus to the trigger.
  • The trigger is a disclosure button with aria-expanded and aria-controls, not a menu. The items are ordinary links.
  • Icons are neutral by default and brighten on hover. Pass an accent class per item for colour, as the Colour toggle does.

Widths

The bar reads its container, not the viewport:

  • Below @lg it shows the mark and a menu button. The menu drops a card with every link, the resources grid, and the actions.
  • From @lg the link row appears, with the brand as its mark only.
  • The primary action returns at @2xl, and the name and secondary action at @3xl.

Usage

The header is a server component. Only the link row and the mobile menu ship JavaScript.

components/blocks/navigation/pill-navbar.tsx
import {
  BookOpenTextIcon,
  ChatTeardropTextIcon,
  CircleHalfIcon,
  KeyIcon,
  QuestionIcon,
  WavesIcon,
} from "@phosphor-icons/react/dist/ssr";
import type { ReactNode } from "react";

import {
  PillNavLinks,
  PillNavMobileMenu,
  type PillNavItem,
} from "@/components/blocks/navigation/pill-nav";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type Brand = { label: string; href: string; logo?: ReactNode };
type Action = { label: string; href: string };

const DEFAULT_ITEMS: PillNavItem[] = [
  { label: "Story", href: "#" },
  { label: "Roadmap", href: "#" },
  { label: "Updates", href: "#" },
  { label: "Pricing", href: "#" },
  {
    label: "Resources",
    items: [
      { label: "FAQ", href: "#", icon: <QuestionIcon weight="fill" /> },
      { label: "Compare apps", href: "#", icon: <CircleHalfIcon weight="fill" /> },
      { label: "Blog", href: "#", icon: <BookOpenTextIcon weight="fill" /> },
      { label: "Manage license", href: "#", icon: <KeyIcon weight="fill" /> },
      { label: "Get support", href: "#", icon: <ChatTeardropTextIcon weight="fill" /> },
    ],
  },
];

export function PillNavbar({
  brand = { label: "Tide", href: "#" },
  items = DEFAULT_ITEMS,
  secondaryAction = { label: "Sign in", href: "#" },
  primaryAction = { label: "Download", href: "#" },
  className,
}: {
  brand?: Brand;
  items?: PillNavItem[];
  secondaryAction?: Action | null;
  primaryAction?: Action | null;
  className?: string;
}) {
  const secondary = secondaryAction && (
    <Button variant="ghost" render={<a href={secondaryAction.href} />}>
      {secondaryAction.label}
    </Button>
  );
  const primary = primaryAction && (
    <Button render={<a href={primaryAction.href} />}>{primaryAction.label}</Button>
  );

  return (
    <header className={cn("@container w-full", className)}>
      <div className="relative mx-auto flex h-16 max-w-6xl items-center gap-4 px-4 @3xl:px-6">
        <div className="flex flex-1">
          <a
            href={brand.href}
            className="inline-flex h-10 items-center gap-2 rounded-full pr-1 text-sm font-medium tracking-tight outline-none focus-visible:ring-[1.5px] focus-visible:ring-ring/60"
          >
            <span className="flex size-7 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-(--custom-shadow-primary) [&_svg]:size-4">
              {brand.logo ?? <WavesIcon weight="bold" />}
            </span>
            {/* The mark alone stands in for the name when space is tight. */}
            <span className="sr-only @3xl:not-sr-only">{brand.label}</span>
          </a>
        </div>

        <nav aria-label="Main" className="hidden @lg:block">
          <PillNavLinks items={items} />
        </nav>

        <div className="flex flex-1 items-center justify-end gap-1">
          {/* The primary action returns first as the row widens. */}
          <div className="hidden @3xl:contents">{secondary}</div>
          <div className="hidden @2xl:contents">{primary}</div>
          <PillNavMobileMenu items={items} className="@lg:hidden">
            {secondary}
            {primary}
          </PillNavMobileMenu>
        </div>
      </div>
    </header>
  );
}
"use client";

import { CaretDownIcon, ListIcon, XIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ReactNode,
} from "react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export type PillNavLink = { label: string; href: string };
export type PillNavMenuLink = PillNavLink & {
  icon?: ReactNode;
  /** Text colour class for the icon, e.g. "text-sky-400". Neutral when unset. */
  accent?: string;
};
export type PillNavMenu = { label: string; items: PillNavMenuLink[] };
export type PillNavItem = PillNavLink | PillNavMenu;

const isMenu = (item: PillNavItem): item is PillNavMenu => "items" in item;

const EASE = [0.23, 1, 0.32, 1] as const;
const PILL_SPRING = { type: "spring", duration: 0.35, bounce: 0.12 } as const;
const CLOSE_DELAY = 140;

type PillRect = { left: number; width: number; jump: boolean };

/**
 * The centre row of links. A single pill follows the hovered or focused link
 * and fades out in place when the pointer leaves; a menu item opens a panel.
 */
export function PillNavLinks({
  items,
  className,
}: {
  items: PillNavItem[];
  className?: string;
}) {
  const reduceMotion = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<(HTMLElement | null)[]>([]);
  const triggerRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const closeTimer = useRef<number | undefined>(undefined);
  const [hovered, setHovered] = useState<number | null>(null);
  const [open, setOpen] = useState<number | null>(null);
  const [rect, setRect] = useState<PillRect | null>(null);
  const baseId = useId();

  const active = open ?? hovered;

  const moveTo = useCallback((index: number) => {
    const el = itemRefs.current[index];
    if (!el) return;
    // The first time the pill appears it jumps into place; after that it
    // slides from wherever it last was, even while fading back in.
    setRect((prev) => ({
      left: el.offsetLeft,
      width: el.offsetWidth,
      jump: prev === null,
    }));
  }, []);

  const cancelClose = () => window.clearTimeout(closeTimer.current);

  const enter = (index: number, pointerType?: string) => {
    cancelClose();
    moveTo(index);
    setHovered(index);
    // A mouse opens menus on hover. Touch waits for the click, and moving
    // focus to another link closes whatever panel was open.
    if (pointerType === "mouse") setOpen(isMenu(items[index]) ? index : null);
    else if (pointerType === undefined) setOpen((o) => (o === index ? o : null));
  };

  const leave = () => {
    cancelClose();
    closeTimer.current = window.setTimeout(() => {
      setHovered(null);
      setOpen(null);
    }, CLOSE_DELAY);
  };

  const close = useCallback((refocus?: number) => {
    setOpen(null);
    if (refocus !== undefined) triggerRefs.current[refocus]?.focus();
  }, []);

  // Outside press and Escape close the panel.
  useEffect(() => {
    if (open === null) return;
    const onPointerDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) close();
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close(open);
    };
    document.addEventListener("pointerdown", onPointerDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onPointerDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [open, close]);

  useEffect(() => () => window.clearTimeout(closeTimer.current), []);

  const slide = rect?.jump || reduceMotion ? { duration: 0 } : PILL_SPRING;

  return (
    <div
      ref={rootRef}
      className={cn("relative", className)}
      onPointerEnter={cancelClose}
      onPointerLeave={leave}
      onBlur={(e) => {
        if (!rootRef.current?.contains(e.relatedTarget as Node)) {
          setHovered(null);
          setOpen(null);
        }
      }}
    >
      <ul className="relative flex items-center">
        {rect && (
          <motion.span
            aria-hidden="true"
            className="pointer-events-none absolute inset-y-0 left-0 rounded-full bg-foreground/[0.06] dark:bg-foreground/10"
            initial={false}
            animate={{ x: rect.left, width: rect.width, opacity: active === null ? 0 : 1 }}
            transition={{ x: slide, width: slide, opacity: { duration: 0.15, ease: EASE } }}
          />
        )}
        {items.map((item, index) => {
          const isActive = active === index;
          const itemClass = cn(
            "relative inline-flex h-8 items-center gap-1 rounded-full px-3 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-colors duration-150",
            // Keep a 40px hit area on a 32px pill.
            "after:absolute after:inset-x-0 after:-inset-y-1",
            "focus-visible:ring-[1.5px] focus-visible:ring-ring/60",
            isActive && "text-foreground",
          );

          if (!isMenu(item)) {
            return (
              <li key={item.label}>
                <a
                  ref={(el) => {
                    itemRefs.current[index] = el;
                  }}
                  href={item.href}
                  className={itemClass}
                  onPointerEnter={(e) => enter(index, e.pointerType)}
                  onFocus={() => enter(index)}
                >
                  {item.label}
                </a>
              </li>
            );
          }

          const panelId = `${baseId}-menu-${index}`;
          const isOpen = open === index;
          return (
            <li key={item.label}>
              <button
                ref={(el) => {
                  itemRefs.current[index] = el;
                  triggerRefs.current[index] = el;
                }}
                type="button"
                aria-expanded={isOpen}
                aria-controls={panelId}
                className={cn(itemClass, "cursor-pointer pr-2.5")}
                onPointerEnter={(e) => enter(index, e.pointerType)}
                onFocus={() => enter(index)}
                onClick={() => {
                  moveTo(index);
                  setOpen(isOpen ? null : index);
                }}
                onKeyDown={(e) => {
                  if (e.key !== "ArrowDown") return;
                  e.preventDefault();
                  setOpen(index);
                  requestAnimationFrame(() =>
                    document.getElementById(panelId)?.querySelector("a")?.focus(),
                  );
                }}
              >
                {item.label}
                <CaretDownIcon
                  weight="bold"
                  className={cn(
                    "size-3 opacity-60 transition-transform duration-200 ease-snappy motion-reduce:transition-none",
                    isOpen && "rotate-180",
                  )}
                />
              </button>
              <MenuPanel
                id={panelId}
                open={isOpen}
                items={item.items}
                onNavigate={() => close()}
              />
            </li>
          );
        })}
      </ul>
    </div>
  );
}

function MenuPanel({
  id,
  open,
  items,
  onNavigate,
}: {
  id: string;
  open: boolean;
  items: PillNavMenuLink[];
  onNavigate: () => void;
}) {
  const reduceMotion = useReducedMotion();

  return (
    // Centred under the whole row of links; the top padding is a hover bridge.
    <div className="absolute top-full left-1/2 z-20 -translate-x-1/2 pt-2">
      <AnimatePresence>
        {open && (
          <motion.div
            id={id}
            className="w-[min(34rem,calc(100cqw-2rem))] origin-top rounded-[20px] bg-primary p-1.5 text-primary-foreground shadow-(--custom-shadow-primary) dark:bg-secondary dark:text-secondary-foreground dark:shadow-(--custom-shadow)"
            initial={
              reduceMotion
                ? { opacity: 0 }
                : { opacity: 0, y: -6, scale: 0.97, filter: "blur(4px)" }
            }
            animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
            exit={
              reduceMotion
                ? { opacity: 0 }
                : { opacity: 0, y: -4, scale: 0.98, filter: "blur(4px)" }
            }
            transition={{ duration: 0.22, ease: EASE }}
          >
            <ul className="grid grid-cols-2 gap-0.5 @xl:grid-cols-3">
              {items.map((item) => (
                <li key={item.label}>
                  <MenuLink item={item} onNavigate={onNavigate} tone="inverse" />
                </li>
              ))}
            </ul>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function MenuLink({
  item,
  onNavigate,
  tone,
}: {
  item: PillNavMenuLink;
  onNavigate: () => void;
  tone: "inverse" | "surface";
}) {
  return (
    <a
      href={item.href}
      onClick={onNavigate}
      className={cn(
        "group/link flex h-11 items-center gap-2.5 rounded-xl px-3 text-sm font-medium outline-none transition-colors duration-150",
        tone === "inverse"
          ? "hover:bg-white/8 focus-visible:bg-white/8 focus-visible:ring-[1.5px] focus-visible:ring-white/30"
          : "hover:bg-muted focus-visible:bg-muted focus-visible:ring-[1.5px] focus-visible:ring-ring/60",
      )}
    >
      {item.icon && (
        <span
          aria-hidden="true"
          className={cn(
            "flex size-4 shrink-0 items-center justify-center [&_svg]:size-4",
            item.accent ??
              "opacity-60 transition-opacity duration-150 group-hover/link:opacity-100",
          )}
        >
          {item.icon}
        </span>
      )}
      <span className="truncate">{item.label}</span>
    </a>
  );
}

/** The compact menu shown when the container is too narrow for the links. */
export function PillNavMobileMenu({
  items,
  children,
  className,
}: {
  items: PillNavItem[];
  /** Extra actions (e.g. CTAs) placed at the bottom of the sheet. */
  children?: ReactNode;
  className?: string;
}) {
  const reduceMotion = useReducedMotion();
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const panelId = useId();

  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      setOpen(false);
      buttonRef.current?.focus();
    };
    document.addEventListener("pointerdown", onPointerDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onPointerDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  const close = () => setOpen(false);

  return (
    <div ref={rootRef} className={className}>
      <Button
        ref={buttonRef}
        variant="ghost"
        size="icon-lg"
        aria-label={open ? "Close menu" : "Open menu"}
        aria-expanded={open}
        aria-controls={panelId}
        onClick={() => setOpen((o) => !o)}
        className="relative"
      >
        <ListIcon
          className={cn(
            "absolute size-4.5 transition-[opacity,scale] duration-200 ease-snappy motion-reduce:transition-none",
            open ? "scale-75 opacity-0" : "scale-100 opacity-100",
          )}
        />
        <XIcon
          className={cn(
            "absolute size-4.5 transition-[opacity,scale] duration-200 ease-snappy motion-reduce:transition-none",
            open ? "scale-100 opacity-100" : "scale-75 opacity-0",
          )}
        />
      </Button>
      <AnimatePresence>
        {open && (
          <motion.nav
            id={panelId}
            aria-label="Main"
            className="absolute inset-x-3 top-full z-20 origin-top-right rounded-2xl bg-card p-2 text-card-foreground shadow-(--custom-shadow)"
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98, y: -4 }}
            transition={{ duration: 0.2, ease: EASE }}
          >
            <ul className="flex flex-col">
              {items.map((item) =>
                isMenu(item) ? (
                  <li key={item.label} className="mt-1 border-t border-border pt-2">
                    <p className="px-3 pb-1 text-xs font-medium text-muted-foreground">
                      {item.label}
                    </p>
                    <ul className="grid grid-cols-2 gap-0.5">
                      {item.items.map((sub) => (
                        <li key={sub.label}>
                          <MenuLink item={sub} onNavigate={close} tone="surface" />
                        </li>
                      ))}
                    </ul>
                  </li>
                ) : (
                  <li key={item.label}>
                    <a
                      href={item.href}
                      onClick={close}
                      className="flex h-11 items-center rounded-xl px-3 text-sm font-medium outline-none transition-colors duration-150 hover:bg-muted focus-visible:bg-muted focus-visible:ring-[1.5px] focus-visible:ring-ring/60"
                    >
                      {item.label}
                    </a>
                  </li>
                ),
              )}
            </ul>
            {children && (
              <div className="mt-2 flex gap-2 border-t border-border px-1 pt-3 pb-1 *:flex-1">
                {children}
              </div>
            )}
          </motion.nav>
        )}
      </AnimatePresence>
    </div>
  );
}

Resources

Search concepts

Search for a command to run...