Web InnoventixFreeCode

Slash Breadcrumb

Original · free

slash separator breadcrumbs

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

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

type PageNode = {
  id: string;
  label: string;
  blurb: string;
  children?: PageNode[];
};

const SITE: PageNode = {
  id: "home",
  label: "Home",
  blurb: "The Meridian developer platform — ship analytics, pipelines, and alerts without babysitting infrastructure.",
  children: [
    {
      id: "products",
      label: "Products",
      blurb: "Everything Meridian builds, grouped by what the job actually is.",
      children: [
        {
          id: "analytics",
          label: "Analytics Suite",
          blurb: "Self-serve dashboards, funnels, and cohorts that read straight from your warehouse.",
          children: [
            {
              id: "dashboards",
              label: "Dashboards",
              blurb: "Drag-and-drop boards that refresh on a schedule you set, down to the minute.",
              children: [
                {
                  id: "custom-views",
                  label: "Custom Views",
                  blurb: "Saved slices of a dashboard scoped to a team, a region, or a single account.",
                  children: [
                    {
                      id: "retention",
                      label: "Retention Panel",
                      blurb: "A pinned view that tracks week-over-week retention against last quarter's baseline.",
                    },
                  ],
                },
                { id: "funnels", label: "Funnels", blurb: "Step conversion with drop-off reasons attached to every stage." },
                { id: "cohorts", label: "Cohorts", blurb: "Group users by first action and watch how each batch behaves over time." },
              ],
            },
            { id: "alerting", label: "Alerting", blurb: "Threshold and anomaly alerts routed to Slack, email, or a webhook." },
          ],
        },
        {
          id: "pipeline",
          label: "Data Pipeline",
          blurb: "Move data in, reshape it, and land it where analysts can reach it.",
          children: [
            { id: "connectors", label: "Connectors", blurb: "Managed syncs for 40+ sources, backfilled and incremental." },
            { id: "transforms", label: "Transforms", blurb: "Version-controlled SQL models that run on every fresh load." },
          ],
        },
      ],
    },
    {
      id: "docs",
      label: "Documentation",
      blurb: "Guides, references, and recipes for building on Meridian.",
      children: [
        { id: "start", label: "Getting Started", blurb: "From zero to a live dashboard in about fifteen minutes." },
        {
          id: "api",
          label: "API Reference",
          blurb: "Every endpoint, its parameters, and a copy-ready example for each.",
          children: [
            { id: "auth", label: "Authentication", blurb: "Personal tokens, scoped keys, and how to rotate them safely." },
            {
              id: "endpoints",
              label: "Endpoints",
              blurb: "The full REST surface, grouped by resource.",
              children: [
                { id: "list-users", label: "List users", blurb: "Paginated GET with filtering by role and status." },
                { id: "create-user", label: "Create user", blurb: "POST a new member and assign starting permissions." },
              ],
            },
            { id: "webhooks", label: "Webhooks", blurb: "Subscribe to events and verify signatures on delivery." },
          ],
        },
        { id: "guides", label: "Guides", blurb: "Task-shaped walkthroughs for the things people ask about most." },
      ],
    },
    { id: "pricing", label: "Pricing", blurb: "Usage-based plans with a generous free tier and no seat charges." },
    { id: "changelog", label: "Changelog", blurb: "What shipped, what changed, and what's deprecated — updated weekly." },
  ],
};

function resolvePath(ids: string[]): PageNode[] {
  const out: PageNode[] = [];
  let level: PageNode[] = [SITE];
  for (const id of ids) {
    const found = level.find((node) => node.id === id);
    if (!found) break;
    out.push(found);
    level = found.children ?? [];
  }
  return out;
}

const MAX_INLINE = 4;

type MenuItem = { id: string; label: string; hint?: string; onSelect: () => void };

