# Overflow Breadcrumb

> Breadcrumbs that fold their middle crumbs into a menu as space runs out and unfold as it returns, with a last crumb you can rename in place.

- Page: https://devl.dev/components/overflow-breadcrumb
- Kind: component · Workspace
- Install: `npx shadcn@latest add https://devl.dev/r/overflow-breadcrumb.json`
- Raw source: https://devl.dev/raw/overflow-breadcrumb.tsx
- npm: lucide-react, motion
- coss ui: menu
- License: MIT. The coss ui components it uses (https://coss.com/ui) are AGPL-3.0.

## Usage

```tsx
"use client";

import { HouseIcon } from "lucide-react";
import { animate, motion, useMotionValue, useReducedMotion } from "motion/react";
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react";
import { type Crumb, OverflowBreadcrumb } from "@/components/overflow-breadcrumb";

const PATH: Crumb[] = [
  { id: "home", label: "Halden", icon: <HouseIcon /> },
  { id: "roadmaps", label: "Roadmaps" },
  { id: "q4", label: "Q4" },
  { id: "launch", label: "Pricing launch" },
  { id: "plan", label: "Launch plan" },
];

const MIN = 220;
const MAX = 560;

export default function OverflowBreadcrumbDemo() {
  const reduce = useReducedMotion();
  const [segments, setSegments] = useState(PATH);
  const width = useMotionValue(MAX);
  const [shown, setShown] = useState(MAX);
  const touched = useRef(false);
  const drag = useRef<{ x: number; w: number } | null>(null);

  useEffect(() => width.on("change", (v) => setShown(Math.round(v))), [width]);

  useEffect(() => {
    if (reduce) return;
    let controls: ReturnType<typeof animate> | undefined;
    const t = window.setTimeout(async () => {
      if (touched.current) return;
      controls = animate(width, 250, { duration: 1.4, ease: [0.65, 0, 0.35, 1] });
      await controls;
      if (touched.current) return;
      controls = animate(width, 400, { duration: 1.2, ease: [0.65, 0, 0.35, 1], delay: 0.6 });
    }, 1400);
    return () => {
      clearTimeout(t);
      controls?.stop();
    };
  }, [reduce, width]);

  const take = () => {
    touched.current = true;
    width.stop();
  };
  const onDown = (e: PointerEvent<HTMLDivElement>) => {
    take();
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { x: e.clientX, w: width.get() };
  };
  const onMove = (e: PointerEvent<HTMLDivElement>) => {
    if (!drag.current) return;
    width.set(Math.max(MIN, Math.min(MAX, drag.current.w + e.clientX - drag.current.x)));
  };
  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const step = e.shiftKey ? 40 : 10;
    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
      e.preventDefault();
      take();
      width.set(Math.max(MIN, Math.min(MAX, width.get() + (e.key === "ArrowRight" ? step : -step))));
    }
  };

  return (
    <div className="flex w-[576px] max-w-full flex-col items-start gap-3">
      <motion.div className="relative max-w-[calc(100%-16px)] rounded-2xl border bg-card shadow-xs/5" style={{ width }}>
        <div className="border-b px-3 py-2.5">
          <OverflowBreadcrumb
            onNavigate={(_, i) => setSegments((s) => (i < s.length - 1 ? s.slice(0, i + 1) : s))}
            onRename={(label) => setSegments((s) => s.map((c, i) => (i === s.length - 1 ? { ...c, label } : c)))}
            segments={segments}
          />
        </div>
        <div className="space-y-2 p-4">
          <div className="h-2 w-3/4 rounded-full bg-muted" />
          <div className="h-2 w-1/2 rounded-full bg-muted" />
          <div className="h-2 w-2/3 rounded-full bg-muted" />
        </div>
        <div
          aria-label="Panel width"
          aria-orientation="horizontal"
          aria-valuemax={MAX}
          aria-valuemin={MIN}
          aria-valuenow={shown}
          className="group/handle absolute top-1/2 -right-4 flex h-16 w-4 before:absolute before:inset-y-0 before:-inset-x-2 -translate-y-1/2 cursor-ew-resize touch-none items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
          onKeyDown={onKey}
          onPointerDown={onDown}
          onPointerMove={onMove}
          onPointerUp={() => {
            drag.current = null;
          }}
          role="slider"
          tabIndex={0}
        >
          <span className="h-8 w-1 rounded-full bg-border transition-[scale,background-color] duration-150 group-hover/handle:scale-y-125 group-hover/handle:bg-muted-foreground/50 group-active/handle:bg-muted-foreground/70" />
        </div>
      </motion.div>
      <p className="text-muted-foreground text-xs">
        Drag the handle to resize · click the last crumb to rename{segments.length < PATH.length && (
          <>
            {" · "}
            <button className="cursor-pointer underline underline-offset-2 hover:text-foreground" onClick={() => setSegments(PATH)} type="button">
              reset path
            </button>
          </>
        )}
      </p>
    </div>
  );
}
```

## Props

| Prop | Type | Default |
| --- | --- | --- |
| `segments` | `Crumb[]` |  |
| `onNavigate`? | `(crumb: Crumb, index: number) => void` |  |
| `onRename`? | `(label: string) => void` |  |
| `maxCurrentWidth`? | `number` | `200` |
| `className`? | `string` |  |

## Source: components/overflow-breadcrumb.tsx

```tsx
"use client";

import { ChevronRightIcon, EllipsisIcon, FolderIcon, PencilIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type KeyboardEvent, type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
import { cn } from "@/lib/utils";

export interface Crumb {
  id: string;
  label: string;
  icon?: ReactNode;
}

export interface OverflowBreadcrumbProps {
  segments: Crumb[];
  onNavigate?: (crumb: Crumb, index: number) => void;
  onRename?: (label: string) => void;
  maxCurrentWidth?: number;
  className?: string;
}

const SEP = 20;
const MORE = 28;

export function OverflowBreadcrumb({ segments, onNavigate, onRename, maxCurrentWidth = 200, className }: OverflowBreadcrumbProps) {
  const reduce = useReducedMotion();
  const navRef = useRef<HTMLElement>(null);
  const measureRef = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);
  const [sizes, setSizes] = useState<number[]>([]);
  const [editing, setEditing] = useState(false);

  useLayoutEffect(() => {
    const el = navRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => setWidth(el.clientWidth));
    ro.observe(el);
    setWidth(el.clientWidth);
    return () => ro.disconnect();
  }, []);

  // Measure every crumb once, offstage, so collapsing never depends on what is currently shown.
  useLayoutEffect(() => {
    const row = measureRef.current;
    if (!row) return;
    setSizes(Array.from(row.children, (c) => (c as HTMLElement).offsetWidth));
  }, [segments.map((s) => s.label).join("\u0000")]);

  const n = segments.length;
  const lastIndex = n - 1;
  let shown = segments.map((_, i) => i);
  if (sizes.length === n && width > 0 && n > 2) {
    const current = Math.min(sizes[lastIndex], maxCurrentWidth) + (onRename ? 18 : 0);
    const all = current + sizes.slice(0, lastIndex).reduce((a, b) => a + b + SEP, 0);
    if (all > width) {
      let budget = width - current - MORE - SEP;
      const keep = new Set<number>([lastIndex]);
      if (sizes[0] + SEP <= budget) {
        keep.add(0);
        budget -= sizes[0] + SEP;
      }
      for (let i = lastIndex - 1; i >= 1; i--) {
        if (sizes[i] + SEP > budget) break;
        keep.add(i);
        budget -= sizes[i] + SEP;
      }
      shown = [...keep].sort((a, b) => a - b);
    }
  }
  const hidden = segments.map((_, i) => i).filter((i) => i !== lastIndex && !shown.includes(i));
  const moreAt = hidden.length ? (shown[0] === 0 ? 1 : 0) : -1;

  type Slot = { key: string; kind: "crumb"; index: number } | { key: string; kind: "more" };
  const slots: Slot[] = shown.map((i) => ({ key: segments[i].id, kind: "crumb" as const, index: i }));
  if (moreAt >= 0) slots.splice(moreAt, 0, { key: "__more", kind: "more" });

  const ease = [0.23, 1, 0.32, 1] as const;
  const item = {
    layout: reduce ? false : ("position" as const),
    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96 },
    animate: { opacity: 1, scale: 1 },
    exit: { opacity: 0, scale: 0.96, transition: { duration: 0.1 } },
    transition: { duration: 0.18, ease },
  };

  return (
    <nav aria-label="Breadcrumb" className={cn("relative min-w-0", className)} ref={navRef}>
      <div aria-hidden className="pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden">
        <div className="flex w-max whitespace-nowrap" ref={measureRef}>
          {segments.map((s) => (
            <span className="flex h-7 items-center gap-1.5 px-1.5 font-medium text-sm [&_svg]:size-4" key={s.id}>
              {s.icon}
              {s.label}
            </span>
          ))}
        </div>
      </div>
      <ol className="flex min-w-0 items-center">
        <AnimatePresence initial={false} mode="popLayout">
          {slots.map((slot, k) => {
            const sep = k > 0 && (
              <ChevronRightIcon aria-hidden className="mx-0.5 size-4 shrink-0 text-muted-foreground/60" />
            );
            if (slot.kind === "more") {
              return (
                <motion.li className="flex shrink-0 items-center" key={slot.key} {...item}>
                  {sep}
                  <Menu>
                    <MenuTrigger
                      aria-label={`Show ${hidden.length} more`}
                      className="relative flex h-7 w-7 cursor-pointer items-center justify-center rounded-md before:absolute before:-inset-y-1 before:inset-x-0 text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground"
                    >
                      <EllipsisIcon className="size-4" />
                    </MenuTrigger>
                    <MenuPopup
                      align="start"
                      className="min-w-48 transition-[scale,opacity] duration-150 ease-out data-ending-style:opacity-0 data-starting-style:scale-[0.96] data-starting-style:opacity-0"
                      sideOffset={6}
                    >
                      {hidden.map((i, depth) => (
                        <MenuItem key={segments[i].id} onClick={() => onNavigate?.(segments[i], i)}>
                          <span className="flex items-center gap-2" style={{ paddingInlineStart: depth * 10 }}>
                            {segments[i].icon ?? <FolderIcon className="size-4 text-muted-foreground" />}
                            {segments[i].label}
                          </span>
                        </MenuItem>
                      ))}
                    </MenuPopup>
                  </Menu>
                </motion.li>
              );
            }
            const s = segments[slot.index];
            const isLast = slot.index === lastIndex;
            return (
              <motion.li className={cn("flex items-center", isLast ? "min-w-0" : "shrink-0")} key={slot.key} {...item}>
                {sep}
                {isLast ? (
                  <CurrentCrumb
                    crumb={s}
                    editing={editing}
                    maxWidth={maxCurrentWidth}
                    onEdit={onRename ? setEditing : undefined}
                    onRename={(label) => {
                      setEditing(false);
                      if (label && label !== s.label) onRename?.(label);
                    }}
                  />
                ) : (
                  <button
                    className="relative flex h-7 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 before:absolute before:-inset-y-1 before:inset-x-0 font-medium text-muted-foreground text-sm outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-4"
                    onClick={() => onNavigate?.(s, slot.index)}
                    type="button"
                  >
                    {s.icon}
                    {s.label}
                  </button>
                )}
              </motion.li>
            );
          })}
        </AnimatePresence>
      </ol>
    </nav>
  );
}

function CurrentCrumb({
  crumb,
  editing,
  maxWidth,
  onEdit,
  onRename,
}: {
  crumb: Crumb;
  editing: boolean;
  maxWidth: number;
  onEdit?: (on: boolean) => void;
  onRename: (label: string) => void;
}) {
  const inputRef = useRef<HTMLInputElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const [draft, setDraft] = useState(crumb.label);
  const cancelled = useRef(false);

  useLayoutEffect(() => {
    if (editing) {
      setDraft(crumb.label);
      cancelled.current = false;
      inputRef.current?.focus();
      inputRef.current?.select();
    }
  }, [editing, crumb.label]);

  const finish = (commit: boolean) => {
    cancelled.current = true;
    onRename(commit ? draft.trim() : crumb.label);
    requestAnimationFrame(() => buttonRef.current?.focus());
  };

  const onKey = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter") {
      event.preventDefault();
      finish(true);
    } else if (event.key === "Escape") {
      event.preventDefault();
      finish(false);
    }
  };

  if (editing) {
    return (
      <input
        aria-label="Page name"
        className="h-7 min-w-16 rounded-md border bg-background px-1.5 font-medium text-foreground text-sm outline-none ring-ring/30 [field-sizing:content] focus:border-ring focus:ring-3"
        maxLength={60}
        onBlur={() => !cancelled.current && finish(true)}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={onKey}
        ref={inputRef}
        style={{ maxWidth }}
        value={draft}
      />
    );
  }

  const content = (
    <>
      {crumb.icon}
      <span className="truncate">{crumb.label}</span>
    </>
  );

  if (!onEdit) {
    return (
      <span aria-current="page" className="flex h-7 min-w-0 items-center gap-1.5 px-1.5 font-medium text-foreground text-sm [&_svg]:size-4" style={{ maxWidth }}>
        {content}
      </span>
    );
  }

  return (
    <button
      aria-current="page"
      aria-label={`${crumb.label}, rename`}
      className="group/cur relative flex h-7 min-w-0 cursor-text before:absolute before:-inset-y-1 before:inset-x-0 items-center gap-1.5 rounded-md px-1.5 font-medium text-foreground text-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-4"
      onClick={() => onEdit(true)}
      ref={buttonRef}
      style={{ maxWidth: maxWidth + 22 }}
      title="Rename"
      type="button"
    >
      {content}
      <PencilIcon className="size-3! shrink-0 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover/cur:opacity-100 group-focus-visible/cur:opacity-100" />
    </button>
  );
}
```
