Animated Breadcrumb
Composable breadcrumb navigation with soft path transitions, a hoverable overflow dropdown for long trails, custom separators, and router-link support.
Preview
TSXcomponents/previews/motion/breadcrumb.preview.tsx
"use client";
import { ArrowUpRight, Folder, Home } from "lucide-react";
import { useLayoutEffect, useRef, useState } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/motion/breadcrumb";
const PATH = ["Workspace", "Projects", "Website", "Design system", "Components", "Navigation", "Breadcrumb"];
export function BreadcrumbPreview() {
const [depth, setDepth] = useState(5);
const currentRef = useRef<HTMLSpanElement>(null);
const restoreFocus = useRef<number | null>(null);
useLayoutEffect(() => {
if (restoreFocus.current === depth) {
currentRef.current?.focus();
restoreFocus.current = null;
}
}, [depth]);
return (
<div className="w-full max-w-lg space-y-8 px-4">
<Breadcrumb className="min-h-[4.25rem] sm:min-h-8">
<BreadcrumbList maxItems={3}>
{PATH.slice(0, depth + 1).map((label, index) => (
<BreadcrumbItem key={label}>
{index > 0 && <BreadcrumbSeparator />}
{index === depth ? (
<BreadcrumbPage ref={currentRef} tabIndex={-1}>
{index === 0 && <Home aria-hidden="true" />}
{label}
</BreadcrumbPage>
) : (
<BreadcrumbLink
href={`#${label.toLowerCase()}`}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
event.preventDefault();
restoreFocus.current = index;
setDepth(index);
}}
>
{index === 0 && <Home aria-hidden="true" />}
{label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="min-h-20 border-t border-border/60 pt-5">
{depth < PATH.length - 1 ? (
<button
type="button"
onClick={() => {
restoreFocus.current = depth + 1;
setDepth((value) => Math.min(value + 1, PATH.length - 1));
}}
className="flex w-full items-center gap-3 rounded-lg border border-border/60 px-4 py-3 text-start text-sm transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Folder aria-hidden="true" className="size-4 text-muted-foreground" />
<span className="flex-1">{PATH[depth + 1]}</span>
<ArrowUpRight aria-hidden="true" className="size-3.5 text-muted-foreground" />
</button>
) : (
<p className="py-3 text-center text-sm text-muted-foreground">Choose a parent path to go back.</p>
)}
</div>
</div>
);
}
TSXcomponents/motion/breadcrumb.tsx
"use client";
// beui.dev/components/motion/breadcrumb
import { ChevronRight, Ellipsis } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useIsPresent,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
Children,
forwardRef,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
useId,
type ComponentPropsWithRef,
type ReactElement,
} from "react";
import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger } from "@/components/motion/popover-morph";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type BreadcrumbProps = ComponentPropsWithRef<"nav">;
/** A navigation landmark. Keep it mounted while the route changes. */
export function Breadcrumb({ className, children, ...props }: BreadcrumbProps) {
const id = useId();
return (
<nav aria-label="Breadcrumb" {...props} className={cn("min-w-0", className)}>
<LayoutGroup id={id}>{children}</LayoutGroup>
</nav>
);
}
export type BreadcrumbListProps = ComponentPropsWithRef<"ol"> & {
/** Maximum visible slots, including the ellipsis. Minimum 3; Infinity disables collapsing. */
maxItems?: number;
/** Accessible label for the hidden ancestor disclosure. */
overflowLabel?: string;
};
/** Pass keyed BreadcrumbItems directly so entering and leaving routes animate. */
export function BreadcrumbList({ className, children, maxItems = 4, overflowLabel = "Show hidden paths", ...props }: BreadcrumbListProps) {
const items = Children.toArray(children);
const limit = Number.isFinite(maxItems) ? Math.max(3, Math.floor(maxItems)) : 4;
const collapse = maxItems !== Infinity && items.length > limit;
const tailCount = limit - 2;
const visible = collapse ? [
items[0],
<BreadcrumbItem key="breadcrumb-overflow">
<BreadcrumbSeparator />
<BreadcrumbEllipsis label={overflowLabel}>
{items.slice(1, -tailCount)}
</BreadcrumbEllipsis>
</BreadcrumbItem>,
...items.slice(-tailCount),
] : items;
return (
<ol
{...props}
className={cn("relative flex flex-wrap items-center gap-x-1 gap-y-1 text-sm", className)}
>
<AnimatePresence initial={false} mode="popLayout">{visible}</AnimatePresence>
</ol>
);
}
export type BreadcrumbItemProps = HTMLMotionProps<"li">;
/** Use a stable route key; put its optional separator inside this item. */
export const BreadcrumbItem = forwardRef<HTMLLIElement, BreadcrumbItemProps>(
function BreadcrumbItem({ className, style, children, ...props }, ref) {
const reduce = useReducedMotion();
const present = useIsPresent();
const itemRef = useRef<HTMLLIElement>(null);
useLayoutEffect(() => {
const item = itemRef.current;
if (!item || !present) return;
const measure = () => {
// popLayout snapshots offsetWidth (integer pixels). Retain the exact
// width so a fractional-pixel loss cannot wrap the final character.
item.style.setProperty("--breadcrumb-exit-width", `${item.getBoundingClientRect().width}px`);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(item);
return () => observer.disconnect();
}, [present]);
const hidden = { opacity: 0, y: reduce ? 0 : 6 };
return (
<motion.li
ref={(node) => {
itemRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
}}
layout={reduce ? false : "position"}
initial={hidden}
animate={{ opacity: 1, y: 0 }}
exit={hidden}
transition={{ duration: 0.2, ease: EASE_OUT, layout: SPRING_LAYOUT }}
{...props}
inert={!present}
aria-hidden={!present || undefined}
style={{
...style,
minWidth: present ? style?.minWidth : "var(--breadcrumb-exit-width)",
pointerEvents: present ? style?.pointerEvents : "none",
}}
className={cn("relative inline-flex min-w-0 max-w-full items-center gap-1", className)}
>
{children}
</motion.li>
);
},
);
export type BreadcrumbLinkProps = ComponentPropsWithRef<"a"> & {
/** Render your router's Link, spreading these props onto it. */
render?: (props: ComponentPropsWithRef<"a">) => ReactElement;
};
export function BreadcrumbLink({ className, render, ...props }: BreadcrumbLinkProps) {
const linkProps = {
...props,
className: cn(
"inline-flex min-h-8 min-w-0 items-center gap-1.5 rounded-md px-2 font-medium text-muted-foreground transition-colors duration-150 hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&>svg]:size-3.5 [&>svg]:shrink-0",
className,
),
};
return render ? render(linkProps) : <a {...linkProps} />;
}
export type BreadcrumbPageProps = ComponentPropsWithRef<"span">;
export function BreadcrumbPage({ className, children, ...props }: BreadcrumbPageProps) {
return (
<span
{...props}
aria-current="page"
className={cn(
"relative isolate inline-flex min-h-8 min-w-0 items-center gap-1.5 rounded-md px-2 font-medium text-foreground [overflow-wrap:anywhere] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&>svg]:size-3.5 [&>svg]:shrink-0",
className,
)}
>
{children}
</span>
);
}
export type BreadcrumbSeparatorProps = ComponentPropsWithRef<"span">;
/** Decorative separator, placed inside the following BreadcrumbItem. */
export function BreadcrumbSeparator({ className, children, ...props }: BreadcrumbSeparatorProps) {
return (
<span
{...props}
aria-hidden="true"
data-breadcrumb-separator=""
className={cn("inline-flex shrink-0 items-center text-muted-foreground/50 [&>svg]:size-3.5 rtl:rotate-180", className)}
>
{children ?? <ChevronRight />}
</span>
);
}
export interface BreadcrumbEllipsisProps {
/** Hidden BreadcrumbItems, in path order. */
children: ReactNode;
className?: string;
label?: string;
}
/** Hover disclosure with click/touch toggle and keyboard access to ancestor links. */
export function BreadcrumbEllipsis({ children, className, label = "Show hidden paths" }: BreadcrumbEllipsisProps) {
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState<{ align: "start" | "end"; side: "top" | "bottom"; width: number }>({ align: "start", side: "bottom", width: 224 });
const canHover = useHoverCapable();
const present = useIsPresent();
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLOListElement>(null);
const focusOnOpen = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const rect = trigger.current?.getBoundingClientRect();
if (!rect) return;
const right = window.innerWidth - rect.left - 8;
const left = rect.right - 8;
const align = right < 224 && left > right ? "end" : "start";
const below = window.innerHeight - rect.bottom;
setPlacement({
align,
side: below < 280 && rect.top > below ? "top" : "bottom",
width: Math.max(32, Math.min(224, align === "start" ? right : left)),
});
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [open]);
const cancelClose = () => {
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
closeTimer.current = null;
};
const leave = () => {
cancelClose();
// Allow the pointer to cross the gap between the trigger and portal.
closeTimer.current = setTimeout(() => {
if (!panel.current?.contains(document.activeElement) && document.activeElement !== trigger.current) setOpen(false);
}, 160);
};
useEffect(() => () => {
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
}, []);
useEffect(() => {
if (!open) return;
const onFocus = (event: FocusEvent) => {
if (event.target instanceof Node && event.target !== trigger.current && !panel.current?.contains(event.target)) setOpen(false);
};
document.addEventListener("focusin", onFocus);
return () => document.removeEventListener("focusin", onFocus);
}, [open]);
return (
<MorphPopover open={open && present} onOpenChange={setOpen} className={cn(className)}>
<span
onPointerEnter={(event) => {
cancelClose();
if (canHover && event.pointerType === "mouse") {
focusOnOpen.current = false;
setOpen(true);
}
}}
onPointerLeave={leave}
>
<MorphPopoverTrigger>
<button
ref={trigger}
type="button"
aria-label={label}
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => { focusOnOpen.current = event.detail === 0; }}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
focusOnOpen.current = true;
setOpen(true);
panel.current?.querySelector<HTMLElement>("a[href],button")?.focus();
}
}}
>
<Ellipsis aria-hidden="true" className="size-4" />
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align={placement.align} side={placement.side} radius={10} sideOffset={6} className="p-1.5">
<BreadcrumbOverflowPaths
ref={panel}
style={{ width: placement.width - 14 }}
focusOnOpen={focusOnOpen}
onPointerEnter={() => { cancelClose(); setOpen(true); }}
onPointerLeave={leave}
onClick={(event) => {
if ((event.target as Element).closest("a[href]")) setOpen(false);
}}
>
{children}
</BreadcrumbOverflowPaths>
</MorphPopoverContent>
</span>
</MorphPopover>
);
}
function BreadcrumbOverflowPaths({ focusOnOpen, ref, ...props }: ComponentPropsWithRef<"ol"> & { focusOnOpen: { current: boolean } }) {
const localRef = useRef<HTMLOListElement>(null);
useLayoutEffect(() => {
if (!focusOnOpen.current) return;
const focus = () => localRef.current?.querySelector<HTMLElement>("a[href],button")?.focus();
focus();
// The portal becomes visible after its parent's layout measurement.
const frame = requestAnimationFrame(() => {
focus();
focusOnOpen.current = false;
});
return () => cancelAnimationFrame(frame);
}, [focusOnOpen]);
return (
<ol
{...props}
ref={(node) => {
localRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
}}
className="flex max-h-64 flex-col gap-0.5 overflow-y-auto [&>li]:w-full [&_a]:w-full [&_a]:py-1 [&_a]:[overflow-wrap:anywhere] [&_[data-breadcrumb-separator]]:hidden"
/>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/breadcrumbbreadcrumbbreadcrumb
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/breadcrumb.tsx
"use client";
// beui.dev/components/motion/breadcrumb
import { ChevronRight, Ellipsis } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useIsPresent,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
Children,
forwardRef,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
useId,
type ComponentPropsWithRef,
type ReactElement,
} from "react";
import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger } from "@/components/motion/popover-morph";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type BreadcrumbProps = ComponentPropsWithRef<"nav">;
/** A navigation landmark. Keep it mounted while the route changes. */
export function Breadcrumb({ className, children, ...props }: BreadcrumbProps) {
const id = useId();
return (
<nav aria-label="Breadcrumb" {...props} className={cn("min-w-0", className)}>
<LayoutGroup id={id}>{children}</LayoutGroup>
</nav>
);
}
export type BreadcrumbListProps = ComponentPropsWithRef<"ol"> & {
/** Maximum visible slots, including the ellipsis. Minimum 3; Infinity disables collapsing. */
maxItems?: number;
/** Accessible label for the hidden ancestor disclosure. */
overflowLabel?: string;
};
/** Pass keyed BreadcrumbItems directly so entering and leaving routes animate. */
export function BreadcrumbList({ className, children, maxItems = 4, overflowLabel = "Show hidden paths", ...props }: BreadcrumbListProps) {
const items = Children.toArray(children);
const limit = Number.isFinite(maxItems) ? Math.max(3, Math.floor(maxItems)) : 4;
const collapse = maxItems !== Infinity && items.length > limit;
const tailCount = limit - 2;
const visible = collapse ? [
items[0],
<BreadcrumbItem key="breadcrumb-overflow">
<BreadcrumbSeparator />
<BreadcrumbEllipsis label={overflowLabel}>
{items.slice(1, -tailCount)}
</BreadcrumbEllipsis>
</BreadcrumbItem>,
...items.slice(-tailCount),
] : items;
return (
<ol
{...props}
className={cn("relative flex flex-wrap items-center gap-x-1 gap-y-1 text-sm", className)}
>
<AnimatePresence initial={false} mode="popLayout">{visible}</AnimatePresence>
</ol>
);
}
export type BreadcrumbItemProps = HTMLMotionProps<"li">;
/** Use a stable route key; put its optional separator inside this item. */
export const BreadcrumbItem = forwardRef<HTMLLIElement, BreadcrumbItemProps>(
function BreadcrumbItem({ className, style, children, ...props }, ref) {
const reduce = useReducedMotion();
const present = useIsPresent();
const itemRef = useRef<HTMLLIElement>(null);
useLayoutEffect(() => {
const item = itemRef.current;
if (!item || !present) return;
const measure = () => {
// popLayout snapshots offsetWidth (integer pixels). Retain the exact
// width so a fractional-pixel loss cannot wrap the final character.
item.style.setProperty("--breadcrumb-exit-width", `${item.getBoundingClientRect().width}px`);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(item);
return () => observer.disconnect();
}, [present]);
const hidden = { opacity: 0, y: reduce ? 0 : 6 };
return (
<motion.li
ref={(node) => {
itemRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
}}
layout={reduce ? false : "position"}
initial={hidden}
animate={{ opacity: 1, y: 0 }}
exit={hidden}
transition={{ duration: 0.2, ease: EASE_OUT, layout: SPRING_LAYOUT }}
{...props}
inert={!present}
aria-hidden={!present || undefined}
style={{
...style,
minWidth: present ? style?.minWidth : "var(--breadcrumb-exit-width)",
pointerEvents: present ? style?.pointerEvents : "none",
}}
className={cn("relative inline-flex min-w-0 max-w-full items-center gap-1", className)}
>
{children}
</motion.li>
);
},
);
export type BreadcrumbLinkProps = ComponentPropsWithRef<"a"> & {
/** Render your router's Link, spreading these props onto it. */
render?: (props: ComponentPropsWithRef<"a">) => ReactElement;
};
export function BreadcrumbLink({ className, render, ...props }: BreadcrumbLinkProps) {
const linkProps = {
...props,
className: cn(
"inline-flex min-h-8 min-w-0 items-center gap-1.5 rounded-md px-2 font-medium text-muted-foreground transition-colors duration-150 hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&>svg]:size-3.5 [&>svg]:shrink-0",
className,
),
};
return render ? render(linkProps) : <a {...linkProps} />;
}
export type BreadcrumbPageProps = ComponentPropsWithRef<"span">;
export function BreadcrumbPage({ className, children, ...props }: BreadcrumbPageProps) {
return (
<span
{...props}
aria-current="page"
className={cn(
"relative isolate inline-flex min-h-8 min-w-0 items-center gap-1.5 rounded-md px-2 font-medium text-foreground [overflow-wrap:anywhere] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&>svg]:size-3.5 [&>svg]:shrink-0",
className,
)}
>
{children}
</span>
);
}
export type BreadcrumbSeparatorProps = ComponentPropsWithRef<"span">;
/** Decorative separator, placed inside the following BreadcrumbItem. */
export function BreadcrumbSeparator({ className, children, ...props }: BreadcrumbSeparatorProps) {
return (
<span
{...props}
aria-hidden="true"
data-breadcrumb-separator=""
className={cn("inline-flex shrink-0 items-center text-muted-foreground/50 [&>svg]:size-3.5 rtl:rotate-180", className)}
>
{children ?? <ChevronRight />}
</span>
);
}
export interface BreadcrumbEllipsisProps {
/** Hidden BreadcrumbItems, in path order. */
children: ReactNode;
className?: string;
label?: string;
}
/** Hover disclosure with click/touch toggle and keyboard access to ancestor links. */
export function BreadcrumbEllipsis({ children, className, label = "Show hidden paths" }: BreadcrumbEllipsisProps) {
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState<{ align: "start" | "end"; side: "top" | "bottom"; width: number }>({ align: "start", side: "bottom", width: 224 });
const canHover = useHoverCapable();
const present = useIsPresent();
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLOListElement>(null);
const focusOnOpen = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const rect = trigger.current?.getBoundingClientRect();
if (!rect) return;
const right = window.innerWidth - rect.left - 8;
const left = rect.right - 8;
const align = right < 224 && left > right ? "end" : "start";
const below = window.innerHeight - rect.bottom;
setPlacement({
align,
side: below < 280 && rect.top > below ? "top" : "bottom",
width: Math.max(32, Math.min(224, align === "start" ? right : left)),
});
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [open]);
const cancelClose = () => {
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
closeTimer.current = null;
};
const leave = () => {
cancelClose();
// Allow the pointer to cross the gap between the trigger and portal.
closeTimer.current = setTimeout(() => {
if (!panel.current?.contains(document.activeElement) && document.activeElement !== trigger.current) setOpen(false);
}, 160);
};
useEffect(() => () => {
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
}, []);
useEffect(() => {
if (!open) return;
const onFocus = (event: FocusEvent) => {
if (event.target instanceof Node && event.target !== trigger.current && !panel.current?.contains(event.target)) setOpen(false);
};
document.addEventListener("focusin", onFocus);
return () => document.removeEventListener("focusin", onFocus);
}, [open]);
return (
<MorphPopover open={open && present} onOpenChange={setOpen} className={cn(className)}>
<span
onPointerEnter={(event) => {
cancelClose();
if (canHover && event.pointerType === "mouse") {
focusOnOpen.current = false;
setOpen(true);
}
}}
onPointerLeave={leave}
>
<MorphPopoverTrigger>
<button
ref={trigger}
type="button"
aria-label={label}
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => { focusOnOpen.current = event.detail === 0; }}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
focusOnOpen.current = true;
setOpen(true);
panel.current?.querySelector<HTMLElement>("a[href],button")?.focus();
}
}}
>
<Ellipsis aria-hidden="true" className="size-4" />
</button>
</MorphPopoverTrigger>
<MorphPopoverContent align={placement.align} side={placement.side} radius={10} sideOffset={6} className="p-1.5">
<BreadcrumbOverflowPaths
ref={panel}
style={{ width: placement.width - 14 }}
focusOnOpen={focusOnOpen}
onPointerEnter={() => { cancelClose(); setOpen(true); }}
onPointerLeave={leave}
onClick={(event) => {
if ((event.target as Element).closest("a[href]")) setOpen(false);
}}
>
{children}
</BreadcrumbOverflowPaths>
</MorphPopoverContent>
</span>
</MorphPopover>
);
}
function BreadcrumbOverflowPaths({ focusOnOpen, ref, ...props }: ComponentPropsWithRef<"ol"> & { focusOnOpen: { current: boolean } }) {
const localRef = useRef<HTMLOListElement>(null);
useLayoutEffect(() => {
if (!focusOnOpen.current) return;
const focus = () => localRef.current?.querySelector<HTMLElement>("a[href],button")?.focus();
focus();
// The portal becomes visible after its parent's layout measurement.
const frame = requestAnimationFrame(() => {
focus();
focusOnOpen.current = false;
});
return () => cancelAnimationFrame(frame);
}, [focusOnOpen]);
return (
<ol
{...props}
ref={(node) => {
localRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
}}
className="flex max-h-64 flex-col gap-0.5 overflow-y-auto [&>li]:w-full [&_a]:w-full [&_a]:py-1 [&_a]:[overflow-wrap:anywhere] [&_[data-breadcrumb-separator]]:hidden"
/>
);
}
TSXcomponents/motion/popover-morph.tsx
"use client";
import {
AnimatePresence,
motion,
animate,
useMotionValue,
usePresence,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child?.props?.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child?.props as { ref?: Ref<HTMLElement> } | undefined)
?.ref;
// Register once per actual ref change, not once per open-state render.
const mergedRef = useMemo(
() => mergeRefs(childRef, ctx.registerTrigger),
[childRef, ctx.registerTrigger],
);
if (!isValidElement(children)) return children;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergedRef,
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipAt(side: Side, align: Align, radius: number, inset: number) {
const top = side === "bottom" ? "0%" : `${inset}%`;
const bottom = side === "bottom" ? `${inset}%` : "0%";
const right = align === "end" ? "0%" : `${inset}%`;
const left = align === "end" ? `${inset}%` : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent(props: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const [portalReady, setPortalReady] = useState(false);
useEffect(() => setPortalReady(true), []);
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open && <MorphPopoverSurface {...props} />}
</AnimatePresence>,
document.body,
);
}
// Measurement belongs to the mounted portal session: reopening must not start
// an entrance at the previous session's coordinates before measuring this one.
function MorphPopoverSurface({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [isPresent, safeToRemove] = usePresence();
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
isPresent,
);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { scale: 0.96, transition: SPRING_PANEL },
show: { scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipAt(side, align, radius, 92),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipAt(side, align, radius, 0),
transition: MORPH_CLIP_TRANSITION,
},
};
// Animate the value directly so opacity stays in the inline style throughout
// the entrance. A native opacity animation can expose the initial inline 0
// for a frame when it finishes, before Motion writes the final value.
const opacity = useMotionValue(0);
const ready = layout !== null;
useEffect(() => {
if (!ready) {
if (!isPresent) safeToRemove?.();
return;
}
const animation = animate(opacity, isPresent ? 1 : 0, {
...(reduce ? { duration: 0.12 } : SPRING_PANEL),
onComplete: () => {
if (!isPresent) safeToRemove?.();
},
});
return () => animation.stop();
}, [opacity, ready, isPresent, reduce, safeToRemove]);
return (
<motion.div
data-morph-popover-portal=""
inert={!isPresent}
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial="hidden"
animate={layout ? "show" : "hidden"}
exit="hidden"
style={{
left,
top,
opacity,
pointerEvents: isPresent ? "auto" : "none",
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
);
}
TSXcomponents/motion/popover-position.ts
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
API Reference
Breadcrumb
className?string—BreadcrumbList
className?string—BreadcrumbLink
className?string—BreadcrumbPage
className?string—BreadcrumbSeparator
className?string—BreadcrumbEllipsis
childrenReactNodeHidden BreadcrumbItems, in path order.
—className?string—label?stringShow hidden pathsBreadcrumbItem
className?string—Related components
Animated Context Menu
Composable context-menu primitives with a pointer-origin clip morph, a gliding active row, checkbox and radio choices, keyboard navigation, typeahead, and long-press support.
Bounce Sidebar
A vertical sidebar whose active dot jumps between destinations on a curved, spring-loaded path.
Animated Sidebar
A composable application sidebar with morphing nested navigation that folds into an animated icon rail on desktop and becomes a focus-managed sheet on mobile.
Updated