← All components

Breadcrumb

The trail of pages above this one, with a long middle folded into a small menu.

The idea

An ordered list inside a nav landmark, because the order is the meaning: each crumb contains the next. The last is the page you are on, in full ink and not a link — a link to where you already are is a trap with an underline. A deep trail folds its middle into one button rather than wrapping or truncating every crumb, since the root and the last two are the ones a reader uses, and the rest open in the same glass menu as every popover here.

Composes DropMenu.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Breadcrumb — usage12 lines
import { Breadcrumb } from "mihirmodi-ui";

// Past four crumbs the middle folds into a "…" menu.
<Breadcrumb
  items={[
    { label: "Northwind", href: "/" },
    { label: "Workspaces", href: "/workspaces" },
    { label: "Research", href: "/workspaces/research" },
    { label: "Q3", href: "/workspaces/research/q3" },
    { label: "Churn review" }, // the current page: no href
  ]}
/>

Props

PropNotes
items{ label, href?, onClick? }[]Root first. The last item is the current page and is never a link.
maxItemsnumberPast this many, the middle folds into a “…” menu.
itemsBefore / itemsAfternumberHow many stay showing at each end when folded.
linkLinkComponentYour router's Link, if there is no UIProvider above.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/navigation/Breadcrumb.tsx229 lines
"use client";

import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { DropMenu } from "../DropMenu.js";
import { glyphProps } from "../../lib/glyph.js";
import { useLinkComponent, type LinkComponent } from "../../lib/link.js";

export type BreadcrumbItem = {
  label: ReactNode;
  /** Where it goes. The last item is the page you are on and is never a link. */
  href?: string;
  /** For a crumb that is a button rather than a route. */
  onClick?: () => void;
};

export type BreadcrumbProps = {
  /** From the root to the current page, in that order. */
  items: BreadcrumbItem[];
  /** Past this many, the middle folds into a "…" menu. */
  maxItems?: number;
  /** How many to keep at the start when folded. */
  itemsBefore?: number;
  /** How many to keep at the end when folded, the current page included. */
  itemsAfter?: number;
  /** The landmark's name. */
  label?: string;
  link?: LinkComponent;
  className?: string;
};

/** A slim chevron, drawn at the text's x-height so it reads as punctuation, not as an icon. */
function Separator() {
  return (
    <svg {...glyphProps} strokeWidth="1.6" className="h-3 w-3 shrink-0 text-faint">
      <path d="M9.5 6l6 6-6 6" />
    </svg>
  );
}

/**
 * Where this page sits, as the trail of pages above it.
 *
 * It is a `nav` landmark around an ordered list, because the order is the
 * meaning: each crumb contains the next. Earlier crumbs are links in the muted
 * tier that come up to full ink under the pointer; the last is the page you are
 * on — `aria-current="page"`, full ink and medium weight, and not a link,
 * because a link to where you already are is a trap with an underline.
 *
 * A deep trail folds its middle into one "…" button rather than wrapping onto a
 * second line or truncating every crumb: the root and the last two are the
 * ones a reader uses, and the rest are a press away in the same small glass
 * menu every popover here opens with. The separators are drawn, not typed, so
 * a screen reader hears the list's structure instead of "slash" between every
 * item.
 */
export function Breadcrumb({
  items,
  maxItems = 4,
  itemsBefore = 1,
  itemsAfter = 2,
  label = "Breadcrumb",
  link,
  className = "",
}: BreadcrumbProps) {
  const Link = useLinkComponent(link);
  const fold = items.length > maxItems && itemsBefore + itemsAfter < items.length;
  const head = fold ? items.slice(0, itemsBefore) : items;
  const hidden = fold ? items.slice(itemsBefore, items.length - itemsAfter) : [];
  const tail = fold ? items.slice(items.length - itemsAfter) : [];
  const last = items.length - 1;

  const crumb = (it: BreadcrumbItem, i: number) => {
    if (i === last) {
      return (
        <span aria-current="page" className="truncate font-medium text-gray-1000">
          {it.label}
        </span>
      );
    }
    const look = "truncate rounded-[6px] text-muted transition-colors duration-150 ease-out hover:text-gray-1000";
    if (it.href) {
      return (
        <Link href={it.href} onClick={it.onClick} className={look}>
          {it.label}
        </Link>
      );
    }
    if (it.onClick) {
      return (
        <button type="button" onClick={it.onClick} className={look}>
          {it.label}
        </button>
      );
    }
    return <span className="truncate text-muted">{it.label}</span>;
  };

  const row = (it: BreadcrumbItem, i: number) => (
    <li key={i} className="flex min-w-0 items-center gap-1.5">
      {i > 0 && <Separator />}
      {crumb(it, i)}
    </li>
  );

  return (
    <nav aria-label={label} className={className}>
      <ol className="flex flex-wrap items-center gap-1.5 text-[13px] leading-none">
        {head.map((it, i) => row(it, i))}
        {fold && (
          <li className="flex items-center gap-1.5">
            <Separator />
            <Folded items={hidden} Link={Link} />
          </li>
        )}
        {tail.map((it, k) => row(it, items.length - itemsAfter + k))}
      </ol>
    </nav>
  );
}

