Capsule Navbar

A header with no bar behind it. The mark sits on the left, and on the right there are three capsules of the same height: one holding the links, a filled one for the main action, and a round one for a single icon. Because nothing spans the full width, the header can sit directly on top of a hero.

Hover Programs, then move to About and run down its links. Each menu is a large dark card that swings down from the top-right corner and settles, tilted, behind the capsules. Switch to Mobile to see the links fold into a menu button, or use Full screen to see the header and its cards at page width.

Three capsules, one height

Every piece on the right is 40px tall and fully rounded, with a 6px gap between them. That shared height is what makes three separate shapes read as one control.

  • The link capsule is a bg-card pill with the standard raised shadow. The links inside are muted 32px pills with 4px of padding around them, so the hover fill sits concentric with the capsule edge.
  • The main action is the usual dark primary button, stretched to the same 40px.
  • The icon action is a 40px circle. Its label is an aria-label, so keep it to something the icon already says clearly, like contact or search.

The swinging card

A menu does not drop a small panel under its link. It brings in a card big enough to hold the links at display size, and the card arrives as if it were hinged above the top-right corner of the page.

  • It swings, it does not fade. The card starts above the header at 24° and springs to rest at -6° over about 700ms, with a little bounce. Its transform-origin is its top-right corner, which sits off screen, so the far corner travels the most.
  • It never settles straight. The resting tilt carries through to the links inside it. The top and right edges stay off screen at every angle, so only the two slanted edges show.
  • It is dark in both themes. primary in light, and the lighter secondary in dark so it still lifts off the page. It is the same surface as the Pill Navbar panel. The header row stays on top of it, and the light capsules read clearly against it. In light mode the main action is the same dark as the card, so it switches to a translucent fill while a card is open.
  • The page steps back. A wash of the background colour fades in over everything under the header.
  • Menus swap, they do not morph. Each menu has its own card. Moving from one trigger to the next swings the new card in while the old one swings out and up.
  • Leaving is quicker than arriving. The exit is a 350ms ease-in with no bounce.

With reduced motion the card fades in already tilted.

Links and previews

The links in the card are set large in medium weight. Hovering one fades the others to 50%.

  • Give a link a badge for a small chip beside its label.
  • Give a link a preview, any node such as an image, and it shows in a tilted tile beside the list while that link is hovered or focused. Each tile comes in with a 10° turn and rests at its own slight angle, so moving down the list feels like dealing cards. The tile is decoration only and is hidden from assistive tech and from narrow containers.

Behaviour

  • A mouse opens a card on hover and a click keeps it open. Touch and keyboard open it with a press, and ↓ moves focus into it.
  • Esc, an outside press, or tabbing away closes it. Closing on pointer leave waits 140ms.
  • The trigger is a disclosure button with aria-expanded and aria-controls. The items are ordinary links.
  • The card is clipped to the header's width and top edge, and hangs below it. It is meant for a header at the top of the page with a hero underneath.

Colour

Everything is neutral by default. Pass any CSS colour as accent to fill the main action, and accentForeground for its text. Set --capsule-scrim on the header to change the wash behind the card.

Widths

The header reads its container, not the viewport. Below @2xl the link capsule is replaced by a round menu button at the end of the row, and the main action and icon stay visible. That menu is a plain card listing every link under its parent. Preview tiles appear from @4xl.

Usage

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

components/blocks/navigation/capsule-navbar.tsx
import { EnvelopeSimpleIcon, ShapesIcon } from "@phosphor-icons/react/dist/ssr";
import type { CSSProperties, ReactNode } from "react";

import {
  CapsuleNavLinks,
  CapsuleNavMobileMenu,
  type CapsuleNavItem,
} from "@/components/blocks/navigation/capsule-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 };
type IconAction = Action & { icon?: ReactNode };

const DEFAULT_ITEMS: CapsuleNavItem[] = [
  {
    label: "Programs",
    items: [
      { label: "Early intervention", href: "#", badge: "Ages 0–3" },
      { label: "Skills training", href: "#", badge: "Ages 3–18" },
      { label: "Workforce", href: "#", badge: "Ages 18–65" },
      { label: "Residential support", href: "#", badge: "Ages 65+" },
    ],
  },
  {
    label: "About",
    items: [
      { label: "Our story", href: "#" },
      { label: "Team", href: "#" },
      { label: "Careers", href: "#" },
      { label: "Contact", href: "#" },
    ],
  },
  { label: "Service areas", href: "#" },
];

