{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "extruded-text",
  "type": "registry:block",
  "title": "Extruded Text",
  "description": "One word in Syne ExtraBold, extruded into bevelled 3D letters that stand on a studio sweep of the same colour. The camera leans toward the pointer on a spring, letters hop on hover and squash on press, and the material switches between matte clay, tinted chrome and candy glass.",
  "docs": "Extruded Text was added to public/vividsites/extruded-text.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/extruded-text",
  "categories": [
    "3d",
    "3d-effects"
  ],
  "files": [
    {
      "path": "components/vividsites/extruded-text.html",
      "type": "registry:file",
      "target": "public/vividsites/extruded-text.html",
      "content": "<!--\n  vividsites · extruded-text\n  One word, set in a real display face and extruded into a bevelled 3D solid that\n  stands on a seamless studio sweep in the same colour. The word leans toward the\n  pointer on a spring; each letter hops when you hover it (touch: drag across) and\n  squashes when you press it. Soft key + rim light, a studio environment tinted by\n  the stage colour, a soft cast shadow and a contact shadow under every letter.\n\n  Markup:\n    data-word        the word (one short word reads best; A–Z, a–z, 0–9, & ! ?)\n    data-material    clay | chrome | candy\n    data-font        URL of a .ttf/.otf/.woff (not woff2). Default: Syne ExtraBold.\n  Customise:\n    --vs-extruded-text-color    the stage and letter hue (letters are a lighter tone of it)\n    --vs-extruded-text-depth    extrusion depth, as a fraction of the letter height (.1–.9)\n    --vs-extruded-text-fill     how much of the width the word spans (.4–.95)\n    --vs-extruded-text-y        vertical centre of the word, as a fraction of the height (.3–.7, default .56)\n    --vs-extruded-text-clear    band at the top kept free of the word, fraction of the height (default .12)\n    --vs-extruded-text-tilt     how far it leans toward the pointer (0 = still)\n    --vs-extruded-text-height   component height\n  JS:\n    el.vsExtrudedText.set({ word, material, color, depth })   any subset; animates the change\n  Needs: three@0.170.0 (+ TTFLoader, FontLoader, BufferGeometryUtils, blur shaders) via the importmap below.\n  Without WebGL the <h2> stays visible, with a CSS extrusion.\n-->\n<div class=\"vs-extruded-text\" data-word=\"Pulse\" data-material=\"clay\">\n  <h2 class=\"vs-extruded-text-word\">Pulse</h2>\n  <canvas class=\"vs-extruded-text-canvas\" aria-hidden=\"true\"></canvas>\n</div>\n\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Syne:wght@800&display=swap\">\n<style>\n  .vs-extruded-text {\n    --vs-extruded-text-color: #ff5a36;\n    --vs-extruded-text-depth: .42;\n    --vs-extruded-text-fill: .72;\n    --vs-extruded-text-y: .56;\n    --vs-extruded-text-clear: .12;\n    --vs-extruded-text-tilt: 1;\n    --vs-extruded-text-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-extruded-text-height); min-height: 360px; overflow: hidden; isolation: isolate;\n    background: radial-gradient(120% 90% at 50% 30%, color-mix(in srgb, var(--vs-extruded-text-color) 88%, #fff), var(--vs-extruded-text-color) 60%, color-mix(in srgb, var(--vs-extruded-text-color) 80%, #000));\n    touch-action: pan-y; -webkit-tap-highlight-color: transparent; cursor: default;\n  }\n  .vs-extruded-text-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .8s cubic-bezier(.16,1,.3,1); }\n  .vs-extruded-text[data-vs-state=\"ready\"] .vs-extruded-text-canvas { opacity: 1; }\n  .vs-extruded-text-word {\n    position: absolute; inset: 0; margin: 0; display: grid; place-items: center; pointer-events: none;\n    font: 800 min(22vw, 34vh)/1 Syne, ui-sans-serif, system-ui, sans-serif; letter-spacing: -.03em;\n    color: color-mix(in srgb, var(--vs-extruded-text-color) 80%, #fff);\n    text-shadow: 1px 1px 0 color-mix(in srgb, var(--vs-extruded-text-color) 78%, #000), 2px 2px 0 color-mix(in srgb, var(--vs-extruded-text-color) 74%, #000),\n      3px 3px 0 color-mix(in srgb, var(--vs-extruded-text-color) 70%, #000), 4px 4px 0 color-mix(in srgb, var(--vs-extruded-text-color) 66%, #000),\n      5px 5px 0 color-mix(in srgb, var(--vs-extruded-text-color) 62%, #000), 6px 6px 0 color-mix(in srgb, var(--vs-extruded-text-color) 58%, #000),\n      10px 18px 30px color-mix(in srgb, var(--vs-extruded-text-color) 40%, #000);\n    transition: opacity .6s cubic-bezier(.16,1,.3,1);\n  }\n  .vs-extruded-text[data-vs-state=\"ready\"] .vs-extruded-text-word, .vs-extruded-text[data-vs-state=\"loading\"] .vs-extruded-text-word { opacity: 0; }\n  .vs-extruded-text:focus-visible { outline: 2px solid color-mix(in srgb, var(--vs-extruded-text-color) 40%, #000); outline-offset: -6px; }\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 { TTFLoader } from 'three/addons/loaders/TTFLoader.js';\nimport { Font } from 'three/addons/loaders/FontLoader.js';\nimport { toCreasedNormals } from 'three/addons/utils/BufferGeometryUtils.js';\nimport { HorizontalBlurShader } from 'three/addons/shaders/HorizontalBlurShader.js';\nimport { VerticalBlurShader } from 'three/addons/shaders/VerticalBlurShader.js';\n\nconst FONT = 'https://cdn.jsdelivr.net/npm/@fontsource/syne@5/files/syne-latin-800-normal.woff';\nconst fonts = new Map();\nconst loadFont = url => {\n  if (!fonts.has(url)) fonts.set(url, new Promise((res, rej) => new TTFLoader().load(url, j => res(new Font(j)), undefined, rej)));\n  return fonts.get(url);\n};\nconst reduce = matchMedia('(prefers-reduced-motion: reduce)');\n\nfunction mount(root) {\n  if (root.vsExtrudedText) return;\n  const canvas = root.querySelector('.vs-extruded-text-canvas');\n  const label = root.querySelector('.vs-extruded-text-word');\n  const cssv = (n, d) => getComputedStyle(root).getPropertyValue(n).trim() || d;\n  const numv = (n, d) => { const v = parseFloat(cssv(n, '')); return Number.isFinite(v) ? v : d; };\n\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); }\n  catch { root.dataset.vsState = 'nogl'; return; }\n  root.dataset.vsState = 'loading';\n  renderer.outputColorSpace = THREE.SRGBColorSpace;\n  renderer.toneMapping = THREE.NeutralToneMapping;\n  renderer.toneMappingExposure = 1;\n  renderer.shadowMap.enabled = true;\n  renderer.shadowMap.type = THREE.PCFShadowMap;\n\n  const opt = {\n    word: root.dataset.word || label?.textContent.trim() || 'Pulse',\n    material: root.dataset.material || 'clay',\n    color: cssv('--vs-extruded-text-color', '#ff5a36'),\n    depth: numv('--vs-extruded-text-depth', .42),\n  };\n\n  const scene = new THREE.Scene();\n  const camera = new THREE.PerspectiveCamera(26, 1, .1, 200);\n  const pmrem = new THREE.PMREMGenerator(renderer);\n\n  /* ---- the studio: a seamless sweep, floor curving up into the wall ---- */\n  function sweepGeo() {\n    const prof = [], R = 3.2, N = 24;\n    for (let i = 0; i <= 6; i++) prof.push([14 - i * (14 + 1.2) / 6, 0]);           // floor, front → back (z, y)\n    for (let i = 1; i <= N; i++) { const a = (i / N) * Math.PI / 2; prof.push([-1.2 - Math.sin(a) * R, R - Math.cos(a) * R]); }\n    for (let i = 1; i <= 4; i++) prof.push([-1.2 - R, R + i * 5]);                  // wall\n    const pos = [], idx = [], X = 60;\n    prof.forEach(([z, y]) => { pos.push(-X, y, z, X, y, z); });\n    for (let i = 0; i < prof.length - 1; i++) { const a = i * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }\n    const g = new THREE.BufferGeometry();\n    g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx); g.computeVertexNormals();\n    return g;\n  }\n  const sweepMat = new THREE.MeshStandardMaterial({ roughness: .95, metalness: 0, envMapIntensity: .22 });\n  const sweep = new THREE.Mesh(sweepGeo(), sweepMat);\n  sweep.receiveShadow = true;\n  scene.add(sweep);\n\n  const hemi = new THREE.HemisphereLight(0xffffff, 0xffffff, .2); scene.add(hemi);\n  /* key: high-ish and mostly frontal, so front faces read clearly brighter than the extrusion walls */\n  const key = new THREE.SpotLight(0xfff3ea, 3.5, 0, .8, 1, 0);   /* a soft spot: fronts are brightest mid-word, the sweep falls off to the edges */\n  key.position.set(-4.3, 5.8, 12); key.target.position.set(0, .3, 0); key.castShadow = true;\n  key.shadow.mapSize.set(2048, 2048); key.shadow.radius = 7; key.shadow.bias = -.0004; key.shadow.normalBias = .02;\n  scene.add(key, key.target);\n  const rim = new THREE.DirectionalLight(0xffffff, 1.1); rim.position.set(6, 4.5, -6); scene.add(rim);\n  /* warm bounce from the floor: lifts the undersides and the walls a touch, never the fronts */\n  const bounce = new THREE.DirectionalLight(0xffb08a, .35); bounce.position.set(1, -3, 2); scene.add(bounce);\n\n  /* ---- the word ---- */\n  const word = new THREE.Group(); scene.add(word);\n  const mats = {\n    clay: new THREE.MeshPhysicalMaterial({ roughness: .6, metalness: 0, specularIntensity: .45, sheen: .12, sheenRoughness: .8, envMapIntensity: .08 }),\n    chrome: new THREE.MeshPhysicalMaterial({ roughness: .1, metalness: 1, clearcoat: .6, clearcoatRoughness: .08, envMapIntensity: 1.25 }),\n    candy: new THREE.MeshPhysicalMaterial({ roughness: .2, metalness: 0, transmission: 1, thickness: 1.2, ior: 1.6, clearcoat: 1, clearcoatRoughness: .03, iridescence: .35, iridescenceIOR: 1.3,\n      attenuationDistance: 2.6, specularIntensity: 1, envMapIntensity: 1.1, dispersion: .25 }),\n  };\n  /* clay: a darker crease where the bevel rolls from the face into the wall (object-space normal) */\n  mats.clay.onBeforeCompile = sh => {\n    sh.vertexShader = sh.vertexShader.replace('#include <common>', '#include <common>\\nvarying vec3 vObjN;').replace('#include <beginnormal_vertex>', '#include <beginnormal_vertex>\\nvObjN = normal;');\n    sh.fragmentShader = sh.fragmentShader.replace('#include <common>', '#include <common>\\nvarying vec3 vObjN;')\n      .replace('#include <color_fragment>', '#include <color_fragment>\\n{ float bz = abs(normalize(vObjN).z); diffuseColor.rgb *= 1.0 - .28 * smoothstep(.97, .8, bz) * smoothstep(.12, .5, bz); }');\n  };\n  let letters = [], bounds = { w: 1, h: .7 }, font = null, fontURL = root.dataset.font || FONT;\n\n  /* smooth the bevel and the curved sides, but keep the flat faces flat (no triangulation streaks) */\n  function flatCaps(soft, src) {\n    const n = soft.getAttribute('normal'), cap = src.groups.find(g => g.materialIndex === 0);\n    if (cap) for (let i = cap.start; i < cap.start + cap.count; i++) { const z = n.getZ(i) >= 0 ? 1 : -1; n.setXYZ(i, 0, 0, z); }\n    soft.clearGroups(); src.dispose(); return soft;\n  }\n  function buildWord() {\n    letters.forEach(l => { l.mesh.geometry.dispose(); word.remove(l.pivot); });\n    letters = [];\n    const size = 1, scale = size / font.data.resolution, depth = THREE.MathUtils.clamp(opt.depth, .05, 1.2);\n    const bevel = Math.min(.045, depth * .16);\n    let x = 0, maxY = 0;\n    const track = -.02 * size;\n    const chars = [...opt.word];\n    const tmp = [];\n    for (const ch of chars) {\n      const glyph = font.data.glyphs[ch] || font.data.glyphs['?'];\n      const adv = (glyph ? glyph.ha : font.data.resolution * .3) * scale;\n      if (ch.trim()) {\n        const shapes = font.generateShapes(ch, size);\n        let g = new THREE.ExtrudeGeometry(shapes, { depth, curveSegments: 14, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel * .85, bevelOffset: 0, bevelSegments: 6 });\n        g.computeBoundingBox();\n        const bb = g.boundingBox, cx = (bb.min.x + bb.max.x) / 2;\n        g.translate(-cx, 0, -depth / 2);\n        g = flatCaps(toCreasedNormals(g, Math.PI / 5), g);\n        maxY = Math.max(maxY, bb.max.y);\n        tmp.push({ g, x: x + cx, ch });\n      }\n      x += adv + track;\n    }\n    const total = x - track;\n    for (const t of tmp) {\n      const mesh = new THREE.Mesh(t.g, mats[opt.material] || mats.clay);\n      mesh.castShadow = opt.material !== 'candy';   /* glass only gets the soft contact shadow */\n      const pivot = new THREE.Group(); pivot.add(mesh);\n      pivot.position.x = t.x - total / 2;\n      word.add(pivot);\n      letters.push({ pivot, mesh, ch: t.ch, y: 0, vy: 0, r: 0, vr: 0, s: 1, vs: 0, drop: 0 });\n    }\n    bounds = { w: total, h: maxY, d: depth + bevel * 2 };\n    word.position.set(0, .012, 0);\n    fitShadows(); frame(); resize();\n  }\n\n  /* ---- contact shadow: the word rendered from below into a blurred darkness map ---- */\n  const CS = { size: 512 };\n  const csRT = new THREE.WebGLRenderTarget(CS.size, CS.size), csBlur = new THREE.WebGLRenderTarget(CS.size, CS.size);\n  csRT.texture.generateMipmaps = csBlur.texture.generateMipmaps = false;\n  const csCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, .7); csCam.rotation.x = Math.PI / 2;\n  const csDepth = new THREE.MeshDepthMaterial();\n  csDepth.onBeforeCompile = s => { s.fragmentShader = s.fragmentShader.replace('gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );', 'gl_FragColor = vec4( vec3( 1.0 ), ( 1.0 - fragCoordZ ) * 1.6 );'); };\n  csDepth.depthTest = csDepth.depthWrite = false;\n  const csPlaneMat = new THREE.MeshBasicMaterial({ map: csRT.texture, transparent: true, depthWrite: false, opacity: .95, toneMapped: false });\n  const csPlane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1).rotateX(Math.PI / 2), csPlaneMat);\n  csPlane.position.y = .004; csPlane.renderOrder = 1; scene.add(csPlane);\n  const hBlur = new THREE.ShaderMaterial(HorizontalBlurShader), vBlur = new THREE.ShaderMaterial(VerticalBlurShader);\n  hBlur.depthTest = vBlur.depthTest = false;\n  const blurQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); const blurCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); blurQuad.frustumCulled = false;\n  const blurScene = new THREE.Scene(); blurScene.add(blurQuad);\n  function fitShadows() {\n    const w = bounds.w + 1.2, d = Math.max(1.4, bounds.d + 1.2);\n    csPlane.scale.set(w, -1, d);\n    csCam.left = -w / 2; csCam.right = w / 2; csCam.top = d / 2; csCam.bottom = -d / 2; csCam.updateProjectionMatrix();\n    const c = key.shadow.camera; c.near = 4; c.far = 32; key.shadow.focus = .5; c.updateProjectionMatrix();\n  }\n  function blurPass(amount) {\n    blurQuad.material = hBlur; hBlur.uniforms.tDiffuse.value = csRT.texture; hBlur.uniforms.h.value = amount / 256;\n    renderer.setRenderTarget(csBlur); renderer.render(blurScene, blurCam);\n    blurQuad.material = vBlur; vBlur.uniforms.tDiffuse.value = csBlur.texture; vBlur.uniforms.v.value = amount / 256;\n    renderer.setRenderTarget(csRT); renderer.render(blurScene, blurCam);\n  }\n  function renderContact() {\n    const bg = scene.background; scene.background = null;\n    sweep.visible = csPlane.visible = false;\n    scene.overrideMaterial = csDepth;\n    const cc = renderer.getClearAlpha(); renderer.setClearAlpha(0);\n    renderer.setRenderTarget(csRT); renderer.clear(); renderer.render(scene, csCam);\n    scene.overrideMaterial = null;\n    blurPass(1.2); blurPass(.5);\n    renderer.setRenderTarget(null); renderer.setClearAlpha(cc);\n    sweep.visible = csPlane.visible = true; scene.background = bg;\n  }\n\n  /* ---- colour: stage + letters + a studio environment tinted by the stage ---- */\n  let envRT = null;\n  function applyColor() {\n    const c = new THREE.Color(opt.color);\n    const hsl = {}; c.getHSL(hsl);\n    sweepMat.color.setHSL(hsl.h, Math.min(1, hsl.s + .04), hsl.l * .8);\n    hemi.color.setHSL(hsl.h, .25, .9); hemi.groundColor.setHSL((hsl.h + .02) % 1, Math.min(1, hsl.s * 1.1), Math.min(.7, hsl.l * 1.05));\n    const luma = .2126 * c.r + .7152 * c.g + .0722 * c.b;               /* linear luminance: bright hues get paler letters so they still separate */\n    const letter = c.clone().offsetHSL(0, .05, .015 + Math.max(0, luma - .3) * .12 + Math.max(0, .3 - luma) * .45);\n    mats.clay.color.copy(letter); mats.clay.sheenColor.setHSL((hsl.h + .015) % 1, .9, .72);\n    mats.chrome.color.copy(c).lerp(new THREE.Color('#ffffff'), .72);\n    mats.candy.color.copy(c).lerp(new THREE.Color('#ffffff'), .45); mats.candy.attenuationColor.copy(c).lerp(new THREE.Color('#ffffff'), .15);\n    csPlaneMat.color.setHSL(hsl.h, Math.min(1, hsl.s + .1), hsl.l * .32);\n    root.style.setProperty('--vs-extruded-text-color', opt.color);\n    /* env: a room in the stage colour, three softboxes and a bright strip */\n    const env = new THREE.Scene();\n    /* a graded studio dome: pale sky, a hot strip just above the horizon, a dark band just\n       below it and a warm floor further down. Flat chrome fronts land in the dark band and\n       sweep toward the floor glow as the word tilts; bevels catch the strip and softboxes. */\n    const sg = new THREE.SphereGeometry(10, 64, 48), sp = sg.getAttribute('position'), cols = [];\n    const white = new THREE.Color(1, 1, 1), tmp = new THREE.Color();\n    const sky = c.clone().lerp(white, .55), dark = c.clone().multiplyScalar(.05), glow = c.clone().lerp(white, .4).multiplyScalar(1.15);\n    const ss = (a, b, x) => { const t = THREE.MathUtils.clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };\n    for (let i = 0; i < sp.count; i++) {\n      const e = Math.asin(THREE.MathUtils.clamp(sp.getY(i) / 10, -1, 1)) * 180 / Math.PI;\n      if (e >= 0) tmp.copy(sky).lerp(white.clone().multiplyScalar(2.6), ss(16, 5, e) * ss(0, 2.5, e));\n      else tmp.copy(dark).lerp(glow, ss(-16, -27, e));\n      cols.push(tmp.r, tmp.g, tmp.b);\n    }\n    sg.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));\n    env.add(new THREE.Mesh(sg, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide })));\n    const box = (w, h, x, y, z, k) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(k, k, k), side: THREE.DoubleSide })); m.position.set(x, y, z); m.lookAt(0, 0, 0); env.add(m); };\n    box(6, 1.6, -3, 7, 5, 5); box(.9, 7, 7.5, 1.5, 3, 3.5); box(12, .5, 0, 5, -7, 4); box(.8, 5, -8, 1.5, -1, 2.5);\n    const next = pmrem.fromScene(env, .035);\n    /* per-material envMap (not scene.environment) so each material's envMapIntensity applies:\n       clay takes only a trace of the room, so its walls stay darker than the key-lit fronts */\n    for (const m of [...Object.values(mats), sweepMat]) { m.envMap = next.texture; m.needsUpdate = true; }\n    envRT?.dispose(); envRT = next;\n    env.traverse(o => { o.geometry?.dispose(); o.material?.dispose(); });\n    kick();\n  }\n\n  /* ---- camera fit ---- */\n  let W = 1, H = 1, portrait = false;\n  function resize() {\n    const dpr = Math.min(2, devicePixelRatio || 1);\n    W = Math.max(1, root.clientWidth); H = Math.max(1, root.clientHeight);\n    renderer.setPixelRatio(dpr); renderer.setSize(W, H, false);\n    camera.aspect = W / H; portrait = W / H < .9;\n    /* the word's centre sits at --y of the height (slightly below the middle reads grounded) */\n    const cyFrac = THREE.MathUtils.clamp(numv('--vs-extruded-text-y', .56), .3, .7);\n    camera.setViewOffset(W, H, 0, -(cyFrac - .5) * H, W, H);\n    camera.updateProjectionMatrix();\n    /* tall frames: turn the word so it recedes from the viewer and can be set much bigger */\n    camera.userData.yaw = portrait ? -.62 : 0;\n    camera.userData.elev = portrait ? .34 : .3;\n    const fill = THREE.MathUtils.clamp(numv('--vs-extruded-text-fill', .72) * (portrait ? 1.28 : 1), .3, .96);\n    /* nothing above this fraction of the height (keeps room for overlaid UI) */\n    const topNdc = 1 - 2 * THREE.MathUtils.clamp(numv('--vs-extruded-text-clear', .12), 0, .6);\n    /* fit: the distance at which the word's box spans \"fill\" of the width without rising into the clear band */\n    const hw = bounds.w / 2, hd = (bounds.d || .5) / 2, v = new THREE.Vector3();\n    const fits = d => {\n      camera.userData.dist = d; frame(0, 0); camera.updateMatrixWorld();\n      let mx = 0, top = -1, bot = 1;\n      for (const x of [-hw, hw]) for (const y of [0, bounds.h + .08]) for (const z of [-hd, hd]) {\n        v.set(x, y, z).project(camera); mx = Math.max(mx, Math.abs(v.x)); top = Math.max(top, v.y); bot = Math.min(bot, v.y);\n      }\n      return mx <= fill && top <= topNdc && bot >= -.9;\n    };\n    let lo = .5, hi = 200;\n    for (let i = 0; i < 30; i++) { const m = (lo + hi) / 2; fits(m) ? hi = m : lo = m; }\n    camera.userData.dist = hi;\n    kick();\n  }\n\n  /* ---- interaction ---- */\n  const ray = new THREE.Raycaster(), ndc = new THREE.Vector2();\n  const tilt = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0 };\n  let hovered = null, pressed = null, lastMove = -1e9, inside = false;\n  function pick(e) {\n    const r = root.getBoundingClientRect();\n    const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;\n    tilt.tx = (px * 2 - 1); tilt.ty = (py * 2 - 1);\n    ndc.set(px * 2 - 1, -(py * 2 - 1));\n    ray.setFromCamera(ndc, camera);\n    const hit = ray.intersectObjects(letters.map(l => l.mesh), false)[0];\n    return hit ? letters.find(l => l.mesh === hit.object) : null;\n  }\n  function hop(l, amt = 1) { if (!l || reduce.matches) return; l.vy += 3.2 * amt; l.vr += (Math.random() - .5) * 5 * amt; }\n  root.addEventListener('pointermove', e => {\n    lastMove = performance.now(); inside = true;\n    const l = pick(e);\n    if (l !== hovered) { hovered = l; hop(l); }\n    root.style.cursor = l ? 'pointer' : 'default';\n    kick();\n  }, { passive: true });\n  root.addEventListener('pointerdown', e => { lastMove = performance.now(); const l = pick(e); hovered = l; pressed = l; if (l && !reduce.matches) l.vs -= 2.2; kick(); }, { passive: true });\n  const up = () => { if (pressed && !reduce.matches) { pressed.vs += 3; pressed.vy += 2.4; } pressed = null; kick(); };\n  root.addEventListener('pointerup', up); root.addEventListener('pointercancel', up);\n  root.addEventListener('pointerleave', () => { hovered = null; inside = false; pressed = null; tilt.tx = 0; tilt.ty = 0; kick(); });\n  root.tabIndex = 0; root.setAttribute('role', 'img');\n  const aria = () => root.setAttribute('aria-label', `${opt.word}, in extruded 3D letters. Press Enter to make the letters hop.`);\n  root.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); letters.forEach((l, i) => setTimeout(() => hop(l, .9), i * 60)); kick(); } });\n\n  /* ---- loop ---- */\n  let raf = 0, visible = true, lost = false, last = 0, t0 = performance.now(), sleep = 0;\n  const kick = () => { if (!raf && visible && !lost && font) { last = 0; sleep = 0; raf = requestAnimationFrame(loop); } };\n  function spring(o, k, x, v, target, stiff, damp, dt) { o[v] += ((target - o[x]) * stiff - o[v] * damp) * dt; o[x] += o[v] * dt; }\n  function frame(tx = tilt.x, ty = tilt.y) {\n    const d = camera.userData.dist || 10, el = (camera.userData.elev || .2) + ty * .12, yaw = (camera.userData.yaw || 0) + tx * .35;\n    const cy = bounds.h * .56;\n    camera.position.set(Math.sin(yaw) * d * Math.cos(el), cy + Math.sin(el) * d, Math.cos(yaw) * d * Math.cos(el));\n    camera.lookAt(0, cy, 0);\n  }\n  function loop(now) {\n    raf = 0; if (lost) return;\n    const dt = Math.min(1 / 30, (now - (last || now)) / 1000); last = now;\n    const still = reduce.matches, amt = numv('--vs-extruded-text-tilt', 1);\n    const t = (now - t0) / 1000;\n    const idle = now - lastMove > 2500 && !inside;\n    const gx = still ? 0 : (idle ? Math.sin(t * .45) * .45 : tilt.tx) * amt;\n    const gy = still ? 0 : (idle ? Math.sin(t * .31) * .25 : tilt.ty) * amt;\n    tilt.vx += ((gx - tilt.x) * 38 - tilt.vx * 11) * dt; tilt.x += tilt.vx * dt;\n    tilt.vy += ((gy - tilt.y) * 38 - tilt.vy * 11) * dt; tilt.y += tilt.vy * dt;\n    let moving = Math.abs(tilt.vx) + Math.abs(tilt.vy) > 1e-3 || !idle;\n    for (const l of letters) {\n      const target = (l === hovered && !still) ? .07 : 0;\n      l.vy += ((target - l.y) * 170 - l.vy * 13) * dt; l.y += l.vy * dt;\n      l.vr += ((0 - l.r) * 150 - l.vr * 9) * dt; l.r += l.vr * dt;\n      l.vs += (((l === pressed ? .82 : 1) - l.s) * 260 - l.vs * 12) * dt; l.s += l.vs * dt;\n      if (l.drop > 0) { l.drop = Math.max(0, l.drop - dt); }\n      const y = Math.max(0, l.y);\n      l.pivot.position.y = y;\n      l.pivot.rotation.z = l.r * .12;\n      l.pivot.rotation.y = l.r * .06;\n      l.pivot.scale.set(1 + (1 - l.s) * .45, l.s, 1 + (1 - l.s) * .45);\n      if (Math.abs(l.vy) + Math.abs(l.vr) + Math.abs(l.vs) + Math.abs(l.y - target) > 1e-3) moving = true;\n    }\n    frame();\n    renderContact();\n    renderer.render(scene, camera);\n    if (root.dataset.vsState !== 'ready') root.dataset.vsState = 'ready';\n    sleep = moving || !still ? 0 : sleep + dt;\n    if (visible && (!still || sleep < .5)) raf = requestAnimationFrame(loop);\n  }\n  function entrance() {\n    if (reduce.matches) return;\n    letters.forEach((l, i) => { l.y = 1.6 + i * .12; l.vy = -1; l.vr = (i % 2 ? 1 : -1) * 2; });\n  }\n\n  canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; cancelAnimationFrame(raf); raf = 0; root.dataset.vsState = 'lost'; });\n  canvas.addEventListener('webglcontextrestored', () => { lost = false; applyColor(); root.dataset.vsState = 'ready'; kick(); });\n  new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) kick(); }).observe(root);\n  new ResizeObserver(resize).observe(root);\n  reduce.addEventListener?.('change', kick);\n\n  applyColor();\n  loadFont(fontURL).then(f => { font = f; buildWord(); entrance(); aria(); kick(); })\n    .catch(err => { root.dataset.vsState = 'nogl'; console.warn('[extruded-text] font', err?.message || err); });\n\n  root.vsExtrudedText = {\n    set(o = {}) {\n      let rebuild = false;\n      if (o.word != null && o.word !== opt.word) { opt.word = String(o.word); root.dataset.word = opt.word; if (label) label.textContent = opt.word; rebuild = true; }\n      if (o.depth != null && +o.depth !== opt.depth) { opt.depth = +o.depth; root.style.setProperty('--vs-extruded-text-depth', opt.depth); rebuild = true; }\n      if (o.material && mats[o.material]) { opt.material = o.material; root.dataset.material = o.material; letters.forEach(l => { l.mesh.material = mats[o.material]; l.mesh.castShadow = o.material !== 'candy'; }); letters.forEach((l, i) => setTimeout(() => hop(l, .6), i * 45)); }\n      if (o.color) { opt.color = o.color; applyColor(); }\n      if (rebuild && font) { const drop = o.word != null; buildWord(); if (drop) entrance(); aria(); }\n      kick();\n      return { ...opt };\n    },\n    get: () => ({ ...opt }),\n  };\n}\nconst boot = () => document.querySelectorAll('.vs-extruded-text').forEach(mount);\ndocument.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D effects",
    "tags": [
      "text",
      "3d type",
      "extrude",
      "bevel",
      "headline",
      "clay",
      "chrome",
      "glass",
      "hover"
    ],
    "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/loaders/TTFLoader.js",
      "https://cdn.jsdelivr.net/npm/@fontsource/syne@5/files/syne-latin-800-normal.woff"
    ],
    "source": null,
    "free": true,
    "accent": "#ff5a36",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/extruded-text/index.html",
    "thumbnail": "https://vividsites.app/api/component/extruded-text/thumb.jpg",
    "video": "https://vividsites.app/api/component/extruded-text/thumb.mp4",
    "page": "https://vividsites.app/c/extruded-text"
  }
}