Web InnoventixFreeCode

Collapsed Breadcrumb

Original · free

collapsed breadcrumbs with ellipsis

byWeb InnoventixReact + Tailwind
crumbcollapsedbreadcrumbs
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/crumb-collapsed.json
crumb-collapsed.tsx
"use client";

import {
  useState,
  useRef,
  useEffect,
  useId,
  Fragment,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

type Crumb = { label: string; href: string };

type CrumbNode =
  | { kind: "link"; crumb: Crumb; index: number }
  | { kind: "current"; crumb: Crumb; index: number }
  | { kind: "ellipsis" };

const TRAIL: Crumb[] = [
  { label: "Home", href: "/" },
  { label: "Developer Platform", href: "/developers" },
  { label: "Guides", href: "/developers/guides" },
  { label: "Authentication", href: "/developers/guides/auth" },
  { label: "OAuth 2.0", href: "/developers/guides/auth/oauth" },
  { label: "Access Tokens", href: "/developers/guides/auth/oauth/tokens" },
  {
    label: "Refreshing Tokens",
    href: "/developers/guides/auth/oauth/tokens/refresh",
  },
];

function Separator() {
  return (
    <svg
      viewBox="0 0 20 20"
      className="h-4 w-4 shrink-0 text-slate-300 dark:text-slate-600"
      aria-hidden="true"
      focusable="false"
    >
      <path
        d="M7.75 4.5 12.5 10l-4.75 5.5"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.5}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function HomeIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      className="h-4 w-4 shrink-0"
      aria-hidden="true"
      focusable="false"
    >
      <path
        d="M3.5 9.25 10 3.75l6.5 5.5M5 8v7.25a.75.75 0 0 0 .75.75H8.5v-4a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v4h2.75a.75.75 0 0 0 .75-.75V8"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.5}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function CrumbCollapsed() {
  const reduce = useReducedMotion();
  const panelId = useId();
  const listId = useId();

  const [open, setOpen] = useState(false);
  const [expanded, setExpanded] = useState(false);

  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const panelRef = useRef<HTMLDivElement | null>(null);
  const linkRefs = useRef<Array<HTMLAnchorElement | null>>([]);

  const first = TRAIL[0];
  const current = TRAIL[TRAIL.length - 1];
  const secondLast = TRAIL[TRAIL.length - 2];
  const hidden = TRAIL.slice(1, TRAIL.length - 2);

  useEffect(() => {
    if (!open) return;
    function onDown(e: MouseEvent) {
      const t = e.target as Node;
      if (panelRef.current?.contains(t)) return;
      if (triggerRef.current?.contains(t)) return;
      setOpen(false);
    }
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") {
        setOpen(false);
        triggerRef.current?.focus();
      }
    }
    document.addEventListener("mousedown", onDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  useEffect(() => {
    if (!open) return;
    const id = requestAnimationFrame(() => linkRefs.current[0]?.focus());
    return () => cancelAnimationFrame(id);
  }, [open]);

  function onPanelKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {
    const items = linkRefs.current.filter(Boolean) as HTMLAnchorElement[];
    if (items.length === 0) return;
    const active = items.indexOf(
      (typeof document !== "undefined"
        ? document.activeElement
        : null) as HTMLAnchorElement,
    );
    if (e.key === "ArrowDown") {
      e.preventDefault();
      items[active < items.length - 1 ? active + 1 : 0]?.focus();
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      items[active > 0 ? active - 1 : items.length - 1]?.focus();
    } else if (e.key === "Home") {
      e.preventDefault();
      items[0]?.focus();
    } else if (e.key === "End") {
      e.preventDefault();
      items[items.length - 1]?.focus();
    }
  }

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

  const linkClass =
    "rounded-md px-1.5 py-1 text-sm font-medium text-slate-500 transition-colors hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800 " +
    focusRing;

  const nodes: CrumbNode[] = expanded
    ? TRAIL.map((crumb, index) =>
        index === TRAIL.length - 1
          ? { kind: "current", crumb, index }
          : { kind: "link", crumb, index },
      )
    : [
        { kind: "link", crumb: first, index: 0 },
        { kind: "ellipsis" },
        { kind: "link", crumb: secondLast, index: TRAIL.length - 2 },
        { kind: "current", crumb: current, index: TRAIL.length - 1 },
      ];

  function keyFor(node: CrumbNode, i: number) {
    if (node.kind === "ellipsis") return `ellipsis-${i}`;
    return node.crumb.href;
  }

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-50 sm:py-24">
      <style>{`
        @keyframes crumbc-pulse {
          0%, 100% { transform: scale(1); opacity: 1; }
          50% { transform: scale(1.55); opacity: 0.45; }
        }
        .crumbc-dot { animation: crumbc-pulse 2.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .crumbc-dot { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
          Component preview
        </p>
        <h2 className="mt-3 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
          Collapsed breadcrumbs
        </h2>
        <p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
          Deep navigation trails stay tidy. The middle levels tuck behind an
          ellipsis you can open with a click or the keyboard, or expand the
          whole path inline.
        </p>

        <div className="mt-8 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800 sm:px-5">
            <nav aria-label="Breadcrumb" className="min-w-0">
              <ol
                id={listId}
                className="flex flex-wrap items-center gap-x-0.5 gap-y-1"
              >
                {nodes.map((node, i) => (
                  <Fragment key={keyFor(node, i)}>
                    {i > 0 && (
                      <li aria-hidden="true" className="flex items-center">
                        <Separator />
                      </li>
                    )}

                    {node.kind === "link" && (
                      <li className="flex items-center">
                        <a href={node.crumb.href} className={linkClass}>
                          {node.index === 0 ? (
                            <span className="flex items-center gap-1.5">
                              <HomeIcon />
                              <span>{node.crumb.label}</span>
                            </span>
                          ) : (
                            node.crumb.label
                          )}
                        </a>
                      </li>
                    )}

                    {node.kind === "current" && (
                      <li className="flex items-center">
                        <span
                          aria-current="page"
                          className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm font-semibold text-slate-900 dark:text-white"
                        >
                          <span
                            className="crumbc-dot h-1.5 w-1.5 rounded-full bg-emerald-500"
                            aria-hidden="true"
                          />
                          {node.crumb.label}
                        </span>
                      </li>
                    )}

                    {node.kind === "ellipsis" && (
                      <li className="relative flex items-center">
                        <button
                          ref={triggerRef}
                          type="button"
                          onClick={() => setOpen((v) => !v)}
                          aria-haspopup="true"
                          aria-expanded={open}
                          aria-controls={panelId}
                          aria-label={`Show ${hidden.length} hidden breadcrumb levels`}
                          className={
                            "flex h-8 w-9 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white " +
                            (open
                              ? "bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white "
                              : "") +
                            focusRing
                          }
                        >
                          <svg
                            viewBox="0 0 24 24"
                            className="h-5 w-5"
                            aria-hidden="true"
                            focusable="false"
                          >
                            <circle cx="5" cy="12" r="1.6" fill="currentColor" />
                            <circle cx="12" cy="12" r="1.6" fill="currentColor" />
                            <circle cx="19" cy="12" r="1.6" fill="currentColor" />
                          </svg>
                        </button>

                        <AnimatePresence>
                          {open && (
                            <motion.div
                              ref={panelRef}
                              id={panelId}
                              role="group"
                              aria-label="Hidden breadcrumb levels"
                              onKeyDown={onPanelKeyDown}
                              initial={{
                                opacity: 0,
                                y: reduce ? 0 : -6,
                                scale: reduce ? 1 : 0.97,
                              }}
                              animate={{ opacity: 1, y: 0, scale: 1 }}
                              exit={{
                                opacity: 0,
                                y: reduce ? 0 : -6,
                                scale: reduce ? 1 : 0.97,
                              }}
                              transition={{
                                duration: reduce ? 0 : 0.16,
                                ease: [0.16, 1, 0.3, 1],
                              }}
                              className="absolute left-0 top-full z-20 mt-2 min-w-[15rem] rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-800 dark:shadow-black/40"
                            >
                              <p className="px-2.5 pb-1.5 pt-1 text-[0.6875rem] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
                                Skipped levels
                              </p>
                              <ul className="space-y-0.5">
                                {hidden.map((crumb, idx) => (
                                  <li key={crumb.href}>
                                    <a
                                      href={crumb.href}
                                      ref={(el) => {
                                        linkRefs.current[idx] = el;
                                      }}
                                      onClick={() => setOpen(false)}
                                      className={
                                        "flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-indigo-50 hover:text-indigo-700 dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-white " +
                                        focusRing
                                      }
                                    >
                                      <span
                                        aria-hidden="true"
                                        className="flex h-5 w-5 items-center justify-center rounded-md bg-slate-100 text-[0.625rem] font-semibold text-slate-500 dark:bg-slate-700 dark:text-slate-300"
                                      >
                                        {idx + 1}
                                      </span>
                                      <span className="truncate">
                                        {crumb.label}
                                      </span>
                                    </a>
                                  </li>
                                ))}
                              </ul>
                            </motion.div>
                          )}
                        </AnimatePresence>
                      </li>
                    )}
                  </Fragment>
                ))}
              </ol>
            </nav>

            <button
              type="button"
              onClick={() => {
                setExpanded((v) => !v);
                setOpen(false);
              }}
              aria-expanded={expanded}
              aria-controls={listId}
              className={
                "shrink-0 rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800 dark:hover:text-white " +
                focusRing
              }
            >
              {expanded ? "Collapse path" : "Expand path"}
            </button>
          </div>

          <div className="px-5 py-6 sm:px-6">
            <span className="inline-flex items-center gap-1.5 rounded-full bg-violet-50 px-2.5 py-0.5 text-xs font-medium text-violet-700 dark:bg-violet-500/10 dark:text-violet-300">
              OAuth 2.0
            </span>
            <h3 className="mt-3 text-lg font-semibold tracking-tight text-slate-900 dark:text-white">
              Refreshing Tokens
            </h3>
            <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              Access tokens expire 60 minutes after they are issued. Use the
              long-lived refresh token to request a fresh access token in the
              background, so people stay signed in without re-entering their
              credentials.
            </p>
          </div>
        </div>

        <p className="mt-4 text-xs text-slate-500 dark:text-slate-500">
          Try it: open the ellipsis and use{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1 py-0.5 font-mono text-[0.6875rem] text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
            ↑
          </kbd>{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1 py-0.5 font-mono text-[0.6875rem] text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
            ↓
          </kbd>{" "}
          to move between levels, or{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1 py-0.5 font-mono text-[0.6875rem] text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
            Esc
          </kbd>{" "}
          to close.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →