Web InnoventixFreeCode

Dropdown Breadcrumb

Original · free

breadcrumb with a dropdown level

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

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

type Section = {
  id: string;
  label: string;
  summary: string;
  pages: string[];
};

const SECTIONS: Section[] = [
  {
    id: "navigation",
    label: "Navigation",
    summary: "Wayfinding patterns — menus, tabs, breadcrumbs and pagination.",
    pages: ["Breadcrumb", "Tabs", "Pagination", "Command Menu"],
  },
  {
    id: "data-display",
    label: "Data Display",
    summary: "Presenting records — tables, stat cards, badges and timelines.",
    pages: ["Data Table", "Stat Card", "Badge", "Timeline"],
  },
  {
    id: "feedback",
    label: "Feedback",
    summary: "System response — toasts, alerts, progress and skeletons.",
    pages: ["Toast", "Alert", "Progress", "Skeleton"],
  },
  {
    id: "overlays",
    label: "Overlays",
    summary: "Layered surfaces — dialogs, popovers, drawers and tooltips.",
    pages: ["Dialog", "Popover", "Drawer", "Tooltip"],
  },
  {
    id: "forms",
    label: "Forms",
    summary: "Data entry and validation — fields, selects, switches and comboboxes.",
    pages: ["Text Field", "Select", "Switch", "Combobox"],
  },
];

