Web InnoventixFreeCode

Pill Breadcrumb

Original · free

pill breadcrumbs

byWeb InnoventixReact + Tailwind
crumbpillbreadcrumbs
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-pill.json
crumb-pill.tsx
"use client"

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

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

type Route = {
  id: string
  name: string
  icon: ReactNode
  trail: Crumb[]
}

type RenderItem =
  | { kind: "crumb"; crumb: Crumb; isCurrent: boolean }
  | { kind: "ellipsis"; hidden: Crumb[] }

function BagIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4 shrink-0"
      aria-hidden="true"
    >
      <path d="M6 7h12l1 13H5L6 7Z" />
      <path d="M9 7a3 3 0 0 1 6 0" />
    </svg>
  )
}

function BookIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4 shrink-0"
      aria-hidden="true"
    >
      <path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15H6.5A2.5 2.5 0 0 0 4 20.5V5.5Z" />
      <path d="M4 20.5A2.5 2.5 0 0 1 6.5 18H19v3H6.5A2.5 2.5 0 0 1 4 20.5Z" />
    </svg>
  )
}

function PenIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4 shrink-0"
      aria-hidden="true"
    >
      <path d="M15 4.5 19.5 9 8.5 20H4v-4.5L15 4.5Z" />
      <path d="m13 6.5 4.5 4.5" />
    </svg>
  )
}

function ChevronIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-3.5 w-3.5"
      aria-hidden="true"
    >
      <path d="m9 6 6 6-6 6" />
    </svg>
  )
}

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

function CopyIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <rect x="9" y="9" width="11" height="11" rx="2.5" />
      <path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" />
    </svg>
  )
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="crumbpill-pop h-4 w-4"
      aria-hidden="true"
    >
      <path d="m5 12.5 4.5 4.5L19 6.5" />
    </svg>
  )
}

const ROUTES: Route[] = [
  {
    id: "store",
    name: "Store",
    icon: <BagIcon />,
    trail: [
      { id: "s-home", label: "Home", href: "#/" },
      { id: "s-electronics", label: "Electronics", href: "#/electronics" },
      { id: "s-audio", label: "Audio", href: "#/electronics/audio" },
      { id: "s-headphones", label: "Headphones", href: "#/electronics/audio/headphones" },
      { id: "s-overear", label: "Over-Ear", href: "#/electronics/audio/headphones/over-ear" },
      { id: "s-product", label: "Studio Reference 40", href: "#/electronics/audio/headphones/over-ear/sr40" },
    ],
  },
  {
    id: "docs",
    name: "Docs",
    icon: <BookIcon />,
    trail: [
      { id: "d-docs", label: "Docs", href: "#/docs" },
      { id: "d-components", label: "Components", href: "#/docs/components" },
      { id: "d-navigation", label: "Navigation", href: "#/docs/components/navigation" },
      { id: "d-breadcrumbs", label: "Breadcrumbs", href: "#/docs/components/navigation/breadcrumbs" },
    ],
  },
  {
    id: "blog",
    name: "Journal",
    icon: <PenIcon />,
    trail: [
      { id: "b-journal", label: "Journal", href: "#/journal" },
      { id: "b-engineering", label: "Engineering", href: "#/journal/engineering" },
      { id: "b-post", label: "Building an accessible breadcrumb", href: "#/journal/engineering/accessible-breadcrumb" },
    ],
  },
]

const COLLAPSE_AFTER = 4

function buildRenderList(trail: Crumb[], expanded: boolean): RenderItem[] {
  const lastIndex = trail.length - 1
  const shouldCollapse = trail.length > COLLAPSE_AFTER && !expanded
  if (!shouldCollapse) {
    return trail.map((crumb, i) => ({ kind: "crumb", crumb, isCurrent: i === lastIndex }))
  }
  const first = trail[0]
  const hidden = trail.slice(1, lastIndex - 1)
  const tail = trail.slice(lastIndex - 1)
  return [
    { kind: "crumb", crumb: first, isCurrent: false },
    { kind: "ellipsis", hidden },
    ...tail.map((crumb, i) => ({
      kind: "crumb" as const,
      crumb,
      isCurrent: i === tail.length - 1,
    })),
  ]
}