/** The folded middle: a "…" button and the crumbs it stands for, in a small glass menu. */
function Folded({ items, Link }: { items: BreadcrumbItem[]; Link: LinkComponent | "a" }) {
  const [open, setOpen] = useState(false);
  const menuId = useId();
  const root = useRef<HTMLSpanElement>(null);
  const btn = useRef<HTMLButtonElement>(null);
  const menu = useRef<HTMLUListElement>(null);
  const rows = () => Array.from(menu.current?.querySelectorAll<HTMLElement>("a, button") ?? []);

  const close = (refocus: boolean) => {
    setOpen(false);
    if (refocus) btn.current?.focus();
  };

  useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) setOpen(false);
    };
    window.addEventListener("pointerdown", onDown);
    return () => window.removeEventListener("pointerdown", onDown);
  }, [open]);

  // Opening from the keyboard lands on the first crumb, so the arrows have somewhere to start.
  const [focusFirst, setFocusFirst] = useState(false);
  useEffect(() => {
    if (open && focusFirst) {
      rows()[0]?.focus();
      setFocusFirst(false);
    }
  }, [open, focusFirst]);

  const onKey = (e: KeyboardEvent) => {
    const all = rows();
    const at = all.findIndex((r) => r === document.activeElement);
    if (e.key === "Escape" && open) {
      e.preventDefault();
      close(true);
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      if (!open) {
        setOpen(true);
        setFocusFirst(true);
      } else all[Math.min(all.length - 1, at + 1)]?.focus();
    } else if (e.key === "ArrowUp" && open) {
      e.preventDefault();
      if (at <= 0) btn.current?.focus();
      else all[at - 1]?.focus();
    } else if (e.key === "Tab" && open) {
      close(false);
    }
  };

  const look =
    "flex h-8 w-full items-center rounded-[8px] px-2.5 text-left text-[13px] text-gray-1000 transition-colors duration-150 hover:bg-gray-alpha-200 focus-visible:bg-gray-alpha-200";

  return (
    <span ref={root} className="relative inline-flex" onKeyDown={onKey}>
      <button
        ref={btn}
        type="button"
        aria-label={`Show ${items.length} more`}
        aria-expanded={open}
        aria-controls={menuId}
        onClick={() => setOpen((o) => !o)}
        className={`flex h-6 min-w-6 items-center justify-center rounded-[6px] px-1 text-muted transition-[background-color,color,transform] duration-150 ease-geist hover:bg-gray-alpha-200 hover:text-gray-1000 active:scale-[0.97] motion-reduce:transition-none ${
          open ? "bg-gray-alpha-200 text-gray-1000" : ""
        }`}
      >
        <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className="h-3.5 w-3.5">
          <circle cx="5.5" cy="12" r="1.5" />
          <circle cx="12" cy="12" r="1.5" />
          <circle cx="18.5" cy="12" r="1.5" />
        </svg>
      </button>
      <DropMenu id={menuId} open={open} placement="down" origin="origin-top-left" className="absolute left-0 top-full z-50 mt-1.5">
        <ul ref={menu} className="glass-menu dock-shadow w-max min-w-[160px] max-w-[260px] rounded-[12px] border-[0.5px] border-gray-alpha-400 p-1">
          {items.map((it, i) => (
            <li key={i}>
              {it.href ? (
                <Link
                  href={it.href}
                  onClick={() => {
                    it.onClick?.();
                    setOpen(false);
                  }}
                  className={look}
                >
                  <span className="truncate">{it.label}</span>
                </Link>
              ) : (
                <button
                  type="button"
                  onClick={() => {
                    it.onClick?.();
                    close(true);
                  }}
                  className={look}
                >
                  <span className="truncate">{it.label}</span>
                </button>
              )}
            </li>
          ))}
        </ul>
      </DropMenu>
    </span>
  );
}
packages/ui/src/components/DropMenu.tsx105 lines
"use client";

import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";

/* Two springs of deliberately different character — the effect comes from letting
   them disagree. The grow axis is light and underdamped, so it sails past its
   final size; the cross axis is stiffer, heavier-damped and slower, so it arrives
   late. Through the overshoot the panel is briefly taller and narrower than it
   ends up, then it settles: squash and stretch out of physics rather than out of
   a hand-authored timeline. */
