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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| items{ label, href?, onClick? }[] | { label, href?, onClick? }[] | Root first. The last item is the current page and is never a link. |
| maxItemsnumber | number= 4 | Past this many, the middle folds into a “…” menu. |
| itemsBefore / itemsAfternumber | number= 1 / 2 | How many stay showing at each end when folded. |
| linkLinkComponent | LinkComponent | Your 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.
"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>
);
}
"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>
);
}
"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}/`);
}