Web InnoventixFreeCode

Chevron Breadcrumb

Original · free

chevron separator breadcrumbs

byWeb InnoventixReact + Tailwind
crumbchevronbreadcrumbs
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-chevron.json
crumb-chevron.tsx
"use client";

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

type Crumb = { label: string; href: string };
type Trail = { id: string; label: string; hint: string; crumbs: Crumb[] };

type CrumbNode =
  | { kind: "crumb"; crumb: Crumb; isFirst: boolean; isCurrent: boolean }
  | { kind: "overflow"; items: Crumb[] };

const TRAILS: Trail[] = [
  {
    id: "docs",
    label: "Docs",
    hint: "Deep documentation path",
    crumbs: [
      { label: "Home", href: "#home" },
      { label: "Documentation", href: "#docs" },
      { label: "Components", href: "#components" },
      { label: "Navigation", href: "#navigation" },
      { label: "Breadcrumbs", href: "#breadcrumbs" },
      { label: "Chevron separator", href: "#chevron" },
    ],
  },
  {
    id: "store",
    label: "Store",
    hint: "Nested product catalog",
    crumbs: [
      { label: "Home", href: "#home" },
      { label: "Store", href: "#store" },
      { label: "Audio", href: "#audio" },
      { label: "Headphones", href: "#headphones" },
      { label: "Studio monitors", href: "#studio" },
      { label: "HX-90 Reference", href: "#hx90" },
    ],
  },
  {
    id: "account",
    label: "Account",
    hint: "Short, uncollapsed trail",
    crumbs: [
      { label: "Home", href: "#home" },
      { label: "Account settings", href: "#account" },
      { label: "Billing & invoices", href: "#billing" },
    ],
  },
];

const RING =
  "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";

function ChevronSeparator() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      className="mx-1 h-4 w-4 shrink-0 text-slate-300 dark:text-slate-600 sm:mx-1.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="m9 6 6 6-6 6" />
    </svg>
  );
}

function HomeIcon() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      className="h-4 w-4 shrink-0"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M3 10.5 12 3l9 7.5" />
      <path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5" />
    </svg>
  );
}

function DotsIcon() {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor">
      <circle cx="5" cy="12" r="1.75" />
      <circle cx="12" cy="12" r="1.75" />
      <circle cx="19" cy="12" r="1.75" />
    </svg>
  );
}

