{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "compare-slider",
  "type": "registry:block",
  "title": "Compare Slider",
  "description": "A before/after image comparison whose divider follows the pointer on a spring, glows with its speed and carries a flick a little past your finger. Transform-only reveal, a full ARIA slider, horizontal or vertical, and three handle styles.",
  "docs": "Compare Slider was added to public/vividsites/compare-slider.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/compare-slider",
  "categories": [
    "ui",
    "cards-layout"
  ],
  "files": [
    {
      "path": "components/vividsites/compare-slider.html",
      "type": "registry:file",
      "target": "public/vividsites/compare-slider.html",
      "content": "<!--\n  vs-compare-slider: before/after image comparison with a spring-driven handle, keyboard control,\n  a vertical mode, three handle styles and an optional scene switcher.\n\n  - Drag anywhere on the image, or click to glide the divider there. The divider follows on a\n    spring, glows with its speed, and the knob leans into its travel; let go mid-flick and it\n    carries a little past your finger. Touch only claims swipes along the slider's axis, so the\n    page still scrolls. Double-click the handle to return to data-start.\n  - The handle is a real ARIA slider (aria-orientation follows the axis): arrows move 2%,\n    Shift+arrows 10%, PageUp/PageDown 10%, Home/End jump to the ends.\n  - The reveal is transform-only: a clip layer and its image counter-translate, so nothing\n    repaints; tag geometry is measured on resize, never in the frame loop.\n  - The first time it scrolls into view the divider sweeps once to show it can move\n    (skip it with data-hint=\"off\").\n  - Each .vs-compare-slider-scene is one before/after pair (the image inside the clip is \"before\").\n    The tablist is optional: delete it for a single pair, or drive it from script.\n  - Options (attributes on .vs-compare-slider, live: change them any time):\n      data-orientation=\"horizontal\" | \"vertical\"\n      data-handle=\"knob\" | \"grip\" | \"line\"\n      data-scene=\"0\"            which pair is shown (el.vsCompare.show(i) does the same)\n      data-start=\"50\"           start position, percent of \"before\" shown\n  - prefers-reduced-motion: no sweep, and the divider snaps instead of gliding.\n\n  CSS variables (on .vs-compare-slider):\n    --vs-compare-slider-ratio    frame aspect ratio\n    --vs-compare-slider-radius   frame corner radius\n    --vs-compare-slider-line     divider + knob colour\n    --vs-compare-slider-accent   focus ring, \"before\" tag dot\n    --vs-compare-slider-ink      tag text colour\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=DM+Mono&display=swap\" rel=\"stylesheet\">\n<style>\n.vs-compare-slider{\n  --vs-compare-slider-ratio: 16 / 9;\n  --vs-compare-slider-radius: 16px;\n  --vs-compare-slider-line: #ffffff;\n  --vs-compare-slider-accent: #ff8a4c;\n  --vs-compare-slider-ink: #f3f3f1;\n  position: relative; width: 100%; color: var(--vs-compare-slider-ink);\n  font-family: \"DM Mono\", ui-monospace, Menlo, monospace;\n  -webkit-font-smoothing: antialiased;\n}\n.vs-compare-slider-frame{\n  position: relative; width: 100%; aspect-ratio: var(--vs-compare-slider-ratio);\n  border-radius: var(--vs-compare-slider-radius); overflow: hidden; isolation: isolate;\n  background: #0b0c0d; cursor: ew-resize; touch-action: pan-y; user-select: none; -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  box-shadow: 0 1px 1px rgba(0,0,0,.4), 0 12px 24px -12px rgba(0,0,0,.6), 0 50px 100px -40px rgba(0,0,0,.85);\n}\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-frame{ cursor: ns-resize; touch-action: pan-x; }\n.vs-compare-slider-frame::after{ /* hairline edge + inner top highlight over the images */\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 8;\n  box-shadow: inset 0 0 0 1px rgba(255,255,255,.09), inset 0 1px 0 rgba(255,255,255,.08);\n}\n.vs-compare-slider-scene{ position: absolute; inset: 0; margin: 0; opacity: 0; visibility: hidden;\n  transition: opacity .8s cubic-bezier(.16,1,.3,1), visibility 0s linear .8s; }\n.vs-compare-slider-scene.is-active{ opacity: 1; visibility: visible; transition: opacity .8s cubic-bezier(.16,1,.3,1), visibility 0s; }\n.vs-compare-slider-img{ position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block;\n  pointer-events: none; -webkit-user-drag: none; }\n/* the reveal is transform-only: the clip slides one way, its image slides back the same amount */\n.vs-compare-slider-clip{ position: absolute; inset: 0; overflow: hidden; will-change: transform; z-index: 1; }\n.vs-compare-slider-clip > div{ position: absolute; inset: 0; will-change: transform; }\n\n/* corner tags */\n.vs-compare-slider-tag{\n  position: absolute; z-index: 3; margin: 0; display: inline-flex; align-items: center; gap: 9px; pointer-events: none;\n  padding: 7px 11px 7px 9px; border-radius: 999px; white-space: nowrap;\n  font-size: 10.5px; line-height: 1; letter-spacing: .08em; text-transform: uppercase;\n  background: rgba(10,11,12,.4); -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4);\n  box-shadow: inset 0 0 0 1px rgba(255,255,255,.12), 0 6px 16px -8px rgba(0,0,0,.6);\n  will-change: opacity, transform;\n}\n.vs-compare-slider-tag b{ font-weight: 400; display: inline-flex; align-items: center; gap: 6px; opacity: .6; }\n.vs-compare-slider-tag b::before{ content: \"\"; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }\n.vs-compare-slider-tag--before b::before{ background: var(--vs-compare-slider-accent); box-shadow: 0 0 8px var(--vs-compare-slider-accent); }\n.vs-compare-slider-tag--before{ left: 16px; bottom: 16px; }\n.vs-compare-slider-tag--after{ right: 16px; bottom: 16px; }\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-tag--before{ bottom: auto; top: 16px; }\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-tag--after{ right: auto; left: 16px; }\n\n/* the handle: a full-size track translated like the clip, the handle pinned to its far edge */\n.vs-compare-slider-track{ position: absolute; inset: 0; z-index: 4; pointer-events: none; will-change: transform; }\n.vs-compare-slider-handle{\n  position: absolute; top: 0; bottom: 0; right: 0; width: 0; pointer-events: auto; outline: none; cursor: ew-resize;\n}\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-handle{ top: auto; left: 0; width: auto; height: 0; cursor: ns-resize; }\n.vs-compare-slider-handle::before{ /* the divider */\n  content: \"\"; position: absolute; top: 0; bottom: 0; left: -1px; width: 2px;\n  background: var(--vs-compare-slider-line); box-shadow: 0 0 0 .5px rgba(0,0,0,.25), 0 0 14px rgba(0,0,0,.35);\n}\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-handle::before{ top: -1px; bottom: auto; left: 0; right: 0; width: auto; height: 2px; }\n.vs-compare-slider-glow{ /* brightens with the divider's speed (JS sets opacity) */\n  position: absolute; top: 0; bottom: 0; left: -14px; width: 28px; opacity: 0; pointer-events: none;\n  background: radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, var(--vs-compare-slider-line) 55%, transparent), transparent);\n  filter: blur(6px); will-change: opacity;\n}\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-glow{ top: -14px; bottom: auto; left: 0; right: 0; width: auto; height: 28px; }\n\n.vs-compare-slider-knob{\n  position: absolute; top: 50%; left: 0; width: 50px; height: 50px; margin: -25px 0 0 -25px; border-radius: 50%;\n  display: grid; place-items: center; color: var(--vs-compare-slider-line);\n  background: rgba(12,13,14,.3); box-shadow: inset 0 0 0 1.5px var(--vs-compare-slider-line), inset 0 1px 0 rgba(255,255,255,.3), 0 10px 28px -8px rgba(0,0,0,.7);\n  -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4);\n  transition: transform .5s cubic-bezier(.34,1.56,.64,1), background-color .25s cubic-bezier(.2,0,0,1), width .4s cubic-bezier(.16,1,.3,1), height .4s cubic-bezier(.16,1,.3,1), margin .4s cubic-bezier(.16,1,.3,1), border-radius .4s;\n}\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-knob{ top: 0; left: 50%; }\n.vs-compare-slider-lean{ display: grid; place-items: center; will-change: transform; }\n.vs-compare-slider-knob svg{ width: 22px; height: 22px; display: block; transition: transform .45s cubic-bezier(.16,1,.3,1); }\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-knob svg{ transform: rotate(90deg); }\n.vs-compare-slider-frame:hover .vs-compare-slider-knob{ transform: scale(1.08); }\n.vs-compare-slider.is-dragging .vs-compare-slider-knob{ transform: scale(.92); background: rgba(12,13,14,.5); }\n.vs-compare-slider.is-dragging .vs-compare-slider-knob svg{ transform: scaleX(1.2); }\n.vs-compare-slider[data-orientation=\"vertical\"].is-dragging .vs-compare-slider-knob svg{ transform: rotate(90deg) scaleX(1.2); }\n.vs-compare-slider-handle:focus-visible .vs-compare-slider-knob{\n  box-shadow: inset 0 0 0 1.5px var(--vs-compare-slider-line), 0 0 0 3px rgba(12,13,14,.7), 0 0 0 5px var(--vs-compare-slider-accent);\n}\n\n/* data-handle=\"grip\": a solid pill on the line */\n.vs-compare-slider[data-handle=\"grip\"] .vs-compare-slider-knob{\n  width: 14px; height: 64px; margin: -32px 0 0 -7px; border-radius: 999px; background: var(--vs-compare-slider-line); color: #111214;\n  box-shadow: inset 0 -1px 0 rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.4), 0 10px 26px -6px rgba(0,0,0,.7); -webkit-backdrop-filter: none; backdrop-filter: none;\n}\n.vs-compare-slider[data-orientation=\"vertical\"][data-handle=\"grip\"] .vs-compare-slider-knob{ width: 64px; height: 14px; margin: -7px 0 0 -32px; }\n.vs-compare-slider[data-handle=\"grip\"] .vs-compare-slider-knob svg{ display: none; }\n.vs-compare-slider[data-handle=\"grip\"] .vs-compare-slider-lean{ width: 2px; height: 20px; border-radius: 2px; background: currentColor; opacity: .35;\n  box-shadow: -3.5px 0 0 currentColor, 3.5px 0 0 currentColor; transform-origin: 50% 50%; }\n.vs-compare-slider[data-orientation=\"vertical\"][data-handle=\"grip\"] .vs-compare-slider-lean{ width: 20px; height: 2px; box-shadow: 0 -3.5px 0 currentColor, 0 3.5px 0 currentColor; }\n.vs-compare-slider[data-handle=\"grip\"].is-dragging .vs-compare-slider-knob{ background: var(--vs-compare-slider-line); transform: scale(.94, 1.06); }\n.vs-compare-slider[data-handle=\"grip\"] .vs-compare-slider-handle:focus-visible .vs-compare-slider-knob{ box-shadow: 0 0 0 3px rgba(12,13,14,.7), 0 0 0 5px var(--vs-compare-slider-accent); }\n/* data-handle=\"line\": no knob, just the divider with two arrowheads riding it */\n.vs-compare-slider[data-handle=\"line\"] .vs-compare-slider-knob{ background: none; box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; width: 60px; margin-left: -30px; }\n.vs-compare-slider[data-orientation=\"vertical\"][data-handle=\"line\"] .vs-compare-slider-knob{ width: 50px; height: 60px; margin: -30px 0 0 -25px; }\n.vs-compare-slider[data-handle=\"line\"] .vs-compare-slider-knob svg{ width: 44px; height: 44px; filter: drop-shadow(0 1px 3px rgba(0,0,0,.6)); }\n.vs-compare-slider[data-handle=\"line\"] .vs-compare-slider-knob svg path{ stroke-width: 1.3; }\n.vs-compare-slider[data-handle=\"line\"].is-dragging .vs-compare-slider-knob{ background: none; }\n.vs-compare-slider[data-handle=\"line\"] .vs-compare-slider-handle:focus-visible .vs-compare-slider-knob{ box-shadow: 0 0 0 2px var(--vs-compare-slider-accent); border-radius: 999px; }\n\n.vs-compare-slider-readout{\n  position: absolute; top: 16px; left: 0; transform: translate(-50%, -6px); opacity: 0; white-space: nowrap;\n  padding: 6px 9px; border-radius: 999px; font-size: 10.5px; letter-spacing: .1em; font-variant-numeric: tabular-nums;\n  background: rgba(12,13,14,.55); box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);\n  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);\n  transition: opacity .25s cubic-bezier(.2,0,0,1), transform .45s cubic-bezier(.16,1,.3,1);\n}\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-readout{ top: 0; left: auto; right: 16px; transform: translate(6px, -50%); }\n.vs-compare-slider.is-dragging .vs-compare-slider-readout,\n.vs-compare-slider-handle:focus-visible .vs-compare-slider-readout{ opacity: 1; transform: translate(-50%, 0); }\n.vs-compare-slider[data-orientation=\"vertical\"].is-dragging .vs-compare-slider-readout,\n.vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-handle:focus-visible .vs-compare-slider-readout{ transform: translate(0, -50%); }\n\n/* optional scene switcher (delete the tablist for a single pair) */\n.vs-compare-slider-tabs{ display: flex; gap: 2px; padding: 3px; margin: 0 0 12px; width: max-content; max-width: 100%;\n  border-radius: 999px; background: #17181b; box-shadow: inset 0 0 0 1px rgba(255,255,255,.09), 0 6px 16px -10px rgba(0,0,0,.5); overflow-x: auto; scrollbar-width: none; }  /* solid, so it reads on light pages too */\n.vs-compare-slider-tabs::-webkit-scrollbar{ display: none; }\n.vs-compare-slider-tab{\n  appearance: none; border: 0; margin: 0; cursor: pointer; white-space: nowrap; border-radius: 999px;\n  padding: 8px 13px; font: 400 10.5px/1 \"DM Mono\", ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase;\n  color: inherit; background: transparent; opacity: .55;\n  transition: opacity .15s cubic-bezier(.2,0,0,1), background-color .3s cubic-bezier(.2,0,0,1), transform .15s cubic-bezier(.2,0,0,1);\n}\n.vs-compare-slider-tab:hover{ opacity: 1; }\n.vs-compare-slider-tab:active{ transform: scale(.96); }\n.vs-compare-slider-tab[aria-selected=\"true\"]{ opacity: 1; background: rgba(255,255,255,.1); }\n.vs-compare-slider-tab:focus-visible{ outline: 2px solid var(--vs-compare-slider-accent); outline-offset: 2px; }\n\n@media (max-width: 640px){\n  .vs-compare-slider-tag{ font-size: 9.5px; padding: 6px 9px 6px 8px; }\n  .vs-compare-slider-tag--before{ left: 12px; bottom: 12px; } .vs-compare-slider-tag--after{ right: 12px; bottom: 12px; }\n  .vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-tag--before{ top: 12px; }\n  .vs-compare-slider[data-orientation=\"vertical\"] .vs-compare-slider-tag--after{ left: 12px; }\n  .vs-compare-slider-knob{ width: 44px; height: 44px; margin: -22px 0 0 -22px; }\n}\n@media (prefers-reduced-motion: reduce){\n  .vs-compare-slider *{ transition: none !important; }\n}\n</style>\n\n<div class=\"vs-compare-slider\" data-start=\"50\" data-orientation=\"horizontal\" data-handle=\"knob\">\n  <div class=\"vs-compare-slider-tabs\" role=\"tablist\" aria-label=\"Choose a comparison\">\n    <button class=\"vs-compare-slider-tab\" type=\"button\" role=\"tab\" aria-selected=\"true\" tabindex=\"0\">Lava field</button>\n    <button class=\"vs-compare-slider-tab\" type=\"button\" role=\"tab\" aria-selected=\"false\" tabindex=\"-1\">Lamp street</button>\n    <button class=\"vs-compare-slider-tab\" type=\"button\" role=\"tab\" aria-selected=\"false\" tabindex=\"-1\">Red mountain</button>\n  </div>\n  <div class=\"vs-compare-slider-frame\">\n    <figure class=\"vs-compare-slider-scene is-active\" data-label=\"Lava field: day 0 versus day 2\">\n      <img class=\"vs-compare-slider-img\" src=\"assets/lava-cooled.webp\" alt=\"The same lava field two days later, crusted grey\" draggable=\"false\">\n      <div class=\"vs-compare-slider-clip\"><div><img class=\"vs-compare-slider-img\" src=\"assets/lava-live.webp\" alt=\"A live lava field, orange seams glowing through the crust\" draggable=\"false\"></div></div>\n      <p class=\"vs-compare-slider-tag vs-compare-slider-tag--before\"><b>Day 0</b>1,140&thinsp;&deg;C</p>\n      <p class=\"vs-compare-slider-tag vs-compare-slider-tag--after\"><b>Day 2</b>80&thinsp;&deg;C, crusted</p>\n    </figure>\n    <figure class=\"vs-compare-slider-scene\" data-label=\"Lamp street: 19:05 versus 22:40\">\n      <img class=\"vs-compare-slider-img\" src=\"assets/street-lit.webp\" alt=\"A rain-soaked alley lit by a red neon sign\" draggable=\"false\">\n      <div class=\"vs-compare-slider-clip\"><div><img class=\"vs-compare-slider-img\" src=\"assets/street-dark.webp\" alt=\"The same alley before the sign is switched on\" draggable=\"false\"></div></div>\n      <p class=\"vs-compare-slider-tag vs-compare-slider-tag--before\"><b>19:05</b>Signs off</p>\n      <p class=\"vs-compare-slider-tag vs-compare-slider-tag--after\"><b>22:40</b>Signs lit</p>\n    </figure>\n    <figure class=\"vs-compare-slider-scene\" data-label=\"Red mountain: dawn versus night\">\n      <img class=\"vs-compare-slider-img\" src=\"assets/fuji-night.webp\" alt=\"The same mountain in blue night light\" draggable=\"false\">\n      <div class=\"vs-compare-slider-clip\"><div><img class=\"vs-compare-slider-img\" src=\"assets/fuji-dawn.webp\" alt=\"A woodblock-style red mountain at dawn over a bay\" draggable=\"false\"></div></div>\n      <p class=\"vs-compare-slider-tag vs-compare-slider-tag--before\"><b>05:20</b>First light</p>\n      <p class=\"vs-compare-slider-tag vs-compare-slider-tag--after\"><b>22:00</b>Moonrise</p>\n    </figure>\n    <div class=\"vs-compare-slider-track\">\n      <div class=\"vs-compare-slider-handle\" role=\"slider\" tabindex=\"0\" aria-label=\"Lava field: day 0 versus day 2\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"50\" aria-valuetext=\"50% before\">\n        <span class=\"vs-compare-slider-glow\" aria-hidden=\"true\"></span>\n        <span class=\"vs-compare-slider-readout\" aria-hidden=\"true\">50 / 50</span>\n        <span class=\"vs-compare-slider-knob\"><span class=\"vs-compare-slider-lean\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M8.5 7.5 4 12l4.5 4.5\"/><path d=\"M15.5 7.5 20 12l-4.5 4.5\"/></svg></span></span>\n      </div>\n    </div>\n  </div>\n</div>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const clamp = (v, a = 0, b = 100) => Math.max(a, Math.min(b, v));\n  function init(root) {\n    if (root.dataset.vsReady) return; root.dataset.vsReady = '1';\n    const frame = root.querySelector('.vs-compare-slider-frame');\n    const track = root.querySelector('.vs-compare-slider-track');\n    const handle = root.querySelector('.vs-compare-slider-handle');\n    const readout = root.querySelector('.vs-compare-slider-readout');\n    const glow = root.querySelector('.vs-compare-slider-glow');\n    const lean = root.querySelector('.vs-compare-slider-lean');\n    const scenes = [...root.querySelectorAll('.vs-compare-slider-scene')].map(el => ({\n      el, clip: el.querySelector('.vs-compare-slider-clip'), inner: el.querySelector('.vs-compare-slider-clip > div'),\n      lb: el.querySelector('.vs-compare-slider-tag--before'), la: el.querySelector('.vs-compare-slider-tag--after'), lbEnd: 0, laStart: 0 }));\n    const tabs = [...root.querySelectorAll('.vs-compare-slider-tab')];\n    let active = Math.max(0, scenes.findIndex(s => s.el.classList.contains('is-active')));\n    scenes.forEach((s, i) => s.el.classList.toggle('is-active', i === active));\n    const start = clamp(parseFloat(root.dataset.start) || 50);\n    // spring on the divider position (percent). Stiffness changes with the gesture: a drag follows tightly, a click glides.\n    const sp = { x: start, v: 0, t: start, k: 200, c: 26 };\n    let raf = 0, last = 0, dragging = false, touched = false, vert = false, L = 1;\n\n    // geometry is read here (resize, orientation, scene change) and never inside the frame loop\n    const measure = () => {\n      vert = root.dataset.orientation === 'vertical';\n      L = (vert ? frame.clientHeight : frame.clientWidth) || 1;\n      handle.setAttribute('aria-orientation', vert ? 'vertical' : 'horizontal');\n      for (const s of scenes) {\n        if (s.lb) s.lbEnd = vert ? s.lb.offsetTop + s.lb.offsetHeight : s.lb.offsetLeft + s.lb.offsetWidth;\n        if (s.la) s.laStart = vert ? s.la.offsetTop : s.la.offsetLeft;\n      }\n      render();\n    };\n    const T = v => vert ? `translate3d(0,${v.toFixed(3)}%,0)` : `translate3d(${v.toFixed(3)}%,0,0)`;\n    const render = () => {\n      const p = clamp(sp.x), off = 100 - p, pos = p / 100 * L;\n      for (const s of scenes) {\n        s.clip.style.transform = T(-off); s.inner.style.transform = T(off);\n        // each tag fades and slides aside as the divider reaches it\n        if (s.lb) { const o = clamp((pos - s.lbEnd - 14) / 44, 0, 1); s.lb.style.opacity = o; s.lb.style.transform = vert ? `translate3d(0,${(o - 1) * 10}px,0)` : `translate3d(${(o - 1) * 10}px,0,0)`; }\n        if (s.la) { const o = clamp((s.laStart - 14 - pos) / 44, 0, 1); s.la.style.opacity = o; s.la.style.transform = vert ? `translate3d(0,${(1 - o) * 10}px,0)` : `translate3d(${(1 - o) * 10}px,0,0)`; }\n      }\n      track.style.transform = T(-off);\n      // velocity: the divider glows and the knob leans into its travel, then both settle\n      const speed = Math.abs(sp.v);\n      if (glow) glow.style.opacity = Math.min(1, speed / 160).toFixed(3);\n      if (lean) {\n        const rot = clamp(sp.v * 0.06, -16, 16), st = Math.min(0.22, speed / 1000);\n        lean.style.transform = `rotate(${(vert ? -rot : rot).toFixed(2)}deg) scale(${(1 + st).toFixed(3)},${(1 - st * 0.4).toFixed(3)})`;\n      }\n      const r = Math.round(p);\n      if (readout) readout.textContent = `${String(r).padStart(2, '0')} / ${String(100 - r).padStart(2, '0')}`;\n      handle.setAttribute('aria-valuenow', r); handle.setAttribute('aria-valuetext', `${r}% before`);\n    };\n    const tick = now => {\n      const dt = Math.min(0.05, (now - last) / 1000); last = now;\n      const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;\n      for (let i = 0; i < n; i++) { sp.v += (-sp.k * (sp.x - sp.t) - sp.c * sp.v) * h; sp.x += sp.v * h; }\n      if (Math.abs(sp.x - sp.t) < 0.01 && Math.abs(sp.v) < 0.05) { sp.x = sp.t; sp.v = 0; render(); raf = 0; }\n      else { render(); raf = requestAnimationFrame(tick); }\n    };\n    const go = (t, k = 200, c = 26) => {\n      sp.t = clamp(t); sp.k = k; sp.c = c;\n      if (reduce.matches) { sp.x = sp.t; sp.v = 0; render(); return; }\n      if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); }\n    };\n    const pct = e => { const r = frame.getBoundingClientRect(); return vert ? (e.clientY - r.top) / r.height * 100 : (e.clientX - r.left) / r.width * 100; };\n\n    // pointer: mouse/pen drag at once; touch waits to see whether the swipe runs along the slider's axis\n    let down = null;\n    const begin = () => { dragging = true; root.classList.add('is-dragging'); frame.setPointerCapture(down.id); };\n    frame.addEventListener('pointerdown', e => {\n      if (e.button !== 0) return;\n      touched = true;\n      down = { id: e.pointerId, x: e.clientX, y: e.clientY, decided: e.pointerType !== 'touch', p: pct(e), t: performance.now(), pv: 0 };\n      if (down.decided) { begin(); go(down.p, 170, 22); }\n    });\n    frame.addEventListener('pointermove', e => {\n      if (!down || e.pointerId !== down.id) return;\n      if (!down.decided) {\n        const dx = Math.abs(e.clientX - down.x), dy = Math.abs(e.clientY - down.y);\n        if (dx < 6 && dy < 6) return;\n        down.decided = true;\n        if (vert ? dy <= dx : dx <= dy) { down = null; return; }   // the page wants this gesture\n        begin();\n      }\n      if (!dragging) return;\n      const now = performance.now(), p = pct(e), dt = Math.max(1, now - down.t);\n      down.pv = down.pv * 0.6 + ((p - down.p) / dt * 1000) * 0.4; down.p = p; down.t = now;\n      go(p, 520, 44);\n    });\n    const end = e => {\n      if (!down || e.pointerId !== down.id) return;\n      if (!down.decided && e.type === 'pointerup') go(pct(e), 150, 22);          // a tap glides there\n      else if (dragging) {                                                       // a flick carries a little past the finger\n        const carry = performance.now() - down.t < 80 ? clamp(down.pv * 0.07, -10, 10) : 0;\n        if (carry) go(sp.t + carry, 160, 21);\n      }\n      down = null; dragging = false; root.classList.remove('is-dragging');\n    };\n    frame.addEventListener('pointerup', end);\n    frame.addEventListener('pointercancel', end);\n    handle.addEventListener('dblclick', () => go(start, 150, 22));\n\n    handle.addEventListener('keydown', e => {\n      const s = e.shiftKey ? 10 : 2;\n      const map = vert ? { ArrowUp: -s, ArrowLeft: -s, ArrowDown: s, ArrowRight: s, PageUp: -10, PageDown: 10 }\n                       : { ArrowLeft: -s, ArrowDown: -s, ArrowRight: s, ArrowUp: s, PageDown: -10, PageUp: 10 };\n      if (e.key in map) { e.preventDefault(); touched = true; go(Math.round(sp.t + map[e.key]), 260, 30); }\n      else if (e.key === 'Home') { e.preventDefault(); touched = true; go(0, 150, 22); }\n      else if (e.key === 'End') { e.preventDefault(); touched = true; go(100, 150, 22); }\n    });\n\n    const show = i => {\n      i = +i;\n      if (i === active || !scenes[i]) return;\n      scenes[active].el.classList.remove('is-active'); active = i; scenes[active].el.classList.add('is-active');\n      tabs.forEach((t, j) => { t.setAttribute('aria-selected', String(j === i)); t.tabIndex = j === i ? 0 : -1; });\n      const lbl = scenes[i].el.dataset.label; if (lbl) handle.setAttribute('aria-label', lbl);\n      if (root.dataset.scene !== String(i)) root.dataset.scene = i;\n      render();\n    };\n    tabs.forEach((t, i) => {\n      t.addEventListener('click', () => show(i));\n      t.addEventListener('keydown', e => {\n        const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;\n        if (d) { e.preventDefault(); const n = (i + d + tabs.length) % tabs.length; tabs[n].focus(); show(n); }\n      });\n    });\n    // attributes are the API: data-orientation, data-handle (CSS only) and data-scene can change at any time\n    new MutationObserver(() => { if (root.dataset.scene != null) show(root.dataset.scene); measure(); })\n      .observe(root, { attributes: true, attributeFilter: ['data-orientation', 'data-handle', 'data-scene'] });\n    new ResizeObserver(measure).observe(frame);\n    root.vsCompare = { show, set: v => go(v) };\n\n    // one-time sweep hint when it first comes into view\n    if (root.dataset.hint !== 'off' && !reduce.matches && 'IntersectionObserver' in window) {\n      const io = new IntersectionObserver(([en]) => {\n        if (!en.isIntersecting) return; io.disconnect();\n        const seq = [[start + 18, 120, 16], [start - 14, 110, 16], [start, 130, 20]];\n        seq.forEach(([t, k, c], i) => setTimeout(() => { if (!touched) go(t, k, c); }, 500 + i * 600));\n      }, { threshold: 0.6 });\n      io.observe(frame);\n    }\n    if (root.dataset.scene != null) show(root.dataset.scene);\n    measure();\n  }\n  const boot = () => document.querySelectorAll('.vs-compare-slider').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Cards & layout",
    "tags": [
      "before after",
      "comparison",
      "slider",
      "image",
      "drag",
      "keyboard",
      "accessible",
      "vertical",
      "spring"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=DM+Mono&display=swap"
    ],
    "source": "hraun",
    "free": true,
    "accent": "#ff8a4c",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/compare-slider/index.html",
    "thumbnail": "https://vividsites.app/api/component/compare-slider/thumb.jpg",
    "video": "https://vividsites.app/api/component/compare-slider/thumb.mp4",
    "page": "https://vividsites.app/c/compare-slider"
  }
}