/**
 * A header with no bar of its own: the links, the main action, and an icon
 * action each sit in a separate capsule of the same height, so it can float
 * over a hero image or illustration. Menus open as a large dark card that
 * swings down, tilted, behind the capsules, so place the header at the top of the page.
 */
export function CapsuleNavbar({
  brand = { label: "Tandem", href: "#" },
  items = DEFAULT_ITEMS,
  primaryAction = { label: "Donate", href: "#" },
  iconAction = { label: "Contact us", href: "#" },
  accent,
  accentForeground = "#fff",
  className,
}: {
  brand?: Brand;
  items?: CapsuleNavItem[];
  primaryAction?: Action | null;
  /** A round, icon-only link. Its label is read out, not shown. */
  iconAction?: IconAction | null;
  /** Any CSS colour for the main action. Neutral when unset. */
  accent?: string;
  accentForeground?: string;
  className?: string;
}) {
  const accentStyle = accent
    ? ({ "--capsule-accent": accent, "--capsule-accent-fg": accentForeground } as CSSProperties)
    : undefined;

  return (
    <header
      className={cn("group/capsule @container relative isolate w-full", className)}
      style={accentStyle}
    >
      {/* The row lets the pointer through to the open card underneath it. */}
      <div className="pointer-events-none mx-auto flex h-16 max-w-6xl items-center justify-between gap-3 px-4 *:pointer-events-auto @3xl:px-6">
        <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 ?? <ShapesIcon weight="fill" />}
          </span>
          {brand.label}
        </a>

        <div className="flex items-center gap-1.5">
          <nav aria-label="Main" className="hidden @2xl:block">
            <CapsuleNavLinks items={items} />
          </nav>
          {primaryAction && (
            <Button
              size="lg"
              className={cn(
                "h-10 px-4",
                // In light mode the open card is the same dark as this button,
                // so it lifts to a translucent fill while a card is open.
                !accent &&
                  "group-has-[[data-capsule-open]]/capsule:bg-white/15 group-has-[[data-capsule-open]]/capsule:shadow-none dark:group-has-[[data-capsule-open]]/capsule:bg-neutral-200",
                accent &&
                  "bg-(--capsule-accent) text-(--capsule-accent-fg) shadow-none hover:bg-(--capsule-accent) hover:brightness-95 dark:bg-(--capsule-accent) dark:hover:bg-(--capsule-accent)",
              )}
              render={<a href={primaryAction.href} />}
            >
              {primaryAction.label}
            </Button>
          )}
          {iconAction && (
            <Button
              variant="outline"
              size="icon-lg"
              aria-label={iconAction.label}
              className="text-muted-foreground hover:text-foreground [&_svg]:size-4.5"
              render={<a href={iconAction.href} />}
            >
              {iconAction.icon ?? <EnvelopeSimpleIcon />}
            </Button>
          )}
          <CapsuleNavMobileMenu items={items} className="@2xl:hidden" />
        </div>
      </div>
    </header>
  );
}
"use client";

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

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

export type CapsuleNavLink = { label: string; href: string };
export type CapsuleNavMenuLink = CapsuleNavLink & {
  /** A small chip beside the label, e.g. "New". */
  badge?: string;
  /** Shown in a tilted tile while the link is hovered or focused, e.g. an image. */
  preview?: ReactNode;
};
export type CapsuleNavMenu = { label: string; items: CapsuleNavMenuLink[] };
export type CapsuleNavItem = CapsuleNavLink | CapsuleNavMenu;

const isMenu = (item: CapsuleNavItem): item is CapsuleNavMenu => "items" in item;

const EASE = [0.23, 1, 0.32, 1] as const;
const CLOSE_DELAY = 140;

// The card hangs from a pivot above the top-right corner and rests at a tilt.
const TILT = -6;
const SWING = { type: "spring", duration: 0.7, bounce: 0.25 } as const;
const SWING_OUT = { duration: 0.35, ease: [0.5, 0, 0.75, 0] } as const;
const PREVIEW_TILTS = [5, -4, 7, -6, 3];
// Set --capsule-scrim on the header to wash the page with another colour.
const SCRIM =
  "0 0 0 200vmax var(--capsule-scrim, color-mix(in oklab, var(--background) 55%, transparent))";

const LINK_CLASS =
  "flex h-10 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";

