{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "keycap",
  "type": "registry:block",
  "title": "Keycap Row",
  "description": "Sculpted keycaps in an aluminium case, modelled in code with dished tops and printed legends. Type anywhere and your letters land on the caps as each one presses 4 mm with a synthesised thock; Enter sends a wave, Esc restores.",
  "docs": "Keycap Row was added to public/vividsites/keycap.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/keycap",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/keycap.html",
      "type": "registry:file",
      "target": "public/vividsites/keycap.html",
      "content": "<!--\n  vividsites · keycap\n  A row of sculpted keycaps in an aluminium case, modelled in code: dished tops,\n  drafted walls, filleted edges, PBT-matte plastic with printed legends, real shadows.\n  Type anywhere on the page and your letters are printed onto the caps one by one,\n  each cap pressing down 4 mm on a stiff spring with a synthesised thock. Enter sends a\n  wave down the row and clears it; Esc brings the word back; Backspace steps back.\n  Click or tap a cap to press it (off-centre clicks rock it, like a real cap).\n  Typing is ignored while an input on your page has focus.\n  Under 640 px wide the caps re-lay into two rows (esc + the first half, the rest + enter)\n  so they stay big on a phone.\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-word       the word printed on the caps at rest (its length sets the row; 3 – 10)  (KEYCAP)\n    data-colorway   cream | carbon | sage                                                    (cream)\n    data-sound      on | off                                                                  (on)\n  CSS variables:\n    --vs-keycap-bg      the desk                              (#e6e2da)\n    --vs-keycap-accent  Esc and Enter                         (#ff6b2c)\n    --vs-keycap-height  component height                      (100svh)\n  JS:\n    el.vsKeycap.type('hello')   el.vsKeycap.press('Enter')\n    el.vsKeycap.set({ colorway: 'carbon', accent: '#3d7bff', sound: false, word: 'THOCK' })\n    el.vsKeycap.destroy()\n  Needs: three@0.170.0 + its addons (importmap below). Remove the importmap if your page already maps them.\n-->\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500&display=swap\">\n<div class=\"vs-keycap\" data-word=\"KEYCAP\" data-colorway=\"cream\" data-sound=\"on\" tabindex=\"0\" role=\"group\" aria-label=\"A row of keycaps. Type to print letters onto them.\">\n  <canvas class=\"vs-keycap-canvas\"></canvas>\n  <p class=\"vs-keycap-live\" aria-live=\"polite\"></p>\n  <div class=\"vs-keycap-fallback\" aria-hidden=\"true\"></div>\n</div>\n\n<style>\n  .vs-keycap {\n    --vs-keycap-bg: #e6e2da;\n    --vs-keycap-accent: #ff6b2c;\n    --vs-keycap-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-keycap-height); min-height: 360px; overflow: hidden; isolation: isolate;\n    background: var(--vs-keycap-bg); outline: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n    transition: background-color .7s cubic-bezier(.16,1,.3,1);\n  }\n  .vs-keycap.is-hot { cursor: pointer; }\n  .vs-keycap:focus-visible { box-shadow: inset 0 0 0 2px var(--vs-keycap-accent); }\n  .vs-keycap-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transform: translateY(14px);\n    transition: opacity 1s cubic-bezier(.16,1,.3,1), transform 1.4s cubic-bezier(.16,1,.3,1); }\n  .vs-keycap[data-vs-state=\"ready\"] .vs-keycap-canvas { opacity: 1; transform: none; }\n  .vs-keycap-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); margin: 0; }\n  .vs-keycap-fallback { position: absolute; inset: 0; display: none; place-items: center; }\n  .vs-keycap-fallback::before { content: \"K E Y C A P\"; padding: 22px 28px; border-radius: 14px; font: 500 28px/1 \"Space Grotesk\", ui-sans-serif, system-ui, sans-serif; letter-spacing: .4em;\n    color: #2b2a28; background: #efe8d8; box-shadow: 0 6px 0 #cfc6b2, 0 18px 30px -10px rgba(0,0,0,.3); }\n  .vs-keycap[data-vs-state=\"nogl\"] .vs-keycap-fallback { display: grid; }\n  @media (prefers-reduced-motion: reduce) { .vs-keycap-canvas { transition: opacity .3s; transform: none; } }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\", \"three/addons/\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\nimport { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';\n\nconst COLORWAYS = {\n  cream:  { cap: '#efe8d8', ink: '#2e2c29', case: '#d8d4cc', plate: '#3a3936', accInk: '#fff6ec' },\n  carbon: { cap: '#2b2c30', ink: '#e9e5dc', case: '#4a4b50', plate: '#1a1a1c', accInk: '#1b1b1d' },\n  sage:   { cap: '#c9d2bf', ink: '#2f3a2c', case: '#e1e3dc', plate: '#56604f', accInk: '#fbfaf5' },\n};\nconst U1 = 1;           // one unit = 19.05 mm\nconst H = 0.46, R = 0.045, DISH = 0.028, TRAVEL = 0.21;\n\nclass Spring {\n  constructor(x, k = 120, c = 18) { this.x = x; this.v = 0; this.t = x; this.k = k; this.c = c; }\n  step(dt) { const n = Math.ceil(dt / (1 / 240)); const h = dt / n; for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.t) - this.c * this.v) * h; this.x += this.v * h; } return this.x; }\n}\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\n\n// a sculpted cap: drafted walls, a filleted rim, a spherical dish; one loft of superellipse rings\nfunction capGeometry(wu) {\n  const a0 = (wu * U1 - 0.05) / 2, b0 = (U1 - 0.05) / 2, a1 = a0 - 0.13, b1 = b0 - 0.12, M = 96;\n  const rings = [];\n  const ring = (a, b, y, n, zOff = 0) => { const pts = []; for (let i = 0; i < M; i++) { const t = i / M * Math.PI * 2, c = Math.cos(t), s = Math.sin(t); pts.push([a * Math.sign(c) * Math.pow(Math.abs(c), 2 / n), y, b * Math.sign(s) * Math.pow(Math.abs(s), 2 / n) + zOff]); } rings.push(pts); };\n  for (let k = 0; k <= 12; k++) { const t = k / 12, e = Math.pow(t, 0.85); ring(a0 + (a1 - a0) * e, b0 + (b1 - b0) * e, (H - R) * t, 9 - 3.5 * t, -0.02 * t); }\n  for (let f = 1; f <= 6; f++) { const p = f / 6 * Math.PI / 2; ring(a1 - R + R * Math.cos(p), b1 - R + R * Math.cos(p), H - R + R * Math.sin(p), 5.5, -0.02); }\n  const aT = a1 - R, bT = b1 - R;\n  for (let k = 1; k <= 13; k++) { const s = 1 - k / 14; ring(aT * s, bT * s, H - DISH * (1 - s * s), 5.5 - 2.5 * (1 - s), -0.02); }\n  const pos = [], uv = [], idx = [];\n  rings.forEach((r) => r.forEach(([x, y, z]) => { pos.push(x, y, z); uv.push(0.5 + x / (2 * aT), 0.5 - (z + 0.02) / (2 * bT)); }));\n  for (let r = 0; r < rings.length - 1; r++) for (let i = 0; i < M; i++) { const a = r * M + i, b = r * M + (i + 1) % M, c = a + M, d = b + M; idx.push(a, c, b, b, c, d); }\n  const ci = rings.length * M, last = (rings.length - 1) * M; pos.push(0, H - DISH, -0.02); uv.push(0.5, 0.5); // close the dish with a fan\n  for (let i = 0; i < M; i++) idx.push(last + i, ci, last + (i + 1) % M);\n  const g = new THREE.BufferGeometry();\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx); g.computeVertexNormals();\n  return g;\n}\n\nfunction legendTexture(text, wu, cap, ink, small) {\n  const c = document.createElement('canvas'); c.width = Math.round(256 * wu); c.height = 256;\n  const x = c.getContext('2d');\n  x.fillStyle = cap; x.fillRect(0, 0, c.width, c.height);\n  // PBT: a faint speckle so the matte surface isn't flat\n  const img = x.getImageData(0, 0, c.width, c.height), d = img.data;\n  for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 7; d[i] += n; d[i + 1] += n; d[i + 2] += n; }\n  x.putImageData(img, 0, 0);\n  x.fillStyle = ink;\n  if (small) { x.font = '500 46px \"Space Grotesk\", ui-sans-serif, system-ui, sans-serif'; x.textAlign = 'left'; x.textBaseline = 'top'; x.fillText(text, 30, 36); }\n  else { x.font = '500 118px \"Space Grotesk\", ui-sans-serif, system-ui, sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(text, c.width / 2, c.height / 2 + 6); }\n  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4; return t;\n}\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-keycap-canvas'), live = el.querySelector('.vs-keycap-live');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;\n  renderer.toneMapping = THREE.NeutralToneMapping; renderer.toneMappingExposure = 1.0;\n  const bg = new THREE.Color(css(el, '--vs-keycap-bg', '#e6e2da')); renderer.setClearColor(bg, 1);\n  const scene = new THREE.Scene();\n  const pmrem = new THREE.PMREMGenerator(renderer); const envRT = pmrem.fromScene(new RoomEnvironment(), 0.04); scene.environment = envRT.texture;\n  const camera = new THREE.PerspectiveCamera(24, 1, 0.1, 100);\n  const key = new THREE.DirectionalLight(0xfff4e6, 1.6); key.position.set(-3, 7, 4); key.castShadow = true;\n  key.shadow.mapSize.set(2048, 2048); key.shadow.radius = 6; key.shadow.bias = -0.0004; key.shadow.normalBias = 0.02;\n  Object.assign(key.shadow.camera, { left: -7, right: 7, top: 3, bottom: -3, near: 1, far: 20 }); scene.add(key);\n  scene.add(new THREE.HemisphereLight(0xffffff, 0x8a8074, 0.5));\n  // the desk: catches the case's shadow\n  const desk = new THREE.Mesh(new THREE.PlaneGeometry(60, 30), new THREE.ShadowMaterial({ opacity: 0.2 })); desk.rotation.x = -Math.PI / 2; desk.position.y = -0.4; desk.receiveShadow = true; scene.add(desk);\n\n  const row = new THREE.Group(); scene.add(row);\n  const S = { vis: true, raf: 0, last: 0, t: 0, cursor: 0, sound: el.dataset.sound !== 'off', audio: null, hot: -1, compact: false, word: (el.dataset.word || 'KEYCAP').toUpperCase().slice(0, 10) };\n  let keys = [], caseMesh = null, plateMesh = null;\n  const geoCache = {};\n  const geoFor = (wu) => geoCache[wu] || (geoCache[wu] = capGeometry(wu));\n  const accent = () => css(el, '--vs-keycap-accent', '#ff6b2c');\n\n  function paint(k) {\n    const cw = COLORWAYS[el.dataset.colorway] || COLORWAYS.cream;\n    const isAcc = k.kind !== 'letter', cap = isAcc ? accent() : cw.cap, ink = isAcc ? cw.accInk : cw.ink;\n    if (k.mat.map) k.mat.map.dispose();\n    k.mat.map = legendTexture(k.kind === 'letter' ? (k.char || '') : k.label, k.wu, cap, ink, k.kind !== 'letter'); k.mat.needsUpdate = true;\n  }\n  function layout() {\n    keys.forEach((k) => { row.remove(k.g); k.mat.map && k.mat.map.dispose(); k.mat.dispose(); });\n    if (caseMesh) { row.remove(caseMesh, plateMesh); caseMesh.geometry.dispose(); plateMesh.geometry.dispose(); }\n    const letters = Math.max(3, Math.min(10, S.word.length));\n    // wide stages: one row. Phones: two rows (a wide esc + the first half, the rest + enter), so the caps stay big\n    const spec = [{ kind: 'esc', label: 'esc', wu: S.compact ? 1.75 : 1, r: 0 }, ...Array.from({ length: letters }, (_, i) => ({ kind: 'letter', char: S.word[i] || '', wu: 1, r: S.compact && i >= Math.ceil(letters / 2) ? 1 : 0 })), { kind: 'enter', label: 'enter', wu: 1.75, r: S.compact ? 1 : 0 }];\n    const rows = S.compact ? 2 : 1, rowW = [0, 1].map((r) => spec.filter((k) => k.r === r).reduce((s, k) => s + k.wu, 0)), total = Math.max(...rowW);\n    const xs = rowW.map((w) => -w / 2);\n    keys = spec.map((k, i) => {\n      const g = new THREE.Group(), mat = new THREE.MeshPhysicalMaterial({ roughness: 0.62, metalness: 0, sheen: 0.25, sheenRoughness: 0.8, envMapIntensity: 0.45 });\n      const m = new THREE.Mesh(geoFor(k.wu), mat); m.castShadow = true; m.receiveShadow = true; g.add(m);\n      const cx = xs[k.r] + k.wu / 2; xs[k.r] += k.wu; g.position.set(cx, 0.08, (k.r - (rows - 1) / 2) * 1.04); row.add(g);\n      const o = { ...k, i, g, m, mat, cx, y: new Spring(0, 900, 34), rz: new Spring(0, 500, 18), rx: new Spring(0, 500, 18), glow: 0 };\n      if (k.wu > 1.2) { o.y.k = 700; o.y.c = 26; } // the stabilised key sits a touch looser\n      paint(o); return o;\n    });\n    // the case: an anodised slab with a dark plate inset\n    const cw = COLORWAYS[el.dataset.colorway] || COLORWAYS.cream;\n    const rr = (w, d, r) => { const s = new THREE.Shape(), hw = w / 2, hd = d / 2; s.moveTo(-hw + r, -hd); s.lineTo(hw - r, -hd); s.quadraticCurveTo(hw, -hd, hw, -hd + r); s.lineTo(hw, hd - r); s.quadraticCurveTo(hw, hd, hw - r, hd); s.lineTo(-hw + r, hd); s.quadraticCurveTo(-hw, hd, -hw, hd - r); s.lineTo(-hw, -hd + r); s.quadraticCurveTo(-hw, -hd, -hw + r, -hd); return s; };\n    const cg = new THREE.ExtrudeGeometry(rr(total + 0.46, rows * 1.04 + 0.42, 0.2), { depth: 0.36, bevelEnabled: true, bevelThickness: 0.04, bevelSize: 0.04, bevelSegments: 5, curveSegments: 12 });\n    cg.rotateX(-Math.PI / 2); cg.translate(0, -0.36, 0);\n    caseMesh = new THREE.Mesh(cg, new THREE.MeshPhysicalMaterial({ color: cw.case, metalness: 0.55, roughness: 0.42, clearcoat: 0.2, envMapIntensity: 0.9 }));\n    caseMesh.castShadow = true; caseMesh.receiveShadow = true;\n    const pg = new THREE.ExtrudeGeometry(rr(total + 0.06, rows * 1.04 + 0.02, 0.08), { depth: 0.02, bevelEnabled: false, curveSegments: 8 }); pg.rotateX(-Math.PI / 2); pg.translate(0, 0.035, 0);\n    plateMesh = new THREE.Mesh(pg, new THREE.MeshStandardMaterial({ color: cw.plate, roughness: 0.7, metalness: 0.2 })); plateMesh.receiveShadow = true;\n    row.add(caseMesh, plateMesh);\n    S.total = total + 0.5; S.depth = rows * 1.04 + 0.46;\n    S.cursor = 0;\n    fit();\n  }\n  function recolor() { const cw = COLORWAYS[el.dataset.colorway] || COLORWAYS.cream; keys.forEach(paint); caseMesh.material.color.set(cw.case); plateMesh.material.color.set(cw.plate); }\n\n  function fit() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h || !S.total) return;\n    camera.aspect = w / h; camera.updateProjectionMatrix();\n    // a three-quarter view on wide stages (the row recedes a little), near top-down on phones;\n    // the distance is solved numerically so the row's projected box fills the frame\n    const narrow = w < 640, az = THREE.MathUtils.degToRad(narrow ? 0 : 14), elv = THREE.MathUtils.degToRad(narrow ? 64 : 40);\n    const fillW = narrow ? 0.9 : 0.86, fillH = 0.62, hw = S.total / 2;\n    const corners = []; for (const x of [-hw, hw]) for (const y of [-0.4, 0.55]) for (const z of [-S.depth / 2, S.depth / 2]) corners.push(new THREE.Vector3(x, y, z));\n    let d = 10, target = new THREE.Vector3(0, 0.05, 0);\n    for (let it = 0; it < 6; it++) {\n      camera.position.set(Math.sin(az) * Math.cos(elv) * d, Math.sin(elv) * d, Math.cos(az) * Math.cos(elv) * d).add(target); camera.lookAt(target); camera.updateMatrixWorld();\n      let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9;\n      for (const c of corners) { const p = c.clone().project(camera); x0 = Math.min(x0, p.x); x1 = Math.max(x1, p.x); y0 = Math.min(y0, p.y); y1 = Math.max(y1, p.y); }\n      const k = Math.max((x1 - x0) / 2 / fillW, (y1 - y0) / 2 / fillH);\n      d *= k;\n      // recentre: nudge the target so the box sits in the middle of the frame\n      const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;\n      const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0), upv = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1);\n      const span = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * d;\n      target.addScaledVector(right, cx * span * camera.aspect * 0.9).addScaledVector(upv, cy * span * 0.9);\n    }\n    camera.position.set(Math.sin(az) * Math.cos(elv) * d, Math.sin(elv) * d, Math.cos(az) * Math.cos(elv) * d).add(target); camera.lookAt(target); camera.updateProjectionMatrix();\n  }\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(w, h, false);\n    const c = w < 640; if (c !== S.compact || !keys.length) { S.compact = c; layout(); } else fit();\n  }\n\n  // ---- sound: a short thock (body + click), a softer tick on the way up\n  function thock(up, wide) {\n    if (!S.sound) return;\n    try {\n      if (!S.audio) { const C = window.AudioContext || window.webkitAudioContext; if (!C) return; S.audio = new C(); }\n      const ctx = S.audio; if (ctx.state === 'suspended') ctx.resume();\n      const t = ctx.currentTime + 0.002, g0 = up ? 0.35 : 1, r = 0.94 + Math.random() * 0.12;\n      const o = ctx.createOscillator(), og = ctx.createGain(); o.type = 'sine'; o.frequency.setValueAtTime((wide ? 150 : 210) * r * (up ? 1.4 : 1), t); o.frequency.exponentialRampToValueAtTime(70, t + 0.06);\n      og.gain.setValueAtTime(0.28 * g0, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.09); o.connect(og); og.connect(ctx.destination); o.start(t); o.stop(t + 0.1);\n      const len = Math.floor(ctx.sampleRate * 0.06), b = ctx.createBuffer(1, len, ctx.sampleRate), d = b.getChannelData(0);\n      for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 4);\n      const n = ctx.createBufferSource(), f = ctx.createBiquadFilter(), ng = ctx.createGain(); n.buffer = b; f.type = 'bandpass'; f.frequency.value = (up ? 3600 : 1900) * r; f.Q.value = 0.9;\n      ng.gain.value = 0.32 * g0; n.connect(f); f.connect(ng); ng.connect(ctx.destination); n.start(t);\n    } catch (_) {}\n  }\n\n  // ---- pressing\n  function down(k, hitX = 0, hitZ = 0) {\n    if (!k || k.isDown) return; k.isDown = true; k.y.t = -TRAVEL; k.rz.t = -hitX * 0.12; k.rx.t = hitZ * 0.1; thock(false, k.wu > 1.2);\n  }\n  function up(k) { if (!k || !k.isDown) return; k.isDown = false; k.y.t = 0; k.rz.t = 0; k.rx.t = 0; thock(true, k.wu > 1.2); }\n  const tap = (k, hold = 90) => { down(k); setTimeout(() => up(k), hold); };\n  const letters = () => keys.filter((k) => k.kind === 'letter');\n  function write(ch) {\n    const L = letters(); if (!L.length) return;\n    if (S.cursor === 0 && S.fresh !== false) { L.forEach((k) => { k.char = ''; paint(k); }); S.fresh = false; }\n    if (S.cursor >= L.length) { // full: the row scrolls like a ticker, newest letter on the last cap\n      for (let i = 0; i < L.length - 1; i++) if (L[i].char !== L[i + 1].char) { L[i].char = L[i + 1].char; paint(L[i]); }\n      S.cursor = L.length - 1;\n    }\n    const k = L[S.cursor]; k.char = ch; paint(k); S.cursor++;\n    live.textContent = L.map((x) => x.char || ' ').join('').trim();\n    return k;\n  }\n  function wave(clear) {\n    const L = letters(); const enter = keys.find((k) => k.kind === 'enter');\n    L.forEach((k, i) => setTimeout(() => { tap(k, 110); if (clear) setTimeout(() => { k.char = ''; paint(k); }, 60); }, reduced ? 0 : 60 + i * 55));\n    S.cursor = 0; S.fresh = false;\n  }\n  function restore() { const L = letters(); L.forEach((k, i) => setTimeout(() => { k.char = S.word[i] || ''; paint(k); tap(k, 80); }, reduced ? 0 : i * 50)); S.cursor = 0; S.fresh = true; }\n  const held = new Map();\n  function keyFor(e) {\n    if (e.key === 'Enter') return keys.find((k) => k.kind === 'enter');\n    if (e.key === 'Escape') return keys.find((k) => k.kind === 'esc') || keys.find((k) => k.kind === 'enter');\n    return null;\n  }\n  const typingElsewhere = (e) => { const t = e.target; return t && t !== el && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); };\n  const onKeyDown = (e) => {\n    if (!S.vis || typingElsewhere(e) || e.metaKey || e.ctrlKey || e.altKey) return;\n    if (e.repeat) return;\n    let k = keyFor(e);\n    if (k) { down(k); held.set(e.code, k); if (e.key === 'Enter') wave(true); if (e.key === 'Escape') restore(); if (e.target === el) e.preventDefault(); return; }\n    if (e.key === 'Backspace') { const L = letters(); if (S.fresh) { S.fresh = false; S.cursor = L.length; } S.cursor = Math.max(0, S.cursor - 1); k = L[S.cursor]; k.char = ''; paint(k); down(k); held.set(e.code, k); return; }\n    if (e.key.length === 1 && /\\S| /.test(e.key)) { k = write(e.key === ' ' ? '' : e.key.toUpperCase()); if (k) { down(k); held.set(e.code, k); } if (e.target === el && e.key === ' ') e.preventDefault(); }\n  };\n  const onKeyUp = (e) => { const k = held.get(e.code); if (k) { up(k); held.delete(e.code); } };\n  document.addEventListener('keydown', onKeyDown); document.addEventListener('keyup', onKeyUp);\n\n  // ---- pointer: raycast the caps\n  const ray = new THREE.Raycaster(), ndc = new THREE.Vector2();\n  let ptrKey = null;\n  const hit = (e) => { const r = el.getBoundingClientRect(); ndc.set((e.clientX - r.left) / r.width * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); ray.setFromCamera(ndc, camera); const hs = ray.intersectObjects(keys.map((k) => k.m), false); if (!hs.length) return null; const k = keys.find((x) => x.m === hs[0].object); const lp = k.g.worldToLocal(hs[0].point.clone()); return { k, x: lp.x / (k.wu / 2), z: lp.z / 0.5 }; };\n  const onPDown = (e) => { const h = hit(e); if (!h) return; ptrKey = h.k; down(h.k, h.x, h.z); try { el.setPointerCapture(e.pointerId); } catch (_) {} };\n  const onPUp = () => { if (!ptrKey) return; const k = ptrKey; ptrKey = null; up(k); if (k.kind === 'enter') wave(true); if (k.kind === 'esc') restore(); };\n  const onPMove = (e) => { if (e.pointerType !== 'mouse') return; const h = hit(e); const i = h ? h.k.i : -1; if (i !== S.hot) { S.hot = i; el.classList.toggle('is-hot', i >= 0); } };\n  const onPLeave = () => { S.hot = -1; el.classList.remove('is-hot'); };\n  el.addEventListener('pointerdown', onPDown); el.addEventListener('pointerup', onPUp); el.addEventListener('pointercancel', onPUp); el.addEventListener('pointermove', onPMove); el.addEventListener('pointerleave', onPLeave);\n\n  function frame(now) {\n    S.raf = 0; if (!S.vis) return;\n    const dt = Math.min(0.05, S.last ? (now - S.last) / 1000 : 0.016); S.last = now; S.t += dt;\n    for (const k of keys) {\n      k.g.position.y = 0.08 + k.y.step(dt); k.g.rotation.z = k.rz.step(dt); k.g.rotation.x = k.rx.step(dt);\n      k.glow += ((k.i === S.hot && !k.isDown ? 1 : 0) - k.glow) * Math.min(1, dt * 12);\n      k.mat.emissive.setScalar(k.glow * 0.035);\n    }\n    // a slow drift of the whole row so the light slides over the caps\n    if (!reduced) { row.rotation.y = Math.sin(S.t * 0.25) * 0.05; row.rotation.x = Math.sin(S.t * 0.18) * 0.015; }\n    renderer.render(scene, camera);\n    loop();\n  }\n  const loop = () => { if (!S.raf && S.vis) S.raf = requestAnimationFrame(frame); };\n  const ro = new ResizeObserver(resize); ro.observe(el);\n  const io = new IntersectionObserver(([e]) => { S.vis = e.isIntersecting; if (S.vis) { S.last = 0; loop(); } }, { rootMargin: '60px' }); io.observe(el);\n  const onVis = () => { S.vis = !document.hidden; if (S.vis) { S.last = 0; loop(); } };\n  document.addEventListener('visibilitychange', onVis);\n  canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.vis = false; });\n  canvas.addEventListener('webglcontextrestored', () => { S.vis = true; S.last = 0; keys.forEach(paint); loop(); });\n  const fontReady = document.fonts ? Promise.race([document.fonts.load('500 118px \"Space Grotesk\"'), new Promise((r) => setTimeout(r, 2000))]) : Promise.resolve();\n  fontReady.then(() => {\n    resize(); keys.forEach(paint); renderer.compile(scene, camera);\n    requestAnimationFrame(() => { el.dataset.vsState = 'ready'; loop(); });\n    document.fonts && document.fonts.addEventListener('loadingdone', () => keys.forEach(paint), { once: true });\n  });\n\n  function type(str) { [...String(str)].forEach((ch, i) => setTimeout(() => { const k = write(ch === ' ' ? '' : ch.toUpperCase()); if (k) tap(k, 80); }, i * 120)); }\n  function press(name) { if (name === 'Enter') { tap(keys.find((k) => k.kind === 'enter'), 120); wave(true); } else if (name === 'Escape') { const k = keys.find((x) => x.kind === 'esc'); if (k) tap(k); restore(); } else type(name); }\n  function set(o = {}) {\n    if (o.colorway && COLORWAYS[o.colorway]) { el.dataset.colorway = o.colorway; recolor(); }\n    if (o.accent) { el.style.setProperty('--vs-keycap-accent', o.accent); keys.filter((k) => k.kind !== 'letter').forEach(paint); }\n    if (o.sound != null) { S.sound = !!o.sound; el.dataset.sound = o.sound ? 'on' : 'off'; }\n    if (o.bg) { el.style.setProperty('--vs-keycap-bg', o.bg); bg.set(o.bg); renderer.setClearColor(bg, 1); }\n    if (o.word) { S.word = String(o.word).toUpperCase().slice(0, 10); el.dataset.word = S.word; layout(); S.fresh = true; }\n  }\n  function destroy() {\n    cancelAnimationFrame(S.raf); S.vis = false; ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keyup', onKeyUp);\n    el.removeEventListener('pointerdown', onPDown); el.removeEventListener('pointerup', onPUp); el.removeEventListener('pointercancel', onPUp); el.removeEventListener('pointermove', onPMove); el.removeEventListener('pointerleave', onPLeave);\n    keys.forEach((k) => { k.mat.map && k.mat.map.dispose(); k.mat.dispose(); }); Object.values(geoCache).forEach((g) => g.dispose());\n    scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material && o.material.dispose) o.material.dispose(); });\n    envRT.dispose(); pmrem.dispose(); if (S.audio) S.audio.close(); renderer.dispose(); delete el.vsKeycap;\n  }\n  S.fresh = true;\n  return { type, press, set, destroy };\n}\n\ndocument.querySelectorAll('.vs-keycap').forEach((el) => { if (!el.vsKeycap) { const api = mount(el); if (api) el.vsKeycap = api; } });\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "keyboard",
      "keycap",
      "typing",
      "3d",
      "sound",
      "mechanical",
      "three.js"
    ],
    "tech": [
      "three.js",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
      "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js"
    ],
    "source": null,
    "free": true,
    "accent": "#ff6b2c",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/keycap/index.html",
    "thumbnail": "https://vividsites.app/api/component/keycap/thumb.jpg",
    "video": "https://vividsites.app/api/component/keycap/thumb.mp4",
    "page": "https://vividsites.app/c/keycap"
  }
}