{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "breadcrumb-trail",
  "type": "registry:block",
  "title": "Breadcrumb Trail",
  "description": "A breadcrumb that folds its middle levels into an ellipsis; hover or focus it and they spring out while the trail slides to make room. Every level reveals a dropdown of its siblings on hover, ArrowDown or a tap on its separator.",
  "docs": "Breadcrumb Trail was added to public/vividsites/breadcrumb-trail.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/breadcrumb-trail",
  "categories": [
    "ui",
    "navigation"
  ],
  "files": [
    {
      "path": "components/vividsites/breadcrumb-trail.html",
      "type": "registry:file",
      "target": "public/vividsites/breadcrumb-trail.html",
      "content": "<!--\nvs-breadcrumb-trail: a breadcrumb that collapses its middle levels into an ellipsis. Hover or\n  focus the ellipsis and the hidden levels spring out of it while the rest of the trail slides\n  to make room (interruptible FLIP on per-item springs; transforms and opacity only). Every\n  level can hold a list of its siblings, revealed on hover, ArrowDown, or a tap on the\n  separator before it. A hairline underline glides between hovered crumbs. If the trail does\n  not fit its container, it collapses further on its own.\n\n  Markup:  <nav class=\"vs-breadcrumb-trail\" aria-label=\"Breadcrumb\"><ol>\n             <li><a href=\"/studio\">studio</a> <ul><li><a href=\"/archive\">archive</a></li>...</ul></li>\n             ...\n             <li><a href=\"/hero\" aria-current=\"page\">hero.png</a></li>\n           </ol></nav>\n           The optional <ul> inside an <li> lists that level's siblings (include the level itself).\n  Attributes on the <nav>:\n    data-max   crumbs kept visible when collapsed, first and last included (default 4, min 2)\n    data-sep   \"chevron\" (default), \"slash\" or \"dot\"\n  Keyboard: Tab through crumbs; the ellipsis expands on focus and hands focus to the first\n    hidden level; ArrowDown on a crumb opens its siblings, ArrowUp/Down move, Escape closes.\n  Events (bubble from the <nav>): \"trailexpand\" / \"trailcollapse\".\n  API: window.vsBreadcrumbTrail.mount(nav) -> { expand(), collapse(), refit() }.\n  Style with the --vs-breadcrumb-trail-* variables below. Reduced motion: no springs, instant.\n-->\n<link href=\"https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">\n<nav class=\"vs-breadcrumb-trail\" aria-label=\"Breadcrumb\" data-max=\"4\" data-sep=\"chevron\">\n  <ol>\n    <li><a href=\"#studio\">studio</a>\n      <ul><li><a href=\"#studio\">studio</a></li><li><a href=\"#archive\">archive</a></li><li><a href=\"#personal\">personal</a></li></ul></li>\n    <li><a href=\"#projects\">projects</a>\n      <ul><li><a href=\"#projects\">projects</a></li><li><a href=\"#clients\">clients</a></li><li><a href=\"#drafts\">drafts</a></li></ul></li>\n    <li><a href=\"#harbor\">harbor</a>\n      <ul><li><a href=\"#harbor\">harbor</a></li><li><a href=\"#tidewater\">tidewater</a></li><li><a href=\"#lantern\">lantern</a></li><li><a href=\"#ferry\">ferry</a></li></ul></li>\n    <li><a href=\"#assets\">assets</a>\n      <ul><li><a href=\"#assets\">assets</a></li><li><a href=\"#docs\">docs</a></li><li><a href=\"#exports\">exports</a></li></ul></li>\n    <li><a href=\"#renders\">renders</a>\n      <ul><li><a href=\"#renders\">renders</a></li><li><a href=\"#sketches\">sketches</a></li><li><a href=\"#textures\">textures</a></li></ul></li>\n    <li><a href=\"#hero-dusk\" aria-current=\"page\">hero-dusk.png</a>\n      <ul><li><a href=\"#hero-dusk\">hero-dusk.png</a></li><li><a href=\"#hero-dawn\">hero-dawn.png</a></li><li><a href=\"#hero-night\">hero-night.png</a></li><li><a href=\"#hero-mobile\">hero-mobile.png</a></li></ul></li>\n  </ol>\n</nav>\n\n<style>\n.vs-breadcrumb-trail {\n  --vs-breadcrumb-trail-size: 1.625rem;        /* crumb text size; everything scales from it */\n  --vs-breadcrumb-trail-font: \"DM Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n  --vs-breadcrumb-trail-accent: #52795c;       /* underline, current chip, open separator */\n  --vs-breadcrumb-trail-ink: color-mix(in srgb, var(--vs-breadcrumb-trail-accent) 32%, #0f1311);\n  --vs-breadcrumb-trail-paper: color-mix(in srgb, var(--vs-breadcrumb-trail-accent) 4%, #fcfbf8);  /* dropdown surface */\n  --vs-breadcrumb-trail-focus: var(--vs-breadcrumb-trail-accent);\n\n  --ink: var(--vs-breadcrumb-trail-ink); --acc: var(--vs-breadcrumb-trail-accent);\n  position: relative; display: block; height: max-content; align-self: flex-start; font: 400 var(--vs-breadcrumb-trail-size)/1.2 var(--vs-breadcrumb-trail-font);\n  color: var(--ink); letter-spacing: -.02em; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;\n}\n.vs-breadcrumb-trail, .vs-breadcrumb-trail * { box-sizing: border-box; }\n.vs-breadcrumb-trail > ol { position: relative; display: flex; flex-wrap: wrap; align-items: center; row-gap: .3em; margin: 0; padding: 0; list-style: none; }\n.vs-breadcrumb-trail-item { position: relative; display: flex; align-items: center; margin: 0; transform-origin: 0 50%; will-change: transform, opacity; }\n.vs-breadcrumb-trail-item.is-out { position: absolute; pointer-events: none; }\n.vs-breadcrumb-trail-item.is-popped { z-index: 3; }\n.vs-breadcrumb-trail-crumb {\n  display: block; padding: .18em .32em; border-radius: .28em; white-space: nowrap; text-decoration: none; outline: none;\n  color: color-mix(in srgb, var(--ink) 56%, transparent); -webkit-tap-highlight-color: transparent;\n  transition: color 150ms cubic-bezier(.2,0,0,1), background-color 150ms cubic-bezier(.2,0,0,1);\n}\n.vs-breadcrumb-trail-crumb:hover, .vs-breadcrumb-trail-crumb[aria-expanded=\"true\"] { color: var(--ink); }\n.vs-breadcrumb-trail-crumb[aria-current=\"page\"] {\n  margin-left: .08em; padding-inline: .5em; font-weight: 500; color: var(--ink);\n  background: color-mix(in srgb, var(--acc) 15%, transparent);\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--acc) 34%, transparent), inset 0 1px 0 rgba(255,255,255,.35);\n}\n.vs-breadcrumb-trail :is(a, button):focus-visible { outline: 2px solid var(--vs-breadcrumb-trail-focus); outline-offset: 2px; }\n/* separators: a button (pointer / touch) that opens the next level's siblings */\n.vs-breadcrumb-trail-sep {\n  all: unset; box-sizing: border-box; display: grid; place-items: center; width: 1.05em; height: 1.3em; border-radius: .25em; flex: none;\n  color: color-mix(in srgb, var(--ink) 30%, transparent); -webkit-tap-highlight-color: transparent;\n  transition: color 150ms cubic-bezier(.2,0,0,1), background-color 150ms cubic-bezier(.2,0,0,1);\n}\nbutton.vs-breadcrumb-trail-sep { cursor: pointer; }\nbutton.vs-breadcrumb-trail-sep:hover { color: color-mix(in srgb, var(--ink) 70%, transparent); background: color-mix(in srgb, var(--ink) 6%, transparent); }\n.vs-breadcrumb-trail-sep svg { width: .56em; height: .56em; overflow: visible; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round;\n  transition: transform .6s cubic-bezier(.3,1.5,.45,1); }\n.vs-breadcrumb-trail-sep.is-open { color: var(--acc); }\n.vs-breadcrumb-trail[data-sep=\"chevron\"] .vs-breadcrumb-trail-sep.is-open svg, .vs-breadcrumb-trail:not([data-sep]) .vs-breadcrumb-trail-sep.is-open svg { transform: rotate(90deg); }\n.vs-breadcrumb-trail[data-sep=\"slash\"] .vs-breadcrumb-trail-sep.is-open svg { transform: rotate(-35deg) scale(1.1); }\n.vs-breadcrumb-trail[data-sep=\"dot\"] .vs-breadcrumb-trail-sep svg { width: .3em; height: .3em; fill: currentColor; stroke: none; }\n.vs-breadcrumb-trail[data-sep=\"dot\"] .vs-breadcrumb-trail-sep.is-open svg { transform: scale(1.7); }\n/* the ellipsis */\n.vs-breadcrumb-trail-dots {\n  all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: .17em;\n  height: 1.56em; padding: 0 .42em; border-radius: .28em; cursor: pointer; color: color-mix(in srgb, var(--ink) 56%, transparent);\n  -webkit-tap-highlight-color: transparent; transition: background-color 150ms cubic-bezier(.2,0,0,1), color 150ms;\n}\n.vs-breadcrumb-trail-dots i { width: .17em; height: .17em; border-radius: 50%; background: currentColor; transition: transform .5s cubic-bezier(.3,1.6,.45,1); }\n.vs-breadcrumb-trail-dots:hover, .vs-breadcrumb-trail-dots:focus-visible { color: var(--ink); background: color-mix(in srgb, var(--acc) 13%, transparent); }\n.vs-breadcrumb-trail-dots:hover i:nth-child(1) { transform: translateX(-.09em); }\n.vs-breadcrumb-trail-dots:hover i:nth-child(3) { transform: translateX(.09em); }\n/* hover underline */\n.vs-breadcrumb-trail-line { position: absolute; left: 0; top: 0; width: 100px; height: 2px; margin-top: -.12em; border-radius: 2px; pointer-events: none;\n  background: var(--acc); transform-origin: 0 0; opacity: 0; }\n/* the sibling dropdown */\n.vs-breadcrumb-trail-pop {\n  position: absolute; left: 0; top: calc(100% + .3em); z-index: 4; min-width: 100%; padding: .3em; border-radius: .62em;\n  font-size: .62em; letter-spacing: -.01em; background: var(--vs-breadcrumb-trail-paper); visibility: hidden; opacity: 0; transform-origin: 1.2em 0;\n  box-shadow: 0 0 0 .5px color-mix(in srgb, var(--ink) 14%, transparent), inset 0 1px 0 rgba(255,255,255,.8),\n    0 1px 2px color-mix(in srgb, var(--ink) 8%, transparent), 0 .9em 2.2em -.8em color-mix(in srgb, var(--ink) 30%, transparent);\n}\n.vs-breadcrumb-trail-head { display: flex; justify-content: space-between; gap: 2em; padding: .55em .7em .45em; font-size: .8em; color: color-mix(in srgb, var(--ink) 46%, transparent); white-space: nowrap; }\n.vs-breadcrumb-trail-pop ul { margin: 0; padding: 0; list-style: none; }\n.vs-breadcrumb-trail-pop li { margin: 0; }\n.vs-breadcrumb-trail-pop a {\n  display: flex; align-items: center; gap: .6em; padding: .5em 1em .5em .7em; border-radius: .4em; white-space: nowrap; text-decoration: none; outline-offset: -2px;\n  color: color-mix(in srgb, var(--ink) 78%, transparent); transition: background-color 120ms cubic-bezier(.2,0,0,1), color 120ms;\n}\n.vs-breadcrumb-trail-pop a::before { content: \"\"; width: .38em; height: .38em; border-radius: 50%; flex: none; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 22%, transparent); }\n.vs-breadcrumb-trail-pop a:hover, .vs-breadcrumb-trail-pop a:focus-visible { color: var(--ink); background: color-mix(in srgb, var(--acc) 13%, transparent); }\n.vs-breadcrumb-trail-pop a.is-here { color: var(--ink); font-weight: 500; }\n.vs-breadcrumb-trail-pop a.is-here::before { background: var(--acc); box-shadow: 0 0 0 .2em color-mix(in srgb, var(--acc) 20%, transparent); }\n@media (forced-colors: active) { .vs-breadcrumb-trail-pop { border: 1px solid CanvasText; } .vs-breadcrumb-trail-line { background: Highlight; } }\n@media (prefers-reduced-motion: reduce) { .vs-breadcrumb-trail *, .vs-breadcrumb-trail *::before { transition-duration: 0s !important; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsBreadcrumbTrail) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const live = new Set(); let raf = 0, last = 0;\n  const tick = now => {\n    const dt = Math.min(.064, (now - last) / 1000 || .016); last = now;\n    for (const t of live) if (!t.update(dt)) live.delete(t);\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = t => { live.add(t); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  /* semi-implicit Euler spring, 120 Hz substeps, optional start delay d (s); e = settle epsilon */\n  const sp = (x, k, c, e = .05) => ({ x, v: 0, g: x, k, c, e, d: 0 });\n  const run = (s, dt) => {\n    if (reduce.matches) { s.x = s.g; s.v = 0; s.d = 0; return false; }\n    if (s.d > 0) { s.d -= dt; return true; }\n    const n = Math.max(1, Math.ceil(dt * 120)), h = dt / n;\n    for (let i = 0; i < n; i++) { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; }\n    if (Math.abs(s.v) < s.e && Math.abs(s.x - s.g) < s.e) { s.x = s.g; s.v = 0; return false; }\n    return true;\n  };\n  const snap = (s, v) => { s.x = s.g = v; s.v = 0; s.d = 0; };\n  const GLYPH = { chevron: '<path d=\"M3.6 1.2 7.4 5 3.6 8.8\"/>', slash: '<path d=\"M6.9.4 3.1 9.6\"/>', dot: '<circle cx=\"5\" cy=\"5\" r=\"5\"/>' };\n  const glyph = t => `<svg viewBox=\"0 0 10 10\" aria-hidden=\"true\">${GLYPH[t] || GLYPH.chevron}</svg>`;\n  const el = (tag, cls, attrs = {}) => { const e = document.createElement(tag); if (cls) e.className = cls; for (const k in attrs) e.setAttribute(k, attrs[k]); return e; };\n  const springs = () => ({ x: sp(0, 320, 30), y: sp(0, 320, 30), o: sp(1, 280, 30, .004), s: sp(1, 360, 28, .002) });\n  let uid = 0;\n\n  function mount(nav) {\n    if (nav.__vsBt) return nav.__vsBt;\n    const ol = nav.querySelector(':scope > ol'); if (!ol) return null;\n    const id = 'vs-breadcrumb-trail-' + (++uid);\n    const crumbs = [...ol.children].filter(n => n.tagName === 'LI').map((li, i, arr) => {\n      li.classList.add('vs-breadcrumb-trail-item');\n      const link = li.querySelector(':scope > a, :scope > span'); link.classList.add('vs-breadcrumb-trail-crumb');\n      const list = li.querySelector(':scope > ul');\n      const c = { li, link, list, sep: null, pop: null, S: springs(), P: sp(0, 560, 38, .003), state: 'in' };\n      if (i > 0) {\n        c.sep = el(list ? 'button' : 'span', 'vs-breadcrumb-trail-sep', { 'aria-hidden': 'true' });\n        if (list) { c.sep.type = 'button'; c.sep.tabIndex = -1; }\n        li.prepend(c.sep);\n      }\n      if (list) {\n        c.pop = el('div', 'vs-breadcrumb-trail-pop', { id: `${id}-${i}` });\n        const head = el('div', 'vs-breadcrumb-trail-head'), a = el('span'), b = el('span');\n        const parent = i > 0 ? arr[i - 1].querySelector(':scope > a, :scope > span').textContent.trim() : '~';\n        a.textContent = parent + '/'; b.textContent = String(list.querySelectorAll('a').length);\n        head.append(a, b); c.pop.append(head, list); li.append(c.pop); c.pop.inert = true;\n        const here = link.textContent.trim();\n        list.querySelectorAll('a').forEach(x => { if (x.textContent.trim() === here) { x.classList.add('is-here'); x.setAttribute('aria-current', 'true'); } });\n        link.setAttribute('aria-expanded', 'false'); link.setAttribute('aria-controls', c.pop.id); link.setAttribute('aria-keyshortcuts', 'ArrowDown');\n      }\n      return c;\n    });\n    const n = crumbs.length;\n    const moreLi = el('li', 'vs-breadcrumb-trail-item vs-breadcrumb-trail-more');\n    const dots = el('button', 'vs-breadcrumb-trail-dots', { type: 'button', 'aria-expanded': 'false' }); dots.innerHTML = '<i></i><i></i><i></i>';\n    moreLi.append(el('span', 'vs-breadcrumb-trail-sep', { 'aria-hidden': 'true' }), dots);\n    crumbs[0].li.after(moreLi);\n    const M = { li: moreLi, S: springs(), state: 'in', more: true };\n    const order = [crumbs[0], M, ...crumbs.slice(1)];\n    const line = el('span', 'vs-breadcrumb-trail-line', { 'aria-hidden': 'true' }); nav.append(line);\n    const Ln = { l: sp(0, 520, 36), r: sp(0, 520, 36), y: sp(0, 420, 34), o: sp(0, 360, 32, .003) };\n    const paintSeps = () => nav.querySelectorAll('.vs-breadcrumb-trail-sep').forEach(s => s.innerHTML = glyph(nav.dataset.sep));\n    paintSeps();\n\n    let expanded = false, effMax = 4, open = null, hot = null, tPop = 0, tPopClose = 0, tCol = 0, tExp = 0;\n    const maxAttr = () => Math.max(2, parseInt(nav.dataset.max, 10) || 4);\n    const hiddenCount = () => Math.max(0, n - Math.min(effMax, n));\n    const plan = () => {\n      const want = new Set(), m = Math.min(effMax, n);\n      if (expanded || n <= m) crumbs.forEach(c => want.add(c));\n      else { want.add(crumbs[0]); want.add(M); for (let i = n - (m - 1); i < n; i++) want.add(crumbs[i]); }\n      return want;\n    };\n    const setDisplay = (e, on) => { e.li.style.display = on ? '' : 'none'; };\n\n    /* interruptible FLIP: record what is on screen, change the flow, spring from the old picture to the new */\n    function apply(animate = true) {\n      const want = plan();\n      const before = new Map();\n      for (const e of order) if (e.state !== 'gone') before.set(e, e.li.getBoundingClientRect());\n      const refOld = before.get(M) && M.state === 'in' ? before.get(M) : null;\n      const leaving = order.filter(e => e.state === 'in' && !want.has(e));\n      const entering = order.filter(e => e.state !== 'in' && want.has(e));\n      for (const e of entering) { e.li.classList.remove('is-out'); e.li.style.left = e.li.style.top = ''; setDisplay(e, true); }\n      for (const e of leaving) e.li.classList.add('is-out');\n      const olR = ol.getBoundingClientRect();\n      for (const e of leaving) {\n        const b = before.get(e);\n        e.li.style.left = (b.left - olR.left) + 'px'; e.li.style.top = (b.top - olR.top) + 'px';\n        snap(e.S.x, 0); snap(e.S.y, 0);\n      }\n      for (const e of order) if (want.has(e)) {\n        const r = e.li.getBoundingClientRect();\n        e.lay = { l: r.left - e.S.x.x, t: r.top - e.S.y.x };\n      }\n      let k = 0;\n      const hiddenNow = entering.filter(e => e.state === 'gone' && !e.more);\n      for (const e of order) {\n        if (!want.has(e)) continue;\n        const b = before.get(e), S = e.S;\n        if (b) { S.x.x = b.left - e.lay.l; S.y.x = b.top - e.lay.t; }\n        else if (refOld && hiddenNow.includes(e)) {             // born from the ellipsis\n          S.x.x = refOld.left - e.lay.l; S.y.x = refOld.top - e.lay.t; S.o.x = 0; S.s.x = .72;\n          S.o.d = S.x.d = S.s.d = k++ * .038;\n        } else { snap(S.x, 0); snap(S.y, 0); S.o.x = 0; S.s.x = .6; }\n        S.x.g = 0; S.y.g = 0; S.o.g = 1; S.s.g = 1; e.state = 'in';\n      }\n      const into = want.has(M) ? M.lay : null;\n      for (const e of leaving) {\n        const b = before.get(e), S = e.S;\n        S.o.g = 0; S.s.g = e.more ? .6 : .8;\n        if (into && !e.more) { S.x.g = (into.l - b.left) * .75; S.y.g = (into.t - b.top) * .75; }\n        e.state = 'out';\n      }\n      dots.setAttribute('aria-expanded', String(!want.has(M)));\n      dots.setAttribute('aria-label', `Show ${hiddenCount()} hidden level${hiddenCount() === 1 ? '' : 's'}`);\n      if (!animate) for (const e of order) { for (const s of Object.values(e.S)) { s.x = s.g; s.v = 0; s.d = 0; } }\n      api.update(0); wake(api);\n    }\n\n    function aimLine(c, snapIt) {\n      if (!c) { Ln.o.g = 0; return; }\n      const li = c.li, a = c.link;\n      const l = ol.offsetLeft + li.offsetLeft + a.offsetLeft + c.S.x.x + a.offsetWidth * .0, r = l + a.offsetWidth;\n      const y = ol.offsetTop + li.offsetTop + a.offsetTop + a.offsetHeight + c.S.y.x;\n      const pad = a.offsetWidth > 40 ? 5 : 2;\n      if (snapIt || Ln.o.x < .06) { snap(Ln.l, l + pad); snap(Ln.r, r - pad); snap(Ln.y, y); }\n      else { const dir = (l + r) - (Ln.l.g + Ln.r.g), [lead, trail] = dir >= 0 ? [Ln.r, Ln.l] : [Ln.l, Ln.r];\n        lead.k = 560; lead.c = 36; trail.k = 250; trail.c = 28; Ln.l.g = l + pad; Ln.r.g = r - pad; Ln.y.g = y; }\n      Ln.o.g = 1;\n    }\n\n    const api = {\n      expand, collapse, refit,\n      update(dt) {\n        let moving = false;\n        for (const e of order) {\n          const S = e.S; for (const k in S) if (run(S[k], dt)) moving = true;\n          if (e.state === 'gone') continue;\n          e.li.style.transform = `translate3d(${S.x.x.toFixed(2)}px,${S.y.x.toFixed(2)}px,0) scale(${S.s.x.toFixed(4)})`;\n          e.li.style.opacity = Math.max(0, Math.min(1, S.o.x)).toFixed(3);\n          if (e.state === 'out' && S.o.x <= .01) {\n            e.state = 'gone'; setDisplay(e, false); e.li.classList.remove('is-out'); e.li.style.left = e.li.style.top = '';\n            snap(S.x, 0); snap(S.y, 0); snap(S.s, 1); snap(S.o, 0);\n          }\n        }\n        for (const c of crumbs) if (c.pop) {\n          if (run(c.P, dt)) moving = true;\n          const p = Math.max(0, Math.min(1, c.P.x));\n          c.pop.style.opacity = p.toFixed(3);\n          c.pop.style.transform = `translate3d(0,${(-7 * (1 - c.P.x)).toFixed(2)}px,0) scale(${(.955 + .045 * c.P.x).toFixed(4)})`;\n          const shown = p > .01 || c === open;\n          c.pop.style.visibility = shown ? 'visible' : 'hidden';\n          if (!shown) c.li.classList.remove('is-popped');\n        }\n        if (hot && moving) aimLine(hot);\n        for (const k in Ln) if (run(Ln[k], dt)) moving = true;\n        line.style.transform = `translate3d(${Ln.l.x.toFixed(2)}px,${Ln.y.x.toFixed(2)}px,0) scaleX(${(Math.max(0, Ln.r.x - Ln.l.x) / 100).toFixed(4)})`;\n        line.style.opacity = Math.max(0, Math.min(1, Ln.o.x)).toFixed(3);\n        return moving;\n      },\n    };\n\n    function expand({ focus } = {}) {\n      clearTimeout(tExp); clearTimeout(tCol);\n      if (expanded || !hiddenCount()) return;\n      const hid = crumbs.filter(c => c.state !== 'in');\n      expanded = true; apply();\n      nav.dispatchEvent(new CustomEvent('trailexpand', { bubbles: true }));\n      if (focus === 'first') hid[0] && hid[0].link.focus({ preventScroll: true });\n      if (focus === 'last') hid[hid.length - 1] && hid[hid.length - 1].link.focus({ preventScroll: true });\n    }\n    function collapse() {\n      clearTimeout(tCol);\n      if (!expanded) return;\n      expanded = false;\n      if (open && !plan().has(open)) closePop();\n      apply();\n      nav.dispatchEvent(new CustomEvent('trailcollapse', { bubbles: true }));\n    }\n    function openPop(c, focus) {\n      clearTimeout(tPop); clearTimeout(tPopClose);\n      if (!c || !c.pop) return;\n      if (open && open !== c) closePop();\n      open = c; c.P.g = 1;\n      c.pop.style.left = c.link.offsetLeft + 'px';\n      c.link.setAttribute('aria-expanded', 'true'); c.sep && c.sep.classList.add('is-open');\n      c.li.classList.add('is-popped'); c.pop.inert = false; c.pop.style.visibility = 'visible';\n      const over = c.pop.getBoundingClientRect().left + c.pop.offsetWidth - (innerWidth - 12);\n      if (over > 0) c.pop.style.left = (c.link.offsetLeft - over) + 'px';\n      wake(api);\n      if (focus) { const f = c.pop.querySelector('a.is-here') || c.pop.querySelector('a'); f && f.focus({ preventScroll: true }); }\n    }\n    function closePop() {\n      clearTimeout(tPop); clearTimeout(tPopClose);\n      if (!open) return;\n      const c = open; open = null; c.P.g = 0;\n      c.link.setAttribute('aria-expanded', 'false'); c.sep && c.sep.classList.remove('is-open'); c.pop.inert = true;\n      wake(api);\n    }\n    function refit() {\n      // measure natural widths in an invisible probe, then keep as many levels as fit (never fewer than 2)\n      const probe = ol.cloneNode(true);\n      probe.querySelectorAll('[id]').forEach(x => x.removeAttribute('id'));\n      probe.style.cssText = 'position:absolute;left:0;top:0;visibility:hidden;pointer-events:none;flex-wrap:nowrap;width:max-content';\n      [...probe.children].forEach(li => { li.removeAttribute('style'); li.classList.remove('is-out'); });\n      nav.append(probe);\n      const W = [...probe.children].map(li => li.getBoundingClientRect().width);\n      probe.remove();\n      const avail = nav.clientWidth, want = maxAttr();\n      const crumbW = W.filter((_, i) => i !== 1), moreW = W[1];\n      const total = crumbW.reduce((a, b) => a + b, 0);\n      let m = Math.min(want, n);\n      if (m >= n && total > avail) m = n - 1;\n      while (m > 2) {\n        let w = crumbW[0] + moreW; for (let i = n - (m - 1); i < n; i++) w += crumbW[i];\n        if (w <= avail) break; m--;\n      }\n      effMax = m >= n && total <= avail ? n : m;\n      apply(); if (hot) aimLine(hot, true);\n    }\n\n    /* pointer */\n    const crumbOf = t => crumbs.find(c => c.li.contains(t) && (c.link === t || c.link.contains(t)));\n    ol.addEventListener('pointerover', e => {\n      if (e.pointerType !== 'mouse') return;\n      const c = crumbOf(e.target);\n      if (!c) return;\n      const cur = c.link.getAttribute('aria-current') === 'page';\n      if (cur && hot) { hot = null; aimLine(null); wake(api); }\n      else if (!cur && c !== hot) { hot = c; aimLine(c); wake(api); }\n      if (c.pop && c !== open) { clearTimeout(tPop); tPop = setTimeout(() => openPop(c), open ? 60 : 260); }\n      if (c === open) clearTimeout(tPopClose);\n    });\n    ol.addEventListener('pointerout', e => {\n      if (e.pointerType !== 'mouse') return;\n      const c = crumbOf(e.target); if (!c || c.li.contains(e.relatedTarget)) return;\n      clearTimeout(tPop);\n      if (c === open) tPopClose = setTimeout(closePop, 220);\n      if (c === hot && !(e.relatedTarget && crumbOf(e.relatedTarget))) { hot = null; aimLine(null); wake(api); }\n    });\n    crumbs.forEach(c => {\n      if (c.pop) {\n        c.pop.addEventListener('pointerenter', () => clearTimeout(tPopClose));\n        c.pop.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') tPopClose = setTimeout(closePop, 220); });\n        c.pop.addEventListener('keydown', e => {\n          const A = [...c.pop.querySelectorAll('a')], i = A.indexOf(document.activeElement);\n          if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); A[Math.max(0, Math.min(A.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))].focus(); }\n          else if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); A[e.key === 'Home' ? 0 : A.length - 1].focus(); }\n          else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePop(); c.link.focus(); }\n          else if (e.key === 'Tab') closePop();\n        });\n      }\n      if (c.sep && c.pop) c.sep.addEventListener('click', () => (open === c ? closePop() : openPop(c)));\n      c.link.addEventListener('keydown', e => { if (e.key === 'ArrowDown' && c.pop) { e.preventDefault(); openPop(c, true); } });\n      c.link.addEventListener('focus', () => { if (c.link.matches(':focus-visible') && c.link.getAttribute('aria-current') !== 'page') { hot = c; aimLine(c); wake(api); } });\n    });\n    dots.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { clearTimeout(tExp); tExp = setTimeout(expand, 80); } });\n    dots.addEventListener('pointerleave', () => clearTimeout(tExp));\n    dots.addEventListener('click', e => expand({ focus: e.detail === 0 ? 'first' : null }));\n    dots.addEventListener('focus', e => {\n      if (!dots.matches(':focus-visible')) return;\n      const from = e.relatedTarget && crumbs.find(c => c.link === e.relatedTarget);\n      expand({ focus: from && crumbs.indexOf(from) > 0 ? 'last' : 'first' });\n    });\n    ol.addEventListener('pointerenter', () => clearTimeout(tCol));\n    ol.addEventListener('pointerleave', e => {\n      if (e.pointerType !== 'mouse') return;\n      if (hot) { hot = null; aimLine(null); wake(api); }\n      if (expanded) { clearTimeout(tCol); tCol = setTimeout(() => { if (!open) collapse(); else tCol = setTimeout(() => { if (!ol.matches(':hover')) collapse(); }, 400); }, 650); }\n    });\n    nav.addEventListener('keydown', e => { if (e.key === 'Escape') { if (open) { const c = open; closePop(); c.link.focus(); } else collapse(); } });\n    nav.addEventListener('focusout', () => setTimeout(() => {\n      if (nav.contains(document.activeElement)) return;\n      closePop(); hot = null; aimLine(null); wake(api);\n      if (!ol.matches(':hover')) collapse();\n    }));\n    document.addEventListener('pointerdown', e => { if (!ol.contains(e.target)) { closePop(); collapse(); } });\n    new MutationObserver(recs => {\n      if (recs.some(r => r.attributeName === 'data-sep')) paintSeps();\n      if (recs.some(r => r.attributeName === 'data-max')) refit();\n    }).observe(nav, { attributes: true, attributeFilter: ['data-sep', 'data-max'] });\n    let lastW = -1, lastF = '';\n    new ResizeObserver(() => {\n      const w = nav.clientWidth, f = getComputedStyle(nav).fontSize;\n      if (w !== lastW || f !== lastF) { lastW = w; lastF = f; refit(); }\n    }).observe(nav);\n    document.fonts && document.fonts.ready.then(() => refit());\n    nav.__vsBt = api;\n    effMax = maxAttr(); apply(false); refit();\n    for (const e of order) for (const s of Object.values(e.S)) { s.x = s.g; s.v = 0; s.d = 0; }\n    api.update(0);\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-breadcrumb-trail').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsBreadcrumbTrail = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Navigation",
    "tags": [
      "breadcrumb",
      "nav",
      "ellipsis",
      "dropdown",
      "spring",
      "flip",
      "keyboard",
      "accessibility",
      "mono"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#52795c",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/breadcrumb-trail/index.html",
    "thumbnail": "https://vividsites.app/api/component/breadcrumb-trail/thumb.jpg",
    "video": "https://vividsites.app/api/component/breadcrumb-trail/thumb.mp4",
    "page": "https://vividsites.app/c/breadcrumb-trail"
  }
}