/**
 * The capsule of links. An item with children swings a large, dark, tilted card
 * down behind the header, with its links set big and an optional preview tile.
 */
export function CapsuleNavLinks({
  items,
  className,
}: {
  items: CapsuleNavItem[];
  className?: string;
}) {
  const rootRef = useRef<HTMLDivElement>(null);
  const triggerRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const closeTimer = useRef<number | undefined>(undefined);
  // A hover has already opened the panel, so the click that follows keeps it.
  const openedByHover = useRef(false);
  const [open, setOpen] = useState<number | null>(null);
  const baseId = useId();

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

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

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

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

  const itemClass =
    "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 after:absolute after:inset-x-0 after:-inset-y-1 hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-[1.5px] focus-visible:ring-ring/60 aria-expanded:bg-foreground/[0.06] aria-expanded:text-foreground dark:hover:bg-foreground/10 dark:aria-expanded:bg-foreground/10";

  return (
    <div
      ref={rootRef}
      data-capsule-open={open !== null ? "" : undefined}
      className={className}
      onPointerEnter={cancelClose}
      onPointerLeave={leave}
      onBlur={(e) => {
        if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(null);
      }}
    >
      <ul className="flex h-10 items-center rounded-full bg-card px-1 text-card-foreground shadow-(--custom-shadow)">
        {items.map((item, index) => {
          if (!isMenu(item)) {
            return (
              <li key={item.label}>
                <a
                  href={item.href}
                  className={itemClass}
                  onPointerEnter={(e) => {
                    if (e.pointerType === "mouse") setOpen(null);
                  }}
                  onFocus={() => setOpen(null)}
                >
                  {item.label}
                </a>
              </li>
            );
          }

          const isOpen = open === index;
          return (
            <li key={item.label}>
              <button
                ref={(el) => {
                  triggerRefs.current[index] = el;
                }}
                type="button"
                aria-expanded={isOpen}
                aria-controls={`${baseId}-menu-${index}`}
                className={cn(itemClass, "cursor-pointer pr-2.5")}
                onPointerEnter={(e) => {
                  if (e.pointerType !== "mouse") return;
                  cancelClose();
                  openedByHover.current = !isOpen;
                  setOpen(index);
                }}
                onFocus={() => setOpen((o) => (o === index ? o : null))}
                onClick={() => {
                  if (openedByHover.current && isOpen) {
                    openedByHover.current = false;
                    return;
                  }
                  setOpen(isOpen ? null : index);
                }}
                onKeyDown={(e) => {
                  if (e.key !== "ArrowDown") return;
                  e.preventDefault();
                  setOpen(index);
                  requestAnimationFrame(() =>
                    document
                      .getElementById(`${baseId}-menu-${index}`)
                      ?.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>
            </li>
          );
        })}
      </ul>

      {/* Sits behind the header row. The clip trims the cards at the top and
          sides of the header and lets them hang below it; clipping the x axis
          as overflow too keeps the off-screen part from adding sideways scroll. */}
      <div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-0 overflow-x-clip [clip-path:inset(0_0_-9999px_0)]">
        <AnimatePresence>
          {open !== null && (
            // A zero-size box whose shadow washes out the page without adding scroll.
            <motion.div
              aria-hidden="true"
              className="absolute top-0 left-0 size-0"
              style={{ boxShadow: SCRIM }}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.25 }}
            />
          )}
        </AnimatePresence>
        {items.map(
          (item, index) =>
            isMenu(item) && (
              <AnimatePresence key={item.label}>
                {open === index && (
                  <MenuCard
                    id={`${baseId}-menu-${index}`}
                    items={item.items}
                    onNavigate={() => setOpen(null)}
                  />
                )}
              </AnimatePresence>
            ),
        )}
      </div>
    </div>
  );
}

function MenuCard({
  id,
  items,
  onNavigate,
}: {
  id: string;
  items: CapsuleNavMenuLink[];
  onNavigate: () => void;
}) {
  const reduceMotion = useReducedMotion();
  const [active, setActive] = useState<number | null>(null);
  const hasPreviews = items.some((item) => item.preview);
  const preview = active === null ? null : items[active].preview;

  return (
    // The negative offsets and the matching padding keep the card's top and
    // right edges off screen, so the tilt never shows a gap.
    <motion.div
      id={id}
      className="pointer-events-auto absolute -top-32 -right-24 w-max origin-top-right rounded-[2rem] bg-primary pt-58 pr-44 pb-12 pl-12 text-primary-foreground shadow-(--custom-shadow-primary) dark:bg-secondary dark:text-secondary-foreground dark:shadow-(--custom-shadow)"
      initial={reduceMotion ? { opacity: 0, rotate: TILT } : { rotate: 24, y: "-100%" }}
      animate={{ opacity: 1, rotate: TILT, y: 0 }}
      exit={reduceMotion ? { opacity: 0 } : { rotate: 14, y: "-110%", transition: SWING_OUT }}
      transition={reduceMotion ? { duration: 0.15 } : SWING}
    >
      <div className="flex items-center gap-10">
        <ul className="group/list flex flex-col">
          {items.map((item, index) => (
            <li key={item.label}>
              <a
                href={item.href}
                onClick={onNavigate}
                onPointerEnter={() => setActive(index)}
                onFocus={() => setActive(index)}
                className="flex items-center gap-3 rounded-lg py-1.5 text-2xl leading-none font-medium tracking-tight outline-none transition-opacity duration-200 group-hover/list:opacity-50 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[1.5px] focus-visible:ring-white/30 @5xl:text-3xl"
              >
                {item.label}
                {item.badge && (
                  <span className="rounded-full bg-white/10 px-2 py-1 text-xs leading-none tracking-normal opacity-80">
                    {item.badge}
                  </span>
                )}
              </a>
            </li>
          ))}
        </ul>
        {hasPreviews && (
          <div aria-hidden="true" className="relative hidden aspect-4/5 w-36 @4xl:block">
            <AnimatePresence>
              {preview && active !== null && (
                <motion.div
                  key={active}
                  className="absolute inset-0 overflow-hidden rounded-2xl bg-white/8 ring-1 ring-white/10 ring-inset"
                  initial={
                    reduceMotion
                      ? { opacity: 0 }
                      : { opacity: 0, scale: 0.94, rotate: PREVIEW_TILTS[active % 5] + 10 }
                  }
                  animate={{ opacity: 1, scale: 1, rotate: PREVIEW_TILTS[active % 5] }}
                  exit={{ opacity: 0, transition: { duration: 0.15, delay: 0.1 } }}
                  transition={{ duration: 0.3, ease: EASE }}
                >
                  {preview}
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        )}
      </div>
    </motion.div>
  );
}

/** The round menu button shown when the container is too narrow for the links. */
export function CapsuleNavMobileMenu({
  items,
  className,
}: {
  items: CapsuleNavItem[];
  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);
  const iconClass =
    "absolute size-4.5 transition-[opacity,scale] duration-200 ease-snappy motion-reduce:transition-none";

  return (
    <div ref={rootRef} className={className}>
      <Button
        ref={buttonRef}
        variant="outline"
        size="icon-lg"
        aria-label={open ? "Close menu" : "Open menu"}
        aria-expanded={open}
        aria-controls={panelId}
        onClick={() => setOpen((o) => !o)}
        className="relative text-foreground"
      >
        <ListIcon className={cn(iconClass, open ? "scale-75 opacity-0" : "scale-100 opacity-100")} />
        <XIcon className={cn(iconClass, open ? "scale-100 opacity-100" : "scale-75 opacity-0")} />
      </Button>
      <AnimatePresence>
        {open && (
          <motion.nav
            id={panelId}
            aria-label="Main"
            className="absolute inset-x-4 top-full z-20 -mt-1 origin-top-right rounded-[20px] bg-card p-1.5 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): ReactNode =>
                isMenu(item) ? (
                  <li key={item.label} className="pt-2 not-first:mt-1 not-first:border-t not-first:border-border">
                    <p className="px-3 pb-1 text-xs font-medium text-muted-foreground">
                      {item.label}
                    </p>
                    <ul>
                      {item.items.map((sub) => (
                        <li key={sub.label}>
                          <a href={sub.href} onClick={close} className={LINK_CLASS}>
                            {sub.label}
                          </a>
                        </li>
                      ))}
                    </ul>
                  </li>
                ) : (
                  <li key={item.label} className="not-first:mt-1 not-first:border-t not-first:border-border not-first:pt-1">
                    <a href={item.href} onClick={close} className={LINK_CLASS}>
                      {item.label}
                    </a>
                  </li>
                ),
              )}
            </ul>
          </motion.nav>
        )}
      </AnimatePresence>
    </div>
  );
}

Search concepts

Search for a command to run...