const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;

/* Closing is quick and flat. A bounce on the way out reads as indecision — the
   panel looks like it is not sure it wants to go. Open and close are not mirror
   images of each other, and should not be. */
const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

export type DropPlacement = "up" | "down";

export type DropMenuProps = {
  open: boolean;
  /** Which way it grows. `up` for a bottom dock, `down` for a header. */
  placement?: DropPlacement;
  /** `transform-origin` utility. Defaults to the edge the menu grows from. */
  origin?: string;
  id?: string;
  className?: string;
  style?: CSSProperties;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
  children: ReactNode;
};

/**
 * The open/close motion for a floating menu that grows out of a trigger sitting
 * against one edge — a drop-up over a dock, a sheet over a tab bar, a popover
 * under a header.
 *
 * It animates a wrapper and nothing else, so what you put inside is yours: give
 * the child the glass, the border and the padding. That split is deliberate — a
 * `backdrop-filter` on an element that is itself being transformed is weakened
 * or dropped entirely by most engines, so the blur must live on a child that is
 * holding still.
 *
 * The panel mounts only while open, which keeps a closed menu out of the tab
 * order and off the paint. The wrapper is always present, so a trigger's
 * `aria-controls` still resolves to something. Under reduced motion the elastic
 * scaling drops out and a plain fade remains.
 *
 * You own `open` — this is not a popover with its own state. Escape handling,
 * click-outside and hover intent belong to the trigger, which knows what it is.
 */
export function DropMenu({
  open,
  placement = "up",
  origin,
  id,
  className = "",
  style,
  onMouseEnter,
  onMouseLeave,
  children,
}: DropMenuProps) {
  const reduce = useReducedMotion();
  // Start nudged back toward the trigger, so it reads as coming out of it.
  const away = placement === "up" ? 4 : -4;
  const anchor = origin ?? (placement === "up" ? "origin-bottom" : "origin-top");

  return (
    <div
      id={id}
      style={style}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
      // Empty and inert when closed, so it can never sit over the page as a trap.
      className={`${className} ${open ? "" : "pointer-events-none"}`}
    >
      <LazyMotion features={domAnimation} strict>
        <AnimatePresence>
          {open && (
            <m.div
              className={anchor}
              initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }}
              animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
              exit={
                reduce
                  ? { opacity: 0, transition: CLOSE }
                  : { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }
              }
              transition={
                reduce
                  ? { duration: 0.15 }
                  : { scaleY: GROW, y: GROW, scaleX: CROSS, opacity: { duration: 0.14, ease: "easeOut" } }
              }
            >
              {children}
            </m.div>
          )}
        </AnimatePresence>
      </LazyMotion>
    </div>
  );
}
packages/ui/src/lib/link.tsx49 lines
"use client";

import { createContext, useContext, type AnchorHTMLAttributes, type ComponentType, type ReactNode } from "react";

/**
 * Anything that renders an `<a>` given an `href`. Next's `Link`, React Router's
 * `Link` (rename `to`), TanStack's, or nothing at all.
 */
export type LinkComponent = ComponentType<
  { href: string; children?: ReactNode } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">
>;

const LinkContext = createContext<LinkComponent | null>(null);

/**
 * Teaches every component beneath it how to route.
 *
 * Without it the components render plain `<a>` tags, which navigate correctly
 * but reload the page — fine for a marketing site, wrong inside an app. Rather
 * than depend on a router (which would tie this package to one framework), the
 * dependency is inverted: you hand in the link component you already use.
 *
 *     import Link from "next/link";
 *     <UIProvider link={Link}>{children}</UIProvider>
 *
 * Every component also takes a `link` prop, so a single odd one out does not
 * need its own provider.
 */
export function UIProvider({ link, children }: { link?: LinkComponent; children: ReactNode }) {
  return <LinkContext.Provider value={link ?? null}>{children}</LinkContext.Provider>;
}

/** The nearest provided link component, or `"a"` when there is none. */
export function useLinkComponent(override?: LinkComponent): LinkComponent | "a" {
  const ctx = useContext(LinkContext);
  return override ?? ctx ?? "a";
}

/**
 * Whether a destination is the current one — React Router's NavLink semantics,
 * kept: "/" matches exactly, everything else matches itself and its descendants,
 * so a nav entry stays lit while you are reading one of its children.
 *
 * Exported because the dock takes `active` as a boolean rather than computing it:
 * the dock has no idea what your routes mean, but you do.
 */
export function isCurrentPath(pathname: string, to: string): boolean {
  return to === "/" ? pathname === "/" : pathname === to || pathname.startsWith(`${to}/`);
}