{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pressure-text","title":"Pressure Text","description":"Type that reacts to the cursor. Letters thicken and widen under the pointer while their neighbours give up the space, so the word keeps its overall width. Both axes are synthesised from a single cut, so it morphs rather than cross-dissolving between weights.","dependencies":[],"files":[{"path":"src/components/PressureText/index.js","content":"export { default } from './PressureText.jsx'\n","type":"registry:component","target":"components/pressure-text/index.js"},{"path":"src/components/PressureText/PressureText.jsx","content":"'use client'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport './PressureText.css'\n\n/* =========================================================\n   PressureText\n   Type that reacts to the cursor — letters thicken and widen\n   under the pointer while their neighbours give up the space,\n   so the word keeps its overall width.\n\n   Author has no variable axis (no `fvar` table, six separate\n   OTFs), so both axes are synthesised — and neither is done by\n   swapping or blending cuts, because a crossfade reads as one\n   weight dissolving into another rather than a glyph growing:\n\n   · weight — a single cut, thickened continuously with\n     `-webkit-text-stroke`. The outline grows outward from the\n     real letterform, so it morphs instead of cross-dissolving.\n   · width  — each letter's box is scaled, then ALL the boxes\n     are normalised so their widths still sum to the natural\n     total. The letter under the cursor takes space from the\n     others; the word itself never changes width.\n\n   Because of that normalisation the resting state always lands\n   back on the font's own metrics, whatever `spread` is set to.\n   ========================================================= */\n\nconst clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n)\n\nconst FALLOFF = {\n  linear: (t) => t,\n  smooth: (t) => t * t * (3 - 2 * t),\n  sharp: (t) => t * t,\n}\n\nexport default function PressureText({\n  text = '',\n  weight = 200,\n  bold = 0.075,\n  spread = 0.9,\n  cursorRadius = 3.2,\n  smoothing = 0.03,\n  falloff = 'linear',\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const chars = useMemo(() => Array.from(text), [text])\n\n  const live = useRef({})\n  // Synced after every render and before the effects below, so the loop reads\n  // current props without being torn down and restarted.\n  useEffect(() => {\n    live.current = { cursorRadius, smoothing, falloff, spread }\n  })\n\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n\n    const cells = Array.from(root.querySelectorAll('.pressure-text__char'))\n    if (!cells.length) return\n\n    const hoverable =\n      window.matchMedia('(hover: hover)').matches &&\n      !window.matchMedia('(prefers-reduced-motion: reduce)').matches\n\n    let raf = 0\n    let last = 0\n    let onScreen = true\n\n    let px = -1e5\n    let py = -1e5\n    let tx = -1e5\n    let ty = -1e5\n\n    let natural = []\n    let boxes = []\n    let reach = 0\n\n    /* Natural widths at the resting weight. Measured with the boxes released\n       so we read the font's own metrics, not last frame's stretched ones. */\n    const remeasure = () => {\n      root.classList.remove('is-live')\n      natural = cells.map((el) => el.offsetWidth)\n      root.classList.add('is-live')\n\n      const fs = parseFloat(getComputedStyle(root).fontSize) || 16\n      reach = fs * Math.max(0.01, live.current.cursorRadius)\n\n      cells.forEach((el, i) => el.style.setProperty('--pt-w', `${natural[i]}px`))\n      reposition()\n    }\n\n    /* Scrolling moves the letters; it cannot change their natural widths or the\n       root's font size. Running the full remeasure on every scroll tick meant a\n       class toggle, an offsetWidth read per letter, a getComputedStyle, a write\n       per letter and then a rect read per letter — 40-odd layout-forcing\n       operations per event on a twenty-letter word, unthrottled and unbatched.\n       Only the positions actually go stale. */\n    const reposition = () => {\n      boxes = cells.map((el) => {\n        const r = el.getBoundingClientRect()\n        return { el, x: r.left + r.width / 2, y: r.top + r.height / 2 }\n      })\n    }\n\n    const paint = () => {\n      const shape = FALLOFF[live.current.falloff] || FALLOFF.linear\n      const s = live.current.spread\n\n      // Pressure per letter, and the width each one would like to have.\n      let wanted = 0\n      let total = 0\n      const press = new Array(boxes.length)\n\n      for (let i = 0; i < boxes.length; i++) {\n        const b = boxes[i]\n        const f = shape(clamp01(1 - Math.hypot(b.x - px, b.y - py) / reach))\n        press[i] = f\n        wanted += natural[i] * (1 + f * s)\n        total += natural[i]\n      }\n\n      // Hand out the same total space, just distributed differently — this is\n      // what keeps the word the same width while the letters trade.\n      const k = wanted > 0 ? total / wanted : 1\n\n      for (let i = 0; i < boxes.length; i++) {\n        const el = boxes[i].el\n        el.style.setProperty('--pt-f', press[i].toFixed(3))\n        el.style.setProperty('--pt-sx', ((1 + press[i] * s) * k).toFixed(4))\n      }\n    }\n\n    const frame = (now) => {\n      const tau = live.current.smoothing\n      const dt = last ? Math.min(0.064, (now - last) / 1000) : 1 / 60\n      last = now\n\n      const k = tau <= 0 ? 1 : 1 - Math.exp(-dt / tau)\n      px += (tx - px) * k\n      py += (ty - py) * k\n      paint()\n\n      if (Math.abs(tx - px) < 0.4 && Math.abs(ty - py) < 0.4) {\n        px = tx\n        py = ty\n        paint()\n        raf = 0\n        last = 0\n        return\n      }\n      raf = requestAnimationFrame(frame)\n    }\n\n    const kick = () => {\n      if (!raf && onScreen) {\n        last = 0\n        raf = requestAnimationFrame(frame)\n      }\n    }\n\n    const onMove = (e) => {\n      tx = e.clientX\n      ty = e.clientY\n      kick()\n    }\n\n    /* Resize and a late webfont genuinely change the metrics, so they pay for\n       the full pass. Scroll takes the cheap one, batched into a frame and\n       skipped entirely while the component is off screen — an instance ten\n       screens away was previously doing the same work as the visible one. */\n    const onGeometry = () => {\n      remeasure()\n      paint()\n    }\n\n    let geomRaf = 0\n    const onScroll = () => {\n      if (!onScreen || geomRaf) return\n      geomRaf = requestAnimationFrame(() => {\n        geomRaf = 0\n        reposition()\n        paint()\n      })\n    }\n\n    const io = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting\n      if (onScreen) {\n        remeasure()\n        kick()\n      } else if (raf) {\n        cancelAnimationFrame(raf)\n        raf = 0\n      }\n    })\n    io.observe(root)\n\n    remeasure()\n    paint()\n\n    if (hoverable) window.addEventListener('pointermove', onMove, { passive: true })\n    window.addEventListener('scroll', onScroll, { passive: true })\n    window.addEventListener('resize', onGeometry)\n\n    // Guarded: cleanup cannot cancel a promise, and this one runs layout.\n    let dead = false\n    document.fonts?.ready?.then(() => {\n      if (!dead) onGeometry()\n    })\n\n    return () => {\n      dead = true\n      io.disconnect()\n      window.removeEventListener('pointermove', onMove)\n      window.removeEventListener('scroll', onScroll)\n      window.removeEventListener('resize', onGeometry)\n      if (geomRaf) cancelAnimationFrame(geomRaf)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [text])\n\n  const vars = {\n    '--pt-weight': weight,\n    '--pt-bold': bold,\n    ...style,\n  }\n\n  return (\n    <span\n      ref={rootRef}\n      className={`pressure-text ${className}`.trim()}\n      style={vars}\n      /* Every glyph below is aria-hidden, so this label is the only accessible\n         name there is — and on a bare span, whose implicit role is generic,\n         ARIA prohibits aria-label and name computation skips it, leaving the\n         word announced as nothing at all. role=\"img\" is what makes the label\n         count — a bare span cannot carry one. */\n      role=\"img\"\n      aria-label={text}\n      {...rest}\n    >\n      {chars.map((ch, i) => (\n        <span\n          key={i}\n          className={`pressure-text__char${ch === ' ' ? ' is-space' : ''}`}\n          aria-hidden=\"true\"\n        >\n          <span className=\"pressure-text__glyph\">{ch === ' ' ? ' ' : ch}</span>\n        </span>\n      ))}\n    </span>\n  )\n}\n","type":"registry:component","target":"components/pressure-text/PressureText.jsx"},{"path":"src/components/PressureText/PressureText.css","content":"/* PressureText — JS writes --pt-f (pressure) and --pt-sx (its share of the\n   word's width) per letter. Everything visual is derived here. */\n\n.pressure-text {\n  --pt-weight: 200;\n  --pt-bold: 0.075; /* stroke, in em, at full pressure */\n\n  display: inline;\n  white-space: nowrap;\n  font-weight: var(--pt-weight);\n}\n\n.pressure-text__char {\n  --pt-f: 0;\n  --pt-sx: 1;\n  --pt-w: auto;\n\n  position: relative;\n  display: inline-block;\n  line-height: inherit;\n  vertical-align: baseline;\n}\n\n/* Released while measuring, so natural widths can be read back. */\n.pressure-text.is-live .pressure-text__char {\n  width: calc(var(--pt-w) * var(--pt-sx));\n}\n\n.pressure-text__glyph {\n  display: block;\n  transform: scaleX(var(--pt-sx));\n  transform-origin: 0 50%;\n  white-space: pre;\n\n  /* Growing the outline is what makes the weight MORPH rather than\n     cross-dissolve between two cuts. Divided by the stretch so a widened\n     letter does not also gain a heavier stem. */\n  -webkit-text-stroke-color: currentColor;\n  -webkit-text-stroke-width: calc(\n    var(--pt-f) * var(--pt-bold) * 1em / var(--pt-sx)\n  );\n  paint-order: stroke fill;\n}\n\n.pressure-text__char.is-space .pressure-text__glyph {\n  -webkit-text-stroke-width: 0;\n}\n","type":"registry:file","target":"components/pressure-text/PressureText.css"},{"path":"src/components/PressureText/PressureText.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement } from 'react'\n\nexport type Falloff = 'linear' | 'smooth' | 'sharp'\n\nexport interface PressureTextProps extends ComponentPropsWithoutRef<'span'> {\n  /** Rendered per glyph, and used as the element's accessible name. */\n  text?: string\n  /** Resting weight of the single cut the effect thickens. */\n  weight?: number\n  /** Peak stroke thickening under the cursor, in em. */\n  bold?: number\n  /** How much width a letter takes from its neighbours, 0..1-ish. */\n  spread?: number\n  /** Reach of the pointer's influence, in em — it scales with the type. */\n  cursorRadius?: number\n  /** Per-frame approach rate. Lower is slower to settle. */\n  smoothing?: number\n  /** How pressure fades with distance from the pointer: `linear` follows it, `smooth` eases at both ends, `sharp` squares it so only the nearest letters feel it. */\n  falloff?: Falloff\n}\n\ndeclare const PressureText: (props: PressureTextProps) => ReactElement\nexport default PressureText\n","type":"registry:lib","target":"components/pressure-text/PressureText.d.ts"}],"type":"registry:component"}