export default function CrumbPill() {
  const reduce = useReducedMotion()
  const [routeId, setRouteId] = useState<string>(ROUTES[0].id)
  const [expanded, setExpanded] = useState(false)
  const [activeIndex, setActiveIndex] = useState(0)
  const [copied, setCopied] = useState(false)

  const linkRefs = useRef<(HTMLElement | null)[]>([])
  const focusAfterExpand = useRef(false)
  const copyTimer = useRef<number | null>(null)

  const route = ROUTES.find((r) => r.id === routeId) ?? ROUTES[0]
  const renderList = buildRenderList(route.trail, expanded)
  const count = renderList.length

  useEffect(() => {
    setExpanded(false)
    setActiveIndex(0)
  }, [routeId])

  useEffect(() => {
    if (expanded && focusAfterExpand.current) {
      focusAfterExpand.current = false
      linkRefs.current[1]?.focus()
    }
  }, [expanded])

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

  const handleKeyNav = useCallback(
    (e: KeyboardEvent<HTMLElement>, total: number) => {
      let next = -1
      switch (e.key) {
        case "ArrowRight":
        case "ArrowDown":
          next = Math.min(activeIndex + 1, total - 1)
          break
        case "ArrowLeft":
        case "ArrowUp":
          next = Math.max(activeIndex - 1, 0)
          break
        case "Home":
          next = 0
          break
        case "End":
          next = total - 1
          break
        default:
          return
      }
      e.preventDefault()
      setActiveIndex(next)
      linkRefs.current[next]?.focus()
    },
    [activeIndex],
  )

  const handleExpand = useCallback(() => {
    focusAfterExpand.current = true
    setExpanded(true)
    setActiveIndex(1)
  }, [])

  const handleCopy = useCallback(async () => {
    const text = route.trail.map((c) => c.label).join(" / ")
    try {
      await navigator.clipboard.writeText(text)
      setCopied(true)
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current)
      copyTimer.current = window.setTimeout(() => setCopied(false), 1800)
    } catch {
      setCopied(false)
    }
  }, [route])

  const itemVariants = {
    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.85 },
    animate: { opacity: 1, y: 0, scale: 1 },
    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.85 },
  }

  const pillBase =
    "relative inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
  const pillLink =
    "border border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-white"
  const pillCurrent =
    "overflow-hidden border border-transparent bg-gradient-to-r from-indigo-500 to-violet-500 text-white shadow-sm shadow-indigo-500/30"

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-zinc-50 to-white px-4 py-16 sm:py-24 dark:from-zinc-950 dark:to-zinc-900">
      <style>{`
        @keyframes crumbpill-sheen {
          0% { transform: translateX(-130%) skewX(-18deg); opacity: 0; }
          16% { opacity: 0.55; }
          52% { opacity: 0; }
          100% { transform: translateX(240%) skewX(-18deg); opacity: 0; }
        }
        @keyframes crumbpill-pop {
          0% { transform: scale(0.4); opacity: 0; }
          60% { transform: scale(1.15); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        .crumbpill-sheen {
          background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%);
          animation: crumbpill-sheen 3.8s ease-in-out 1.2s infinite;
        }
        .crumbpill-pop { animation: crumbpill-pop 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .crumbpill-sheen { animation: none; opacity: 0; }
          .crumbpill-pop { animation: none; }
        }
      `}</style>

      <div className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-300/20 blur-3xl dark:bg-indigo-500/10" />

      <div className="relative mx-auto max-w-3xl">
        <header className="mb-8 sm:mb-10">
          <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/60 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:border-zinc-700 dark:bg-zinc-900/60 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Navigation
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
            Pill breadcrumbs
          </h2>
          <p className="mt-2 max-w-xl text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
            A rounded breadcrumb trail that collapses long paths behind an
            expandable step. Fully keyboard-navigable with a roving focus model.
          </p>
        </header>

        <div className="rounded-3xl border border-zinc-200 bg-white/80 p-6 shadow-xl shadow-zinc-900/5 backdrop-blur-sm sm:p-8 dark:border-zinc-800 dark:bg-zinc-900/70 dark:shadow-black/30">
          <div
            role="group"
            aria-label="Choose an example breadcrumb trail"
            className="mb-7 inline-flex rounded-full border border-zinc-200 bg-zinc-100/80 p-1 dark:border-zinc-700 dark:bg-zinc-800/70"
          >
            {ROUTES.map((r) => {
              const active = r.id === routeId
              return (
                <button
                  key={r.id}
                  type="button"
                  aria-pressed={active}
                  onClick={() => setRouteId(r.id)}
                  className={`relative rounded-full px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-800 ${
                    active
                      ? "text-zinc-900 dark:text-white"
                      : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
                  }`}
                >
                  {active && (
                    <motion.span
                      layoutId="crumbpill-seg"
                      className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-zinc-700"
                      transition={{ duration: reduce ? 0 : 0.3, ease: [0.22, 1, 0.36, 1] }}
                    />
                  )}
                  <span className="relative z-10 inline-flex items-center gap-1.5">
                    {r.icon}
                    {r.name}
                  </span>
                </button>
              )
            })}
          </div>

          <nav aria-label="Breadcrumb" onKeyDown={(e) => handleKeyNav(e, count)}>
            <ol className="flex flex-wrap items-center gap-1.5">
              <AnimatePresence mode="popLayout" initial={false}>
                {renderList.map((item, i) => {
                  const key = item.kind === "ellipsis" ? "ellipsis" : item.crumb.id
                  return (
                    <motion.li
                      key={key}
                      layout
                      variants={itemVariants}
                      initial="initial"
                      animate="animate"
                      exit="exit"
                      transition={{ duration: reduce ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] }}
                      className="flex items-center gap-1.5"
                    >
                      {i > 0 && (
                        <span
                          aria-hidden="true"
                          className="text-zinc-300 dark:text-zinc-600"
                        >
                          <ChevronIcon />
                        </span>
                      )}

                      {item.kind === "crumb" ? (
                        <a
                          ref={(el) => {
                            linkRefs.current[i] = el
                          }}
                          href={item.crumb.href}
                          aria-current={item.isCurrent ? "page" : undefined}
                          tabIndex={activeIndex === i ? 0 : -1}
                          onFocus={() => setActiveIndex(i)}
                          onClick={(e) => {
                            e.preventDefault()
                            setActiveIndex(i)
                          }}
                          className={`${pillBase} ${item.isCurrent ? pillCurrent : pillLink}`}
                        >
                          {i === 0 && (
                            <span
                              className={
                                item.isCurrent
                                  ? "text-white"
                                  : "text-indigo-500 dark:text-indigo-400"
                              }
                            >
                              {route.icon}
                            </span>
                          )}
                          <span>{item.crumb.label}</span>
                          {item.isCurrent && (
                            <span
                              aria-hidden="true"
                              className="crumbpill-sheen pointer-events-none absolute inset-y-0 left-0 w-1/3"
                            />
                          )}
                        </a>
                      ) : (
                        <button
                          ref={(el) => {
                            linkRefs.current[i] = el
                          }}
                          type="button"
                          onClick={handleExpand}
                          onFocus={() => setActiveIndex(i)}
                          tabIndex={activeIndex === i ? 0 : -1}
                          aria-expanded={false}
                          aria-label={`Show ${item.hidden.length} hidden ${
                            item.hidden.length === 1 ? "step" : "steps"
                          }: ${item.hidden.map((h) => h.label).join(", ")}`}
                          className={`${pillBase} ${pillLink} px-2.5`}
                        >
                          <EllipsisIcon />
                        </button>
                      )}
                    </motion.li>
                  )
                })}
              </AnimatePresence>
            </ol>
          </nav>

          <div className="mt-7 flex flex-col gap-4 border-t border-zinc-200/70 pt-5 sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800">
            <p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
              Focus a pill, then use{" "}
              <kbd className="rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 font-mono text-[0.7rem] text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
                ←
              </kbd>{" "}
              <kbd className="rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 font-mono text-[0.7rem] text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
                →
              </kbd>{" "}
              to move. Open the{" "}
              <span className="font-medium text-zinc-700 dark:text-zinc-200">⋯</span>{" "}
              step to reveal a collapsed trail.
            </p>

            <button
              type="button"
              onClick={handleCopy}
              className={`inline-flex shrink-0 items-center justify-center gap-2 rounded-full border px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
                copied
                  ? "border-emerald-300 bg-emerald-50 text-emerald-700 focus-visible:ring-emerald-500 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
                  : "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-100 focus-visible:ring-indigo-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
              }`}
            >
              {copied ? <CheckIcon /> : <CopyIcon />}
              {copied ? "Path copied" : "Copy path"}
            </button>
          </div>

          <span className="sr-only" role="status" aria-live="polite">
            {copied ? "Breadcrumb path copied to clipboard" : ""}
          </span>
        </div>
      </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 →