{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-flap-button",
  "type": "registry:block",
  "title": "Split-Flap Button",
  "description": "A button whose label is a row of departure-board flaps. On hover each cell falls through its drum letter by letter to a second label, with the top leaf dropping, the bottom leaf landing with a bounce and shading on the turn. The same engine drives a whole live board.",
  "docs": "Split-Flap Button was added to public/vividsites/split-flap-button.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/split-flap-button",
  "categories": [
    "ui",
    "buttons-inputs"
  ],
  "files": [
    {
      "path": "components/vividsites/split-flap-button.html",
      "type": "registry:file",
      "target": "public/vividsites/split-flap-button.html",
      "content": "<!--\nvs-split-flap-button: a button whose label is a row of split-flap cells, like a station\n  departures board. On hover each cell falls through its drum, letter by letter, to the\n  alternate label: the top leaf drops under gravity, the bottom leaf lands and bounces,\n  the leaves shade as they turn. Click flips to an optional confirmation.\n\n  Markup:\n    <button class=\"vs-split-flap-button\" data-alt=\"Gate 14 ›\" data-done=\"Boarded ✓\">Check in</button>\n  Attributes (live):\n    data-alt     label shown on hover / keyboard focus          (optional)\n    data-done    label shown after a click                      (optional)\n    data-hold    ms the done label stays                        (default 1800)\n    data-speed   ms per flip                                    (default 70)\n    data-stagger ms between neighbouring cells starting         (default 32)\n    data-align   \"left\" to left-align labels (centred by default)\n  Themes: .vs-split-flap-button--paper (white flaps) · .vs-split-flap-button--signal (amber)\n  States: hover/focus flips to data-alt and lights the LED · press · disabled.\n\n  Display-only boards: any element with [data-split-flap] gets cells too, and\n  window.vsSplitFlap.flip(el, \"NEW TEXT\") drives it from your own code.\n  Characters outside the drum (A–Z 0–9 : . - › ✓ ' /) render instantly.\n  Reduced motion: labels swap without flipping.\n-->\n<link href=\"https://fonts.googleapis.com/css2?family=Oswald:wght@500&display=swap\" rel=\"stylesheet\">\n<button class=\"vs-split-flap-button\" type=\"button\" data-alt=\"Gate 14 ›\" data-done=\"Boarded ✓\">Check in</button>\n\n<style>\n.vs-split-flap-button, [data-split-flap] {\n  --vs-split-flap-font: 'Oswald', 'Arial Narrow', sans-serif;\n  --vs-split-flap-size: 1.35rem;          /* glyph size; cells scale with it */\n  --vs-split-flap-ink: #f3ede1;           /* letters */\n  --vs-split-flap-cell: #1c1f22;          /* flap colour */\n  --vs-split-flap-bezel: #0d0f10;         /* housing */\n  --vs-split-flap-accent: #ffc24a;        /* LED, hover rim and focus */\n  --vs-split-flap-gap: 3px;               /* space between cells */\n  --vs-split-flap-hinge: rgba(0,0,0,.75); /* the split line */\n  --vs-split-flap-pin: #3a3d41;           /* hinge pins */\n}\n.vs-split-flap-button, .vs-split-flap-button *, [data-split-flap] * { box-sizing: border-box; }\n/* themes */\n.vs-split-flap-button--paper {\n  --vs-split-flap-ink: #17171a; --vs-split-flap-cell: #f3f0e8; --vs-split-flap-bezel: #d8d3c8;\n  --vs-split-flap-accent: #ff4f1f; --vs-split-flap-hinge: rgba(0,0,0,.28); --vs-split-flap-pin: #b9b3a7;\n}\n.vs-split-flap-button--signal {\n  --vs-split-flap-ink: #ffb62e; --vs-split-flap-cell: #121314; --vs-split-flap-bezel: #050506;\n  --vs-split-flap-accent: #ffb62e; --vs-split-flap-hinge: rgba(0,0,0,.9); --vs-split-flap-pin: #2a2b2d;\n}\n.vs-split-flap-button {\n  position: relative; display: inline-flex; align-items: center; gap: calc(var(--vs-split-flap-size) * .5); cursor: pointer; user-select: none; -webkit-user-select: none;\n  padding: calc(var(--vs-split-flap-size) * .36) calc(var(--vs-split-flap-size) * .42) calc(var(--vs-split-flap-size) * .36) calc(var(--vs-split-flap-size) * .56); border-radius: calc(var(--vs-split-flap-size) * .5); border: 0; color: var(--vs-split-flap-ink);\n  background: linear-gradient(180deg, color-mix(in srgb, var(--vs-split-flap-bezel), #fff 7%), var(--vs-split-flap-bezel));\n  box-shadow: inset 0 0 0 1px rgba(255,255,255,.07), inset 0 1px 0 rgba(255,255,255,.08), 0 1px 1px rgba(0,0,0,.2), 0 12px 30px -14px rgba(0,0,0,.7), 0 30px 60px -30px rgba(0,0,0,.5);\n  font: inherit; outline: none; -webkit-tap-highlight-color: transparent; touch-action: manipulation;\n  transition: box-shadow .25s cubic-bezier(.2,0,0,1), transform .15s cubic-bezier(.2,0,0,1);\n}\n.vs-split-flap-button:hover, .vs-split-flap-button:focus-visible {\n  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-split-flap-accent) 45%, transparent), inset 0 1px 0 rgba(255,255,255,.06), 0 14px 34px -14px rgba(0,0,0,.85), 0 0 0 4px color-mix(in srgb, var(--vs-split-flap-accent) 0%, transparent);\n}\n.vs-split-flap-button:focus-visible { box-shadow: inset 0 0 0 1px var(--vs-split-flap-accent), 0 0 0 3px color-mix(in srgb, var(--vs-split-flap-accent) 40%, transparent); }\n.vs-split-flap-button:active { transform: translateY(1px) scale(.99); }\n.vs-split-flap-button:disabled, .vs-split-flap-button[aria-disabled=\"true\"] { cursor: not-allowed; opacity: .5; }\n.vs-split-flap-led {\n  width: calc(var(--vs-split-flap-size) * .26); height: calc(var(--vs-split-flap-size) * .26); border-radius: 50%; flex: none;\n  background: color-mix(in srgb, var(--vs-split-flap-accent) 25%, var(--vs-split-flap-bezel)); box-shadow: inset 0 1px 1px rgba(0,0,0,.35);\n  transition: background .2s, box-shadow .2s;\n}\n.vs-split-flap-button:hover .vs-split-flap-led, .vs-split-flap-button:focus-visible .vs-split-flap-led, .vs-split-flap-button.is-done .vs-split-flap-led {\n  background: var(--vs-split-flap-accent); box-shadow: 0 0 8px var(--vs-split-flap-accent), 0 0 2px #fff inset;\n}\n.vs-split-flap-row { display: inline-flex; gap: var(--vs-split-flap-gap); }\n.vs-split-flap-cell {\n  --h: calc(var(--vs-split-flap-size) * 1.42);\n  position: relative; width: calc(var(--vs-split-flap-size) * .78); height: var(--h); flex: none;\n  perspective: calc(var(--vs-split-flap-size) * 9); border-radius: 3px;\n  font: 500 var(--vs-split-flap-size)/var(--h) var(--vs-split-flap-font); text-transform: uppercase; text-align: center;\n  color: var(--vs-split-flap-ink); box-shadow: 0 1px 2px rgba(0,0,0,.45), 0 0 0 .5px rgba(0,0,0,.2);\n}\n.vs-split-flap-half {\n  position: absolute; left: 0; right: 0; height: 50%; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden;\n  background: var(--vs-split-flap-cell);\n}\n.vs-split-flap-half > b { position: absolute; left: 0; right: 0; height: var(--h); font-weight: inherit; }\n.vs-split-flap-t { top: 0; border-radius: 3px 3px 0 0; transform-origin: 50% 100%;\n  background: linear-gradient(180deg, color-mix(in srgb, var(--vs-split-flap-cell), #fff 7%), var(--vs-split-flap-cell)); }\n.vs-split-flap-t > b { top: 0; }\n.vs-split-flap-b { bottom: 0; border-radius: 0 0 3px 3px; transform-origin: 50% 0;\n  background: linear-gradient(180deg, color-mix(in srgb, var(--vs-split-flap-cell), #000 18%), var(--vs-split-flap-cell) 40%); }\n.vs-split-flap-b > b { bottom: 0; }\n.vs-split-flap-leaf { will-change: transform; }\n.vs-split-flap-shade { position: absolute; inset: 0; background: #000; opacity: 0; pointer-events: none; }\n.vs-split-flap-cell::after { /* the hinge gap */\n  content: \"\"; position: absolute; left: 0; right: 0; top: calc(50% - .5px); height: 1px; background: var(--vs-split-flap-hinge); z-index: 3;\n}\n.vs-split-flap-cell::before { /* hinge pins */\n  content: \"\"; position: absolute; left: -1px; right: -1px; top: calc(50% - 1.5px); height: 3px; z-index: 4;\n  background: linear-gradient(90deg, var(--vs-split-flap-pin) 0 2px, transparent 2px calc(100% - 2px), var(--vs-split-flap-pin) calc(100% - 2px));\n}\n.vs-split-flap-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@media (forced-colors: active) { .vs-split-flap-button { border: 1px solid ButtonText; } .vs-split-flap-button:focus-visible { outline: 2px solid Highlight; outline-offset: 3px; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsSplitFlap) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const DRUM = \" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/'›✓\";\n  const MAX = 7;                               // longest run of flips a cell will show\n  const norm = s => [...String(s).toUpperCase()];\n\n  function makeCell() {\n    const el = document.createElement('span'); el.className = 'vs-split-flap-cell';\n    const part = (cls, shade) => {\n      const h = document.createElement('span'); h.className = 'vs-split-flap-half ' + cls;\n      const b = document.createElement('b'); h.appendChild(b);\n      const i = shade ? document.createElement('i') : null; if (i) { i.className = 'vs-split-flap-shade'; h.appendChild(i); }\n      el.appendChild(h); return { h, b, i };\n    };\n    const T = part('vs-split-flap-t'), B = part('vs-split-flap-b');\n    const LT = part('vs-split-flap-t vs-split-flap-leaf', 1), LB = part('vs-split-flap-b vs-split-flap-leaf', 1);\n    LT.h.style.transform = 'rotateX(-90deg)';\n    const c = { el, cur: ' ', target: ' ', busy: false, anims: [] };\n    const show = ch => { T.b.textContent = B.b.textContent = LT.b.textContent = LB.b.textContent = ch; LT.h.style.transform = 'rotateX(-90deg)'; LB.h.style.transform = 'none'; };\n    c.set = ch => { c.cur = c.target = ch; show(ch); };\n    c.flip = (next, d) => new Promise(done => {\n      T.b.textContent = next; B.b.textContent = c.cur; LT.b.textContent = c.cur; LB.b.textContent = next;\n      const e1 = { duration: d * .5, easing: 'cubic-bezier(.55,0,1,.45)', fill: 'forwards' };\n      LT.h.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-90deg)' }], e1);\n      LT.i.animate([{ opacity: 0 }, { opacity: .6 }], e1);\n      LB.h.animate([\n        { transform: 'rotateX(90deg)', offset: 0 }, { transform: 'rotateX(0deg)', offset: .62 },\n        { transform: 'rotateX(14deg)', offset: .8 }, { transform: 'rotateX(0deg)', offset: 1 }],\n        { duration: d * .95, delay: d * .5, easing: 'cubic-bezier(.3,0,.6,1)', fill: 'backwards' });\n      LB.i.animate([{ opacity: .5 }, { opacity: 0 }], { duration: d * .6, delay: d * .5, fill: 'both' });\n      // the next flip may start as soon as this leaf has landed; the bounce overlaps it\n      setTimeout(() => { B.b.textContent = next; c.cur = next; done(); }, d * 1.12);\n    });\n    c.run = async (d) => {\n      if (c.busy) return; c.busy = true;\n      while (c.cur !== c.target) {\n        let i = DRUM.indexOf(c.cur), j = DRUM.indexOf(c.target);\n        if (i < 0 || j < 0 || reduce.matches) { c.set(c.target); break; }\n        let dist = (j - i + DRUM.length) % DRUM.length;\n        if (dist > MAX) { i = (j - MAX + DRUM.length) % DRUM.length; c.cur = DRUM[i]; B.b.textContent = c.cur; dist = MAX; }\n        await c.flip(DRUM[(i + 1) % DRUM.length], d);\n      }\n      c.busy = false;\n    };\n    return c;\n  }\n\n  function flip(el, text) {\n    const st = el.__vsFlap; if (!st) return;\n    const chars = norm(text);\n    const n = st.cells.length, pad = Math.max(0, Math.floor((n - chars.length) / 2) * (st.center ? 1 : 0));\n    st.cells.forEach((c, i) => {\n      const ch = chars[i - pad] || ' ';\n      c.target = ch;\n      if (reduce.matches) { c.set(ch); return; }\n      clearTimeout(c.t); c.t = setTimeout(() => c.run(+(el.dataset.speed || st.speed)), i * +(el.dataset.stagger || st.stagger));\n    });\n    if (st.sr) st.sr.textContent = text;\n  }\n\n  function mount(el) {\n    if (el.__vsFlap) return;\n    const isBtn = el.classList.contains('vs-split-flap-button');\n    const text = (el.dataset.splitFlap || el.textContent).trim().replace(/\\s+/g, ' ');\n    const labels = [text, el.dataset.alt, el.dataset.done].filter(Boolean);\n    const len = +(el.dataset.cells || Math.max(...labels.map(l => norm(l).length)));\n    el.textContent = '';\n    const row = document.createElement('span'); row.className = 'vs-split-flap-row'; row.setAttribute('aria-hidden', 'true');\n    const cells = Array.from({ length: len }, makeCell); cells.forEach(c => row.appendChild(c.el));\n    const st = el.__vsFlap = { cells, speed: +(el.dataset.speed || 70), stagger: +(el.dataset.stagger || 32), center: isBtn && el.dataset.align !== 'left', sr: null };\n    if (isBtn) {\n      const led = document.createElement('span'); led.className = 'vs-split-flap-led'; led.setAttribute('aria-hidden', 'true');\n      var live = document.createElement('span'); live.className = 'vs-split-flap-sr'; live.setAttribute('aria-live', 'polite');\n      el.append(led, row, live);\n      el.setAttribute('aria-label', text);\n    } else {\n      const sr = document.createElement('span'); sr.className = 'vs-split-flap-sr'; sr.textContent = text; st.sr = sr;\n      el.append(row, sr);\n    }\n    const pad0 = st.center ? Math.max(0, Math.floor((len - norm(text).length) / 2)) : 0;\n    norm(text).forEach((ch, i) => cells[i + pad0] && cells[i + pad0].set(ch));\n    if (!isBtn) return;\n    let done = 0, hovering = false;\n    const rest = () => flip(el, hovering && el.dataset.alt ? el.dataset.alt : text);\n    el.addEventListener('pointerenter', e => { if (e.pointerType !== 'mouse') return; hovering = true; if (!done) rest(); });\n    el.addEventListener('pointerleave', () => { hovering = false; if (!done) rest(); });\n    el.addEventListener('focus', () => { if (el.matches(':focus-visible')) { hovering = true; if (!done) rest(); } });\n    el.addEventListener('blur', () => { hovering = false; if (!done) rest(); });\n    el.addEventListener('click', () => {\n      if (!el.dataset.done) return;\n      clearTimeout(done); el.classList.add('is-done'); flip(el, el.dataset.done); live.textContent = el.dataset.done;\n      done = setTimeout(() => { done = 0; el.classList.remove('is-done'); live.textContent = ''; rest(); }, +(el.dataset.hold || 1800));\n    });\n  }\n  const init = () => document.querySelectorAll('.vs-split-flap-button, [data-split-flap]').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsSplitFlap = { mount, flip };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Buttons & inputs",
    "tags": [
      "button",
      "split-flap",
      "solari",
      "departures",
      "flip",
      "hover",
      "board",
      "typography"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Oswald:wght@500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#ffc24a",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/split-flap-button/index.html",
    "thumbnail": "https://vividsites.app/api/component/split-flap-button/thumb.jpg",
    "video": "https://vividsites.app/api/component/split-flap-button/thumb.mp4",
    "page": "https://vividsites.app/c/split-flap-button"
  }
}