function CrumbMenu({
  items,
  renderTrigger,
  triggerAriaLabel,
  triggerClassName,
  triggerAriaCurrent,
  reduce,
}: {
  items: MenuItem[];
  renderTrigger: (open: boolean) => ReactNode;
  triggerAriaLabel: string;
  triggerClassName: string;
  triggerAriaCurrent?: boolean;
  reduce: boolean;
}) {
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const menuId = useId();
  const btnRef = useRef<HTMLButtonElement | null>(null);
  const menuRef = useRef<HTMLDivElement | null>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const close = useCallback((focusTrigger: boolean) => {
    setOpen(false);
    if (focusTrigger) btnRef.current?.focus();
  }, []);

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

  useEffect(() => {
    if (!open) return;
    function onPointerDown(event: PointerEvent) {
      const target = event.target as Node;
      if (menuRef.current?.contains(target) || btnRef.current?.contains(target)) return;
      setOpen(false);
    }
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  const moveTo = useCallback(
    (next: number) => {
      const count = items.length;
      if (count === 0) return;
      const idx = (next + count) % count;
      setActiveIndex(idx);
      itemRefs.current[idx]?.focus();
    },
    [items.length],
  );

  function onMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        moveTo(activeIndex + 1);
        break;
      case "ArrowUp":
        event.preventDefault();
        moveTo(activeIndex - 1);
        break;
      case "Home":
        event.preventDefault();
        moveTo(0);
        break;
      case "End":
        event.preventDefault();
        moveTo(items.length - 1);
        break;
      case "Escape":
        event.preventDefault();
        close(true);
        break;
      case "Tab":
        setOpen(false);
        break;
      default:
        break;
    }
  }

  function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      setOpen(true);
    }
  }

  return (
    <span className="relative inline-flex">
      <button
        ref={btnRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        aria-current={triggerAriaCurrent ? "page" : undefined}
        aria-label={triggerAriaLabel}
        onClick={() => setOpen((prev) => !prev)}
        onKeyDown={onTriggerKeyDown}
        className={triggerClassName}
      >
        {renderTrigger(open)}
      </button>

      <AnimatePresence>
        {open && (
          <motion.div
            id={menuId}
            ref={menuRef}
            role="menu"
            aria-label={triggerAriaLabel}
            onKeyDown={onMenuKeyDown}
            initial={reduce ? false : { opacity: 0, y: -6, scale: 0.97 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
            transition={{ duration: reduce ? 0 : 0.16, ease: [0.16, 1, 0.3, 1] }}
            className="absolute left-0 top-[calc(100%+0.4rem)] z-30 w-64 origin-top overflow-hidden rounded-2xl border border-slate-200 bg-white/95 p-1.5 shadow-2xl shadow-slate-900/10 backdrop-blur dark:border-white/10 dark:bg-slate-900/95 dark:shadow-black/50"
          >
            {items.map((item, index) => (
              <button
                key={item.id}
                ref={(el) => {
                  itemRefs.current[index] = el;
                }}
                type="button"
                role="menuitem"
                tabIndex={-1}
                onClick={() => {
                  item.onSelect();
                  setOpen(false);
                }}
                className="group flex w-full items-start gap-2.5 rounded-xl px-3 py-2 text-left transition-colors hover:bg-slate-100 focus:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:hover:bg-white/10 dark:focus:bg-white/10 dark:focus-visible:ring-indigo-400"
              >
                <span className="mt-0.5 select-none font-mono text-sm text-indigo-500 dark:text-indigo-400" aria-hidden="true">
                  /
                </span>
                <span className="flex min-w-0 flex-col">
                  <span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">{item.label}</span>
                  {item.hint ? (
                    <span className="truncate text-xs text-slate-500 dark:text-slate-400">{item.hint}</span>
                  ) : null}
                </span>
              </button>
            ))}
          </motion.div>
        )}
      </AnimatePresence>
    </span>
  );
}

function HomeIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" aria-hidden="true">
      <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 SlashSeparator() {
  return (
    <svg viewBox="0 0 20 20" fill="none" className="h-4 w-4 text-slate-300 dark:text-slate-600" aria-hidden="true">
      <line x1="7" y1="16" x2="13" y2="4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function DotsIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4" aria-hidden="true">
      <circle cx="4" cy="10" r="1.6" />
      <circle cx="10" cy="10" r="1.6" />
      <circle cx="16" cy="10" r="1.6" />
    </svg>
  );
}

function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="m6 8 4 4 4-4" />
    </svg>
  );
}

function CopyIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" aria-hidden="true">
      <rect x="7" y="7" width="10" height="10" rx="2" />
      <path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" aria-hidden="true">
      <path d="m4 10.5 4 4 8-9" />
    </svg>
  );
}

const linkClass =
  "inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";

const currentTriggerClass =
  "inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm font-semibold text-slate-900 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-white dark:hover:bg-white/10 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";

const overflowTriggerClass =
  "inline-flex items-center justify-center rounded-lg px-1.5 py-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";

type Rendered =
  | { kind: "crumb"; node: PageNode; depth: number; isCurrent: boolean }
  | { kind: "overflow"; hidden: { node: PageNode; depth: number }[] };