function HomeIcon() {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
      className="h-4 w-4"
    >
      <path
        d="M3.5 9.5 10 4l6.5 5.5M5 8.5V16h3.25v-3.75h3.5V16H15V8.5"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ChevronRight() {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
      className="h-3.5 w-3.5"
    >
      <path
        d="M7.75 4.5 13.25 10l-5.5 5.5"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ChevronDown() {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
      className="h-3.5 w-3.5"
    >
      <path
        d="M5.5 8 10 12.5 14.5 8"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

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

export default function CrumbDropdown() {
  const reduce = useReducedMotion();
  const baseId = useId();
  const triggerId = `${baseId}-trigger`;
  const menuId = `${baseId}-menu`;

  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const [focusIndex, setFocusIndex] = useState(0);
  const [currentPage, setCurrentPage] = useState(SECTIONS[0].pages[0]);

  const rootRef = useRef<HTMLDivElement | null>(null);
  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const activeSection = SECTIONS[activeIndex];

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

  useEffect(() => {
    if (!open) return;
    function handlePointer(event: PointerEvent) {
      if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener("pointerdown", handlePointer);
    return () => document.removeEventListener("pointerdown", handlePointer);
  }, [open]);

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

  function closeMenu(returnFocus: boolean) {
    setOpen(false);
    if (returnFocus) triggerRef.current?.focus();
  }

  function toggleMenu() {
    if (open) setOpen(false);
    else openMenu(activeIndex);
  }

  function selectSection(index: number) {
    setActiveIndex(index);
    setCurrentPage(SECTIONS[index].pages[0]);
    closeMenu(true);
  }

  function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
    if (event.key === "ArrowDown") {
      event.preventDefault();
      openMenu(activeIndex);
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      openMenu(SECTIONS.length - 1);
    }
  }

  function onMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
    const last = SECTIONS.length - 1;
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        setFocusIndex((i) => (i >= last ? 0 : i + 1));
        break;
      case "ArrowUp":
        event.preventDefault();
        setFocusIndex((i) => (i <= 0 ? last : i - 1));
        break;
      case "Home":
        event.preventDefault();
        setFocusIndex(0);
        break;
      case "End":
        event.preventDefault();
        setFocusIndex(last);
        break;
      case "Escape":
        event.preventDefault();
        closeMenu(true);
        break;
      case "Tab":
        setOpen(false);
        break;
      default:
        break;
    }
  }

  const crumbLink =
    "inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-slate-500 transition-colors 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:text-white dark:focus-visible:ring-offset-slate-900";

  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">
      <style>{`
        @keyframes crmbdd-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.4; transform: scale(0.65); }
        }
        @keyframes crmbdd-ring {
          0% { transform: scale(0.6); opacity: 0.55; }
          70% { transform: scale(2.1); opacity: 0; }
          100% { transform: scale(2.1); opacity: 0; }
        }
        .crmbdd-dot { animation: crmbdd-pulse 2.6s ease-in-out infinite; }
        .crmbdd-ring { animation: crmbdd-ring 2.6s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .crmbdd-dot, .crmbdd-ring { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-64 w-[36rem] -translate-x-1/2 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-600/10"
      />

      <div className="relative mx-auto w-full max-w-3xl">
        <p className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
          Aurora UI · Reference
        </p>

        <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900/60 sm:p-7">
          <nav aria-label="Breadcrumb">
            <ol className="flex flex-wrap items-center gap-x-1 gap-y-1.5 text-sm font-medium">
              <li>
                <a href="#home" className={crumbLink}>
                  <HomeIcon />
                  <span className="sr-only sm:not-sr-only">Home</span>
                </a>
              </li>
              <li aria-hidden="true" className="text-slate-300 dark:text-slate-600">
                <ChevronRight />
              </li>
              <li>
                <a href="#components" className={crumbLink}>
                  Components
                </a>
              </li>
              <li aria-hidden="true" className="text-slate-300 dark:text-slate-600">
                <ChevronRight />
              </li>

              <li>
                <div ref={rootRef} className="relative">
                  <button
                    id={triggerId}
                    ref={triggerRef}
                    type="button"
                    aria-haspopup="menu"
                    aria-expanded={open}
                    aria-controls={open ? menuId : undefined}
                    aria-label={`Section: ${activeSection.label}. Choose another section`}
                    onClick={toggleMenu}
                    onKeyDown={onTriggerKeyDown}
                    className={
                      "inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors 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 " +
                      (open
                        ? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300"
                        : "text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-white")
                    }
                  >
                    {activeSection.label}
                    <motion.span
                      className="inline-flex text-slate-400 dark:text-slate-500"
                      animate={{ rotate: open ? 180 : 0 }}
                      transition={{ duration: reduce ? 0 : 0.2, ease: [0.16, 1, 0.3, 1] }}
                    >
                      <ChevronDown />
                    </motion.span>
                  </button>

                  <AnimatePresence>
                    {open && (
                      <motion.div
                        id={menuId}
                        role="menu"
                        aria-labelledby={triggerId}
                        aria-orientation="vertical"
                        onKeyDown={onMenuKeyDown}
                        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 w-72 origin-top-left rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40 dark:ring-white/10"
                      >
                        <p className="px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
                          Jump to section
                        </p>
                        <ul className="flex flex-col gap-0.5">
                          {SECTIONS.map((section, i) => {
                            const selected = i === activeIndex;
                            return (
                              <li key={section.id} role="none">
                                <button
                                  ref={(el) => {
                                    itemRefs.current[i] = el;
                                  }}
                                  type="button"
                                  role="menuitemradio"
                                  aria-checked={selected}
                                  tabIndex={i === focusIndex ? 0 : -1}
                                  onClick={() => selectSection(i)}
                                  onMouseEnter={() => setFocusIndex(i)}
                                  className={
                                    "group flex w-full items-start gap-3 rounded-lg px-2.5 py-2 text-left transition-colors focus:outline-none " +
                                    (i === focusIndex
                                      ? "bg-indigo-50 dark:bg-indigo-500/10"
                                      : "hover:bg-slate-50 dark:hover:bg-slate-800/60")
                                  }
                                >
                                  <span className="min-w-0 flex-1">
                                    <span className="block text-sm font-medium text-slate-900 dark:text-white">
                                      {section.label}
                                    </span>
                                    <span className="mt-0.5 block text-xs leading-snug text-slate-500 dark:text-slate-400">
                                      {section.summary}
                                    </span>
                                  </span>
                                  <span
                                    aria-hidden={!selected}
                                    className={
                                      "mt-0.5 shrink-0 text-emerald-500 transition-opacity dark:text-emerald-400 " +
                                      (selected ? "opacity-100" : "opacity-0")
                                    }
                                  >
                                    <CheckIcon />
                                  </span>
                                </button>
                              </li>
                            );
                          })}
                        </ul>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
              </li>

              <li aria-hidden="true" className="text-slate-300 dark:text-slate-600">
                <ChevronRight />
              </li>
              <li>
                <span
                  aria-current="page"
                  className="rounded-md px-1.5 py-1 font-semibold text-slate-900 dark:text-white"
                >
                  {currentPage}
                </span>
              </li>
            </ol>
          </nav>

          <div className="my-6 h-px w-full bg-slate-200 dark:bg-slate-800" />

          <div>
            <div className="flex items-center gap-2.5">
              <span className="relative inline-flex h-2.5 w-2.5 items-center justify-center">
                <span className="crmbdd-ring absolute inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500/60" />
                <span className="crmbdd-dot relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
              </span>
              <h2 className="text-lg font-semibold text-slate-900 dark:text-white">
                {activeSection.label}
              </h2>
            </div>
            <p className="mt-1.5 text-sm text-slate-600 dark:text-slate-300">
              {activeSection.summary}
            </p>

            <div className="mt-4 flex flex-wrap gap-2" role="group" aria-label={`Pages in ${activeSection.label}`}>
              {activeSection.pages.map((page) => {
                const isCurrent = page === currentPage;
                return (
                  <button
                    key={page}
                    type="button"
                    aria-pressed={isCurrent}
                    onClick={() => setCurrentPage(page)}
                    className={
                      "rounded-full border px-3 py-1 text-xs font-medium transition-colors 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 " +
                      (isCurrent
                        ? "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-500"
                        : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white")
                    }
                  >
                    {page}
                  </button>
                );
              })}
            </div>
          </div>
        </div>

        <p className="mt-4 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
          Focus the
          <span className="font-medium text-slate-700 dark:text-slate-200">{activeSection.label}</span>
          crumb, then press
          <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-sans text-[11px] font-medium text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
            ↓
          </kbd>
          to browse sections and
          <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-sans text-[11px] font-medium text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
            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 →