function OverflowMenu({ items, reduced }: { items: Crumb[]; reduced: boolean }) {
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const buttonRef = useRef<HTMLButtonElement | null>(null);
  const itemRefs = useRef<Array<HTMLAnchorElement | null>>([]);
  const menuId = useId();

  useEffect(() => {
    if (open) itemRefs.current[activeIndex]?.focus();
  }, [open, activeIndex]);

  useEffect(() => {
    if (!open) return;
    function handlePointer(e: Event) {
      const target = e.target as globalThis.Node | null;
      if (wrapRef.current && target && !wrapRef.current.contains(target)) {
        setOpen(false);
      }
    }
    document.addEventListener("pointerdown", handlePointer);
    return () => document.removeEventListener("pointerdown", handlePointer);
  }, [open]);

  function openMenu(index: number) {
    setActiveIndex(index);
    setOpen(true);
  }

  function close(focusButton: boolean) {
    setOpen(false);
    if (focusButton) buttonRef.current?.focus();
  }

  function onButtonKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>) {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      openMenu(0);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      openMenu(items.length - 1);
    }
  }

  function onItemKeyDown(e: ReactKeyboardEvent<HTMLAnchorElement>, index: number) {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setActiveIndex((index + 1) % items.length);
        break;
      case "ArrowUp":
        e.preventDefault();
        setActiveIndex((index - 1 + items.length) % items.length);
        break;
      case "Home":
        e.preventDefault();
        setActiveIndex(0);
        break;
      case "End":
        e.preventDefault();
        setActiveIndex(items.length - 1);
        break;
      case "Escape":
        e.preventDefault();
        close(true);
        break;
      case "Tab":
        setOpen(false);
        break;
      default:
        break;
    }
  }

  const panelMotion = reduced
    ? {}
    : {
        initial: { opacity: 0, y: -6, scale: 0.97 },
        animate: { opacity: 1, y: 0, scale: 1 },
        exit: { opacity: 0, y: -6, scale: 0.97 },
        transition: { duration: 0.16, ease: "easeOut" as const },
      };

  return (
    <div ref={wrapRef} className="relative inline-flex">
      <button
        ref={buttonRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        aria-label={`Show ${items.length} hidden breadcrumb levels`}
        onClick={() => (open ? close(false) : openMenu(0))}
        onKeyDown={onButtonKeyDown}
        className={`inline-flex h-7 items-center justify-center rounded-md px-1.5 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${RING}`}
      >
        <DotsIcon />
      </button>

      <AnimatePresence>
        {open && (
          <motion.div
            {...panelMotion}
            className="absolute left-0 top-full z-20 mt-2 min-w-[13rem] origin-top-left"
          >
            <ul
              id={menuId}
              role="menu"
              aria-label="Hidden breadcrumb levels"
              className="overflow-hidden 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"
            >
              {items.map((item, i) => (
                <li key={item.href} role="none">
                  <a
                    role="menuitem"
                    href={item.href}
                    ref={(el) => {
                      itemRefs.current[i] = el;
                    }}
                    tabIndex={i === activeIndex ? 0 : -1}
                    onKeyDown={(e) => onItemKeyDown(e, i)}
                    onClick={() => setOpen(false)}
                    className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-slate-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300 ${RING}`}
                  >
                    <span
                      aria-hidden="true"
                      className="h-1.5 w-1.5 shrink-0 rounded-full bg-slate-300 dark:bg-slate-600"
                    />
                    {item.label}
                  </a>
                </li>
              ))}
            </ul>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function TrailPicker({
  trails,
  activeId,
  onChange,
}: {
  trails: Trail[];
  activeId: string;
  onChange: (id: string) => void;
}) {
  const refs = useRef<Array<HTMLButtonElement | null>>([]);
  const activeIndex = trails.findIndex((t) => t.id === activeId);

  function onKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>) {
    const len = trails.length;
    let next = activeIndex;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (activeIndex + 1) % len;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (activeIndex - 1 + len) % len;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = len - 1;
    else return;
    e.preventDefault();
    onChange(trails[next].id);
    refs.current[next]?.focus();
  }

  return (
    <div
      role="radiogroup"
      aria-label="Example breadcrumb path"
      className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-slate-100/70 p-1 dark:border-slate-800 dark:bg-slate-800/60"
    >
      {trails.map((trail, i) => {
        const selected = trail.id === activeId;
        return (
          <button
            key={trail.id}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            onClick={() => onChange(trail.id)}
            onKeyDown={onKeyDown}
            className={`relative rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${RING} ${
              selected
                ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
            }`}
          >
            {trail.label}
          </button>
        );
      })}
    </div>
  );
}

const STYLES = `
@keyframes cc-crumb-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes cc-dot-pulse {
  0%, 100% { opacity: 0.4; transform: scale(0.82); box-shadow: 0 0 0 0 rgba(99,102,241,0.5); }
  50% { opacity: 1; transform: scale(1); box-shadow: 0 0 0 5px rgba(99,102,241,0); }
}
.cc-crumb { animation: cc-crumb-in 0.5s cubic-bezier(0.16,1,0.3,1) both; }
.cc-dot { animation: cc-dot-pulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .cc-crumb, .cc-dot { animation: none !important; }
}
`;

export default function CrumbChevron() {
  const reduced = useReducedMotion() ?? false;
  const [activeId, setActiveId] = useState<string>(TRAILS[0].id);

  const active = TRAILS.find((t) => t.id === activeId) ?? TRAILS[0];
  const crumbs = active.crumbs;
  const shouldCollapse = crumbs.length > 4;

  const nodes: CrumbNode[] = [];
  if (!shouldCollapse) {
    crumbs.forEach((crumb, i) =>
      nodes.push({
        kind: "crumb",
        crumb,
        isFirst: i === 0,
        isCurrent: i === crumbs.length - 1,
      }),
    );
  } else {
    const middle = crumbs.slice(1, -2);
    const tail = crumbs.slice(-2);
    nodes.push({ kind: "crumb", crumb: crumbs[0], isFirst: true, isCurrent: false });
    nodes.push({ kind: "overflow", items: middle });
    tail.forEach((crumb, i) =>
      nodes.push({
        kind: "crumb",
        crumb,
        isFirst: false,
        isCurrent: i === tail.length - 1,
      }),
    );
  }

  const current = crumbs[crumbs.length - 1];
  const hiddenCount = shouldCollapse ? crumbs.length - 3 : 0;

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 dark:from-slate-950 dark:to-slate-900 sm:px-8 lg:py-28">
      <style>{STYLES}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-40 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.12),transparent)] dark:bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.18),transparent)]"
      />

      <div className="relative mx-auto w-full max-w-3xl">
        <div className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-wider text-indigo-600 dark:border-slate-800 dark:bg-slate-900 dark:text-indigo-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Navigation pattern
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Chevron breadcrumbs
          </h2>
          <p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A trail that shows where you are and how you got there. Long paths collapse into a
            keyboard-navigable menu, so the current page always stays in view.
          </p>
        </div>

        <div className="rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:shadow-black/20 sm:p-7">
          <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <TrailPicker trails={TRAILS} activeId={activeId} onChange={setActiveId} />
            <p className="text-xs text-slate-500 dark:text-slate-400">{active.hint}</p>
          </div>

          <nav aria-label="Breadcrumb" className="min-h-[2.25rem]">
            <ol key={activeId} className="flex flex-wrap items-center gap-y-1.5">
              {nodes.map((node, i) => (
                <li
                  key={node.kind === "overflow" ? "overflow" : node.crumb.href}
                  className="cc-crumb flex items-center"
                  style={{ animationDelay: `${i * 55}ms` }}
                >
                  {i > 0 && <ChevronSeparator />}

                  {node.kind === "overflow" ? (
                    <OverflowMenu items={node.items} reduced={reduced} />
                  ) : node.isCurrent ? (
                    <span
                      aria-current="page"
                      className="inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-semibold text-slate-900 dark:text-white"
                    >
                      {node.crumb.label}
                    </span>
                  ) : (
                    <a
                      href={node.crumb.href}
                      className={`inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-slate-500 transition hover:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-300 ${RING}`}
                    >
                      {node.isFirst && <HomeIcon />}
                      {node.crumb.label}
                    </a>
                  )}
                </li>
              ))}
            </ol>
          </nav>

          <div className="mt-6 flex items-center gap-3 border-t border-slate-200 pt-5 dark:border-slate-800">
            <span
              aria-hidden="true"
              className="cc-dot h-2.5 w-2.5 shrink-0 rounded-full bg-indigo-500"
            />
            <p className="text-sm text-slate-600 dark:text-slate-400">
              You are viewing{" "}
              <span className="font-semibold text-slate-900 dark:text-white">{current.label}</span>
              {hiddenCount > 0 && (
                <span className="text-slate-400 dark:text-slate-500">
                  {" "}
                  · {hiddenCount} levels collapsed
                </span>
              )}
            </p>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
          Tip: focus a link and use Tab, or open the “…” menu with Enter and browse it with the arrow keys.
        </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 →