{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tilt-card",
  "type": "registry:block",
  "title": "Tilt Card",
  "description": "A collectible card that leans toward the pointer on a critically damped spring, lifts, and throws a travelling glare and a holographic contour foil. Click, Enter or Space turns it over; touch drags tilt it.",
  "docs": "Tilt Card was added to public/vividsites/tilt-card.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/tilt-card",
  "categories": [
    "ui",
    "cards-layout"
  ],
  "files": [
    {
      "path": "components/vividsites/tilt-card.html",
      "type": "registry:file",
      "target": "public/vividsites/tilt-card.html",
      "content": "<!--\n  vs-tilt-card: a 3D tilt card with pointer-tracked glare, holographic foil and a flip to its back.\n\n  Drop in as many .vs-tilt-card elements as you like; the script wires each one.\n  - Hover / drag (touch) tilts the card toward the pointer on a critically damped spring,\n    and it springs back flat when the pointer leaves.\n  - Click, Enter or Space turns the card over. Arrow keys tilt it while focused.\n  - Motion is transform/opacity only; the rAF loop stops as soon as the springs settle.\n  - prefers-reduced-motion: no tilt, the flip is instant.\n\n  CSS variables (set on .vs-tilt-card, or per card inline):\n    --vs-tilt-card-w        card width (height follows a 5:7 ratio)\n    --vs-tilt-card-radius   corner radius\n    --vs-tilt-card-max      max tilt, in degrees (unitless number)\n    --vs-tilt-card-ink      text colour\n    --vs-tilt-card-accent   stamp / foil tint, keyed to the photo's light\n    --vs-tilt-card-glare    glare strength 0..1\n    --vs-tilt-card-foil     holographic foil strength 0..1\n    --vs-tilt-card-back     back-face paper colour\n    --vs-tilt-card-shadow   colour of the cast shadow (lighter on a light page)\n  Replace the <img src> with your own art; portrait 5:7 works best.\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=Gilda+Display&family=Space+Mono&display=swap\" rel=\"stylesheet\">\n<style>\n.vs-tilt-card{\n  --vs-tilt-card-w: 300px;\n  --vs-tilt-card-radius: 20px;\n  --vs-tilt-card-max: 11;\n  --vs-tilt-card-ink: #fff6ed;\n  --vs-tilt-card-accent: #ffb27a;\n  --vs-tilt-card-glare: .85;\n  --vs-tilt-card-foil: .6;\n  --vs-tilt-card-back: #16120f;\n  --vs-tilt-card-shadow: rgba(3,2,2,.62);\n  position: relative; width: var(--vs-tilt-card-w); aspect-ratio: 5 / 7;\n  perspective: 1000px; cursor: pointer; outline: none; user-select: none; -webkit-user-select: none;\n  touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n  color: var(--vs-tilt-card-ink); font-family: \"Space Mono\", ui-monospace, Menlo, monospace;\n}\n.vs-tilt-card-shadow{\n  position: absolute; left: 9%; right: 9%; top: 14%; bottom: -3%; border-radius: var(--vs-tilt-card-radius);\n  background: var(--vs-tilt-card-shadow); filter: blur(26px); transform: translate3d(0,18px,0); will-change: transform, opacity;\n  pointer-events: none;\n}\n.vs-tilt-card-body{\n  position: absolute; inset: 0; transform-style: preserve-3d; will-change: transform; pointer-events: none;\n}\n.vs-tilt-card-face{\n  position: absolute; inset: 0; border-radius: var(--vs-tilt-card-radius); overflow: hidden;\n  backface-visibility: hidden; -webkit-backface-visibility: hidden; isolation: isolate;\n  background: #0d0c0b;\n}\n.vs-tilt-card-face::after{ /* hairline edge + top light, like a laminated card */\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 6;\n  box-shadow: inset 0 0 0 1px rgba(255,246,237,.16), inset 0 1px 0 rgba(255,255,255,.22);\n}\n.vs-tilt-card-back{ transform: rotateY(180deg); background: var(--vs-tilt-card-back); }\n\n.vs-tilt-card-img{\n  position: absolute; inset: -5%; width: 110%; height: 110%; object-fit: cover; display: block;\n  will-change: transform; transform: translate3d(0,0,0);\n}\n.vs-tilt-card-scrim{\n  position: absolute; inset: 0; z-index: 1;\n  background:\n    linear-gradient(to top, rgba(10,8,7,.9) 0%, rgba(10,8,7,.5) 26%, rgba(10,8,7,0) 52%),\n    linear-gradient(to bottom, rgba(10,8,7,.55) 0%, rgba(10,8,7,.12) 16%, rgba(10,8,7,0) 26%);\n}\n/* holographic foil: a contour-line mask, with a moving spectral strip under it */\n.vs-tilt-card-foil{\n  position: absolute; inset: 0; z-index: 2; opacity: 0; mix-blend-mode: color-dodge; overflow: hidden;\n  --vs-tilt-card-lines: repeating-radial-gradient(ellipse 140% 90% at 18% 112%, #000 0 .8px, transparent 1.4px 11px);\n  -webkit-mask-image: var(--vs-tilt-card-lines), linear-gradient(to top, #000 8%, rgba(0,0,0,.35) 45%, transparent 70%);\n  -webkit-mask-composite: source-in;\n          mask-image: var(--vs-tilt-card-lines), linear-gradient(to top, #000 8%, rgba(0,0,0,.35) 45%, transparent 70%);\n          mask-composite: intersect;\n  will-change: opacity;\n}\n.vs-tilt-card-foil i{\n  position: absolute; top: -50%; left: -100%; width: 300%; height: 200%; will-change: transform;\n  background: linear-gradient(112deg,\n    transparent 40%, #ff9a6b 44%, #ffe38a 46.5%, #9dffcf 49%, #8ad7ff 51.5%, #c8a2ff 54%, #ff9ad1 56%, transparent 60%);\n}\n.vs-tilt-card-glare{\n  position: absolute; inset: 0; z-index: 5; overflow: hidden; opacity: 0; mix-blend-mode: overlay;\n  pointer-events: none; will-change: opacity;\n}\n.vs-tilt-card-glare i{\n  position: absolute; left: -50%; top: -50%; width: 200%; height: 200%; will-change: transform;\n  background: radial-gradient(circle at 50% 50%, rgba(255,255,255,1) 0%, rgba(255,255,255,.5) 12%, rgba(255,255,255,.12) 26%, rgba(255,255,255,0) 42%);\n}\n.vs-tilt-card-content{\n  position: absolute; inset: 0; z-index: 3; padding: 20px 20px 18px; display: flex; flex-direction: column;\n  will-change: transform;\n}\n.vs-tilt-card-row{ display: flex; justify-content: space-between; align-items: center; gap: 12px;\n  font-size: 10.5px; line-height: 1; letter-spacing: .16em; text-transform: uppercase; }\n.vs-tilt-card-row span:last-child{ opacity: .62; }\n.vs-tilt-card-title{\n  margin: auto 0 0; font: 400 38px/.95 \"Gilda Display\", Georgia, serif; letter-spacing: -.02em;\n  text-shadow: 0 2px 18px rgba(0,0,0,.35);\n}\n.vs-tilt-card-sub{ margin: 8px 0 16px; font: 400 16px/1.2 \"Gilda Display\", Georgia, serif; opacity: .62; }\n.vs-tilt-card-foot{ display: flex; justify-content: space-between; align-items: flex-end; gap: 10px;\n  padding-top: 12px; border-top: 1px solid rgba(255,246,237,.2);\n  font-size: 10px; line-height: 1.5; letter-spacing: .12em; text-transform: uppercase; }\n.vs-tilt-card-foot b{ font-weight: 400; color: var(--vs-tilt-card-accent); }\n.vs-tilt-card-turn{ display: inline-flex; align-items: center; gap: 6px; opacity: .7; white-space: nowrap; }\n.vs-tilt-card-turn svg{ width: 12px; height: 12px; }\n.vs-tilt-card-dot{ display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 8px;\n  background: var(--vs-tilt-card-accent); box-shadow: 0 0 10px var(--vs-tilt-card-accent); vertical-align: 1px; }\n\n/* back face */\n.vs-tilt-card-back .vs-tilt-card-content{ padding: 22px 20px 18px; }\n.vs-tilt-card-back::before{ /* topographic paper texture */\n  content: \"\"; position: absolute; inset: 0; opacity: .08;\n  background: repeating-radial-gradient(ellipse 120% 80% at 80% -10%, var(--vs-tilt-card-ink) 0 1px, transparent 1.5px 11px);\n}\n.vs-tilt-card-back .vs-tilt-card-title{ margin: 18px 0 0; font-size: 30px; }\n.vs-tilt-card-dl{ margin: auto 0 0; display: grid; grid-template-columns: 1fr 1fr; gap: 14px 12px; }\n.vs-tilt-card-dl dt{ font-size: 9.5px; letter-spacing: .18em; text-transform: uppercase; opacity: .5; }\n.vs-tilt-card-dl dd{ margin: 5px 0 0; font: 400 17px/1.1 \"Gilda Display\", Georgia, serif; }\n.vs-tilt-card-code{ margin-top: 18px; height: 34px; opacity: .85;\n  background: repeating-linear-gradient(90deg, var(--vs-tilt-card-ink) 0 2px, transparent 2px 4px, var(--vs-tilt-card-ink) 4px 5px, transparent 5px 9px, var(--vs-tilt-card-ink) 9px 12px, transparent 12px 14px); }\n.vs-tilt-card-back .vs-tilt-card-foot{ margin-top: 10px; }\n\n.vs-tilt-card:focus-visible .vs-tilt-card-face{ box-shadow: 0 0 0 2px #0d0c0b, 0 0 0 4px var(--vs-tilt-card-accent); }\n@media (prefers-reduced-motion: reduce){\n  .vs-tilt-card-foil, .vs-tilt-card-glare{ display: none; }\n}\n</style>\n\n<div class=\"vs-tilt-card\" tabindex=\"0\" role=\"button\" aria-pressed=\"false\" aria-label=\"Altus Col permit card. Press to turn it over.\">\n  <div class=\"vs-tilt-card-shadow\" aria-hidden=\"true\"></div>\n  <div class=\"vs-tilt-card-body\">\n    <div class=\"vs-tilt-card-face vs-tilt-card-front\">\n      <img class=\"vs-tilt-card-img\" src=\"assets/summit.webp\" alt=\"A snow-streaked granite peak above a sea of cloud at first light\" draggable=\"false\">\n      <div class=\"vs-tilt-card-scrim\"></div>\n      <div class=\"vs-tilt-card-foil\" aria-hidden=\"true\"><i></i></div>\n      <div class=\"vs-tilt-card-content\">\n        <div class=\"vs-tilt-card-row\"><span><i class=\"vs-tilt-card-dot\"></i>Nº 014</span><span>Field permit</span></div>\n        <h3 class=\"vs-tilt-card-title\">Altus Col</h3>\n        <p class=\"vs-tilt-card-sub\">4,212 m &middot; north ridge</p>\n        <div class=\"vs-tilt-card-foot\"><span>Issued <b>06:12</b><br>First light</span><span class=\"vs-tilt-card-turn\">Turn <svg viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.2\" aria-hidden=\"true\"><path d=\"M10 6a4 4 0 1 1-1.2-2.85\"/><path d=\"M9.2 1.2v2.2H7\"/></svg></span></div>\n      </div>\n      <div class=\"vs-tilt-card-glare\" aria-hidden=\"true\"><i></i></div>\n    </div>\n    <div class=\"vs-tilt-card-face vs-tilt-card-back\" aria-hidden=\"true\">\n      <div class=\"vs-tilt-card-content\">\n        <div class=\"vs-tilt-card-row\"><span>Permit 014 / 120</span><span>Series III</span></div>\n        <h3 class=\"vs-tilt-card-title\">One ascent, north ridge.</h3>\n        <dl class=\"vs-tilt-card-dl\"><div><dt>Route</dt><dd>North ridge</dd></div><div><dt>Grade</dt><dd>AD+</dd></div><div><dt>Window</dt><dd>04:00&ndash;11:00</dd></div><div><dt>Party</dt><dd>Two, roped</dd></div></dl>\n        <div class=\"vs-tilt-card-code\" aria-hidden=\"true\"></div>\n        <div class=\"vs-tilt-card-foot\"><span>ALT&middot;014&middot;0612</span><span class=\"vs-tilt-card-turn\">Back <svg viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.2\" aria-hidden=\"true\"><path d=\"M10 6a4 4 0 1 1-1.2-2.85\"/><path d=\"M9.2 1.2v2.2H7\"/></svg></span></div>\n      </div>\n      <div class=\"vs-tilt-card-glare\" aria-hidden=\"true\"><i></i></div>\n    </div>\n  </div>\n</div>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const spring = (k, c, x = 0) => ({ x, v: 0, t: x, k, c });\n\n  function init(card) {\n    if (card.dataset.vsReady) return; card.dataset.vsReady = '1';\n    const q = s => card.querySelector(s), qa = s => card.querySelectorAll(s);\n    const body = q('.vs-tilt-card-body'), shadow = q('.vs-tilt-card-shadow');\n    const imgs = qa('.vs-tilt-card-img'), contents = qa('.vs-tilt-card-content');\n    const glares = qa('.vs-tilt-card-glare'), foils = qa('.vs-tilt-card-foil');\n    const back = q('.vs-tilt-card-back'), front = q('.vs-tilt-card-front');\n    const max = () => parseFloat(getComputedStyle(card).getPropertyValue('--vs-tilt-card-max')) || 11;\n    const glareK = () => parseFloat(getComputedStyle(card).getPropertyValue('--vs-tilt-card-glare')) || 0;\n    const foilK = () => parseFloat(getComputedStyle(card).getPropertyValue('--vs-tilt-card-foil')) || 0;\n    // tension/friction pairs: 170/26 is critically damped (no overshoot) for the tilt; the flip overshoots a touch\n    const nx = spring(170, 26), ny = spring(170, 26), hov = spring(140, 24), sc = spring(320, 24, 1), fl = spring(95, 14);\n    const all = [nx, ny, hov, sc, fl];\n    let raf = 0, last = 0, flipped = false, gK = glareK(), fK = foilK(), M = max();\n\n    const render = () => {\n      const rx = -ny.x * M, ry = nx.x * M;\n      body.style.transform = `rotateX(${rx.toFixed(3)}deg) rotateY(${(ry + fl.x * 180).toFixed(3)}deg) scale(${(sc.x * (1 + hov.x * .025)).toFixed(4)})`;\n      shadow.style.transform = `translate3d(${(-nx.x * 14).toFixed(2)}px, ${(18 - ny.x * 10 + hov.x * 8).toFixed(2)}px, 0) scale(${(1 - (1 - sc.x) * 2).toFixed(4)})`;\n      shadow.style.opacity = (0.7 + hov.x * 0.3).toFixed(3);\n      imgs.forEach(el => el.style.transform = `translate3d(${(-nx.x * 9).toFixed(2)}px, ${(-ny.x * 9).toFixed(2)}px, 0)`);\n      contents.forEach(el => el.style.transform = `translate3d(${(nx.x * 5).toFixed(2)}px, ${(ny.x * 5).toFixed(2)}px, 0)`);\n      const d = Math.min(1, Math.hypot(nx.x, ny.x));\n      glares.forEach(el => {\n        el.style.opacity = (hov.x * gK * (0.45 + 0.55 * d)).toFixed(3);\n        el.firstElementChild.style.transform = `translate3d(${(nx.x * 50).toFixed(2)}%, ${(ny.x * 50).toFixed(2)}%, 0)`;\n      });\n      foils.forEach(el => {\n        el.style.opacity = (hov.x * fK * (0.35 + 0.65 * d)).toFixed(3);\n        el.firstElementChild.style.transform = `translate3d(${(nx.x * 18 + ny.x * 8).toFixed(2)}%, 0, 0)`;\n      });\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++) for (const s of all) { s.v += (-s.k * (s.x - s.t) - s.c * s.v) * h; s.x += s.v * h; }\n      render();\n      if (all.every(s => Math.abs(s.x - s.t) < 1e-4 && Math.abs(s.v) < 1e-3)) {\n        all.forEach(s => { s.x = s.t; s.v = 0; }); render(); raf = 0;\n      } else raf = requestAnimationFrame(tick);\n    };\n    const kick = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n    const aim = (x, y, h) => {\n      if (reduce.matches) return;\n      nx.t = Math.max(-1, Math.min(1, x)); ny.t = Math.max(-1, Math.min(1, y)); hov.t = h; kick();\n    };\n    const fromEvent = e => {\n      const r = card.getBoundingClientRect();\n      aim(((e.clientX - r.left) / r.width) * 2 - 1, ((e.clientY - r.top) / r.height) * 2 - 1, 1);\n    };\n    const rest = () => { nx.t = 0; ny.t = 0; hov.t = 0; sc.t = 1; kick(); };\n    const flip = () => {\n      flipped = !flipped; fl.t = flipped ? 1 : 0;\n      card.setAttribute('aria-pressed', String(flipped));\n      front && front.setAttribute('aria-hidden', String(flipped));\n      back && back.setAttribute('aria-hidden', String(!flipped));\n      if (reduce.matches) { fl.x = fl.t; fl.v = 0; render(); } else kick();\n    };\n\n    card.addEventListener('pointerenter', e => { gK = glareK(); fK = foilK(); M = max(); if (e.pointerType !== 'touch') fromEvent(e); });\n    card.addEventListener('pointermove', e => { if (e.pointerType !== 'touch' || e.pressure > 0) fromEvent(e); });\n    card.addEventListener('pointerleave', rest);\n    card.addEventListener('pointercancel', rest);\n    card.addEventListener('pointerdown', e => { sc.t = 0.965; if (e.pointerType === 'touch') { gK = glareK(); fK = foilK(); M = max(); fromEvent(e); } kick(); });\n    card.addEventListener('pointerup', e => { sc.t = 1; if (e.pointerType === 'touch') setTimeout(rest, 260); kick(); });\n    card.addEventListener('click', flip);\n    card.addEventListener('keydown', e => {\n      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flip(); return; }\n      const k = { ArrowLeft: [-.8, 0], ArrowRight: [.8, 0], ArrowUp: [0, -.8], ArrowDown: [0, .8] }[e.key];\n      if (k) { e.preventDefault(); gK = glareK(); fK = foilK(); M = max(); aim(k[0], k[1], 1); }\n    });\n    card.addEventListener('focus', () => { if (card.matches(':focus-visible')) { gK = glareK(); fK = foilK(); M = max(); aim(-.35, -.45, .8); } });\n    card.addEventListener('blur', rest);\n    render();\n  }\n  const boot = () => document.querySelectorAll('.vs-tilt-card').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Cards & layout",
    "tags": [
      "card",
      "tilt",
      "3d",
      "glare",
      "holographic",
      "foil",
      "flip",
      "spring",
      "hover",
      "touch"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Gilda+Display&family=Space+Mono&display=swap"
    ],
    "source": "altus",
    "free": true,
    "accent": "#ffb27a",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/tilt-card/index.html",
    "thumbnail": "https://vividsites.app/api/component/tilt-card/thumb.jpg",
    "video": "https://vividsites.app/api/component/tilt-card/thumb.mp4",
    "page": "https://vividsites.app/c/tilt-card"
  }
}