export default function CrumbSlash() {
  const reduce = useReducedMotion() ?? false;
  const [pathIds, setPathIds] = useState<string[]>(["home", "docs", "api"]);
  const [copied, setCopied] = useState(false);
  const copyTimer = useRef<number | null>(null);

  const trail = useMemo(() => resolvePath(pathIds), [pathIds]);
  const current = trail[trail.length - 1];
  const childList = current.children ?? [];
  const pathString = useMemo(() => trail.map((node) => node.label).join(" / "), [trail]);

  const navigateTo = useCallback((depth: number) => {
    setPathIds((ids) => ids.slice(0, depth + 1));
  }, []);

  const goDeeper = useCallback((childId: string) => {
    setPathIds((ids) => [...ids, childId]);
  }, []);

  useEffect(() => {
    return () => {
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    };
  }, []);

  const copyPath = useCallback(async () => {
    try {
      await navigator.clipboard?.writeText(pathString);
      setCopied(true);
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
      copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
    } catch {
      setCopied(false);
    }
  }, [pathString]);

  const items: Rendered[] = useMemo(() => {
    const out: Rendered[] = [];
    if (trail.length <= MAX_INLINE) {
      trail.forEach((node, depth) => {
        out.push({ kind: "crumb", node, depth, isCurrent: depth === trail.length - 1 });
      });
      return out;
    }
    out.push({ kind: "crumb", node: trail[0], depth: 0, isCurrent: false });
    const hidden = trail.slice(1, -2).map((node, i) => ({ node, depth: i + 1 }));
    out.push({ kind: "overflow", hidden });
    const tailStart = trail.length - 2;
    trail.slice(-2).forEach((node, i) => {
      const depth = tailStart + i;
      out.push({ kind: "crumb", node, depth, isCurrent: depth === trail.length - 1 });
    });
    return out;
  }, [trail]);

  const styleId = useId();

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes crumbslash-${styleId}-fadeup {
          from { opacity: 0; transform: translateY(8px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes crumbslash-${styleId}-pop {
          0% { opacity: 0; transform: scale(0.85); }
          60% { transform: scale(1.06); }
          100% { opacity: 1; transform: scale(1); }
        }
        .crumbslash-${styleId}-fadeup { animation: crumbslash-${styleId}-fadeup 0.4s cubic-bezier(0.16,1,0.3,1) both; }
        .crumbslash-${styleId}-pop { animation: crumbslash-${styleId}-pop 0.22s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .crumbslash-${styleId}-fadeup, .crumbslash-${styleId}-pop { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(60rem_40rem_at_50%_-10%,rgba(99,102,241,0.10),transparent)] dark:bg-[radial-gradient(60rem_40rem_at_50%_-10%,rgba(99,102,241,0.16),transparent)]"
      />

      <div className="mx-auto max-w-3xl">
        <div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-white/10 dark:bg-slate-900/70 dark:shadow-black/40">
          <div className="border-b border-slate-200 px-5 py-4 sm:px-7 sm:py-5 dark:border-white/10">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <div className="flex items-center gap-2.5">
                <span className="grid h-8 w-8 place-items-center rounded-xl bg-slate-900 font-mono text-base font-bold text-white dark:bg-white dark:text-slate-900">
                  /
                </span>
                <div>
                  <p className="text-sm font-semibold leading-none text-slate-900 dark:text-white">Meridian Docs</p>
                  <p className="mt-1 text-xs leading-none text-slate-500 dark:text-slate-400">Slash-separated breadcrumbs</p>
                </div>
              </div>

              <button
                type="button"
                onClick={copyPath}
                aria-label={copied ? "Path copied to clipboard" : "Copy current path"}
                className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:border-white/20 dark:hover:text-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950"
              >
                <span className={copied ? `crumbslash-${styleId}-pop text-emerald-500 dark:text-emerald-400` : ""}>
                  {copied ? <CheckIcon /> : <CopyIcon />}
                </span>
                <span aria-hidden="true">{copied ? "Copied" : "Copy path"}</span>
              </button>
            </div>
          </div>

          <nav aria-label="Breadcrumb" className="px-4 py-4 sm:px-6 sm:py-5">
            <ol className="flex flex-wrap items-center gap-x-0.5 gap-y-1.5">
              {items.map((item, index) => (
                <Fragment key={item.kind === "overflow" ? "overflow" : item.node.id}>
                  <li className="relative flex items-center">
                    {item.kind === "overflow" ? (
                      <CrumbMenu
                        reduce={reduce}
                        triggerAriaLabel="Show hidden breadcrumb levels"
                        triggerClassName={overflowTriggerClass}
                        renderTrigger={() => <DotsIcon />}
                        items={item.hidden.map((h) => ({
                          id: h.node.id,
                          label: h.node.label,
                          hint: h.node.blurb,
                          onSelect: () => navigateTo(h.depth),
                        }))}
                      />
                    ) : item.isCurrent ? (
                      childList.length > 0 ? (
                        <CrumbMenu
                          reduce={reduce}
                          triggerAriaCurrent
                          triggerAriaLabel={`Current page, ${item.node.label}. Browse sub-pages`}
                          triggerClassName={currentTriggerClass}
                          renderTrigger={(open) => (
                            <>
                              {item.depth === 0 ? <HomeIcon /> : null}
                              <span>{item.node.label}</span>
                              <ChevronIcon className={`h-4 w-4 text-slate-400 transition-transform duration-200 dark:text-slate-500 ${open ? "rotate-180" : ""}`} />
                            </>
                          )}
                          items={childList.map((child) => ({
                            id: child.id,
                            label: child.label,
                            hint: child.blurb,
                            onSelect: () => goDeeper(child.id),
                          }))}
                        />
                      ) : (
                        <span aria-current="page" className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-semibold text-slate-900 dark:text-white">
                          {item.depth === 0 ? <HomeIcon /> : null}
                          {item.node.label}
                        </span>
                      )
                    ) : (
                      <button type="button" onClick={() => navigateTo(item.depth)} className={linkClass}>
                        {item.depth === 0 ? <HomeIcon /> : null}
                        <span>{item.node.label}</span>
                      </button>
                    )}
                  </li>

                  {index < items.length - 1 ? (
                    <li aria-hidden="true" className="flex items-center">
                      <SlashSeparator />
                    </li>
                  ) : null}
                </Fragment>
              ))}
            </ol>
          </nav>

          <div key={current.id} className={`crumbslash-${styleId}-fadeup border-t border-slate-200 px-5 py-6 sm:px-7 sm:py-7 dark:border-white/10`}>
            <p className="text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">You are here</p>
            <h2 className="mt-2 text-xl font-bold tracking-tight text-slate-900 sm:text-2xl dark:text-white">{current.label}</h2>
            <p className="mt-2 max-w-prose text-sm leading-relaxed text-slate-600 dark:text-slate-300">{current.blurb}</p>

            {childList.length > 0 ? (
              <div className="mt-6">
                <p className="mb-2.5 text-xs font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">In this section</p>
                <ul className="grid gap-2 sm:grid-cols-2">
                  {childList.map((child) => (
                    <li key={child.id}>
                      <button
                        type="button"
                        onClick={() => goDeeper(child.id)}
                        className="group flex w-full items-start gap-3 rounded-xl border border-slate-200 bg-slate-50/60 px-3.5 py-3 text-left transition-colors hover:border-indigo-300 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/10 dark:bg-white/[0.03] dark:hover:border-indigo-400/60 dark:hover:bg-white/[0.06] dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950"
                      >
                        <span className="mt-0.5 select-none font-mono text-sm text-slate-300 transition-colors group-hover:text-indigo-500 dark:text-slate-600 dark:group-hover:text-indigo-400" aria-hidden="true">
                          /
                        </span>
                        <span className="flex min-w-0 flex-col">
                          <span className="text-sm font-semibold text-slate-800 dark:text-slate-100">{child.label}</span>
                          <span className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-slate-500 dark:text-slate-400">{child.blurb}</span>
                        </span>
                      </button>
                    </li>
                  ))}
                </ul>
              </div>
            ) : (
              <div className="mt-6 rounded-xl border border-dashed border-slate-200 px-4 py-3 text-xs text-slate-500 dark:border-white/10 dark:text-slate-400">
                This is a leaf page — use the breadcrumb slashes above to step back up the tree.
              </div>
            )}
          </div>
        </div>

        <p className="mx-auto mt-5 max-w-md text-center text-xs leading-relaxed text-slate-500 dark:text-slate-400">
          Click any crumb to jump up a level. Open the{" "}
          <ChevronIcon className="inline h-3.5 w-3.5 -translate-y-px text-slate-400 dark:text-slate-500" /> on the current page, or the{" "}
          <span className="font-mono text-slate-400 dark:text-slate-500">•••</span> overflow menu, to go deeper — everything is keyboard navigable.
        </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 →