{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-select",
  "title": "Text Select",
  "description": "Typewriter with text selection: a line types word by word, then gets highlighted. A line that writes itself a word at a time — each one arriving in the accent and cooling to the foreground a beat later, so exactly one word is ever coloured — then pushes forward to full size on a baseline-pivoted scale and gets selected, the drag sweeping across under a gradient sheen with a hard caret edge.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/text-select/index.tsx",
      "content": "// Text Select · snapcn — https://snapcn.dev/docs/text/text-select\n\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/Inter\";\nimport { Fragment, useEffect, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  mixOklch,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n  withAlpha,\n} from \"@/lib/snap-cn-ui\";\n\n// Loaded through @remotion/google-fonts, never a CSS variable — a Remotion\n// bundle has none of the app's CSS, so a `var(--font-…)` gets you the right face\n// in the Player and a fallback in the mp4 (design-system rule 4).\nconst { fontFamily: SANS } = loadSans(\"normal\", {\n  weights: [\"400\", \"500\", \"600\"],\n  subsets: [\"latin\"],\n});\n\n// --- The stage -------------------------------------------------------------\n//\n// The reference recording is 810×458 — near enough 16:9 — and every number\n// below was read off its frames, so there is no second conversion to get wrong.\n\nconst REF_W = 810;\nconst REF_H = 458;\n\n// --- Measured proportions --------------------------------------------------\n\n/** The line is set tight: a hair of negative tracking and tighter word gaps. */\nconst TRACKING = -0.012;\nconst WORD_GAP = -0.07;\n/** Selection box: 0.941em tall, sitting 0.120em below the baseline. */\nconst SEL_HEIGHT = 0.941;\nconst SEL_BELOW = 0.12;\n/** …and reaching 0.063em past the type on each side, as a caret's would. */\nconst SEL_PAD = 0.063;\n\n/**\n * The sheen, read straight off the reference.\n *\n * The selection is **not a flat fill**. Sampled column by column with the glyphs\n * masked out, its blue deepens to a trough a third of the way across, opens into\n * a bright band at 59%, and closes again toward the right edge — a swing of 2.8×\n * in strength from the palest point to the deepest. Each pair is `[position,\n * multiple of the base alpha]`.\n *\n * It is anchored to the **box**, not to the page: through the whole drag the\n * bright band sits at 0.58 of whatever width the box has reached that frame\n * (measured: 0.584, 0.571, 0.580, 0.603, 0.585, 0.576, 0.589 …), which is what a\n * CSS gradient on a growing element does for free — and it is why the sheen\n * appears to travel while the selection is being pulled across.\n */\nconst SHEEN: readonly (readonly [number, number])[] = [\n  [0, 0.72],\n  [0.1, 0.95],\n  [0.19, 1.12],\n  [0.3, 1.42],\n  [0.37, 1.44],\n  [0.44, 1.22],\n  [0.51, 0.78],\n  [0.59, 0.51],\n  [0.69, 0.74],\n  [0.78, 0.88],\n  [0.89, 1.05],\n  [1, 1.2],\n];\n\n// --- Measured timeline, in seconds -----------------------------------------\n\n/** A word lands every 208ms, and each one resolves in 130ms. */\nconst WORD_STAGGER = 0.208;\nconst WORD_DUR = 0.13;\n/** The line arrives from 0.475em to the right on every word, and settles. */\nconst SHIFT_X = 0.475;\nconst SHIFT_Y = 0.085;\n/** Barely a blur — 1px at 20px type. It softens the landing, nothing more. */\nconst WORD_BLUR = 1;\n/** A word stays accent-coloured for one beat, then cools over another. */\nconst COOL_DELAY = 0.208;\nconst COOL_DUR = 0.208;\n\n/** Fitted to the ink: half the distance in the first eighth of the move. */\nconst WORD_EASE = Easing.out(Easing.poly(4));\n/** The drag. Harder still — it is a hand pulling a selection, not a tween. */\nconst SELECT_EASE = Easing.out(Easing.poly(5));\n/**\n * The push forward.\n *\n * A *moderate* decelerate on purpose. Quint- and expo-out cover 99% of their\n * travel in the first third and then crawl, and on a frame clock that crawl is\n * not a settle, it is identical frames — measured elsewhere in this repo, five\n * of fourteen frames moved under half a pixel and the type visibly stopped dead\n * partway. This curve arrives at a standstill without spending frames on travel\n * you cannot see.\n */\nconst ZOOM_EASE = Easing.bezier(0.2, 0.6, 0.35, 1);\n\n// --- Pure helpers (unit-tested) --------------------------------------------\n\nexport interface WordInk {\n  /** 0 before its cue, 1 once it has landed. */\n  opacity: number;\n  /** How far through the accent → foreground cool-down it is. */\n  cool: number;\n  blur: number;\n}\n\n/**\n * One word's arrival and its cool-down.\n *\n * The entrance is a **fade**, not the scale-and-blur of a title reveal: measure\n * the ink and its second moment barely moves (11.87px → 11.79px rms), so there\n * is no scale worth the name and about a pixel of blur. What does move is the\n * whole line — see `lineShift`.\n *\n * The colour is the other half. A word arrives in the accent, holds it for one\n * beat while it is the newest thing on the line, and cools to the foreground\n * over the next — which is what makes the eye follow the line left to right\n * instead of reading it all at once.\n */\nexport function wordInk(\n  t: number,\n  index: number,\n  o: {\n    at: number;\n    stagger: number;\n    dur: number;\n    coolDelay: number;\n    coolDur: number;\n    blur: number;\n    /** Set on the last word: it cools on the drag, not on a word that never comes. */\n    selectAt?: number;\n    selectDur?: number;\n  },\n): WordInk {\n  const cue = o.at + index * o.stagger;\n  if (t < cue) return { opacity: 0, cool: 0, blur: o.blur };\n  const p = interpolate(t, [cue, cue + o.dur], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: WORD_EASE,\n  });\n  // Every word cools one beat after it lands — one beat being exactly the\n  // stagger, so the accent walks the line. The last word has no next word to\n  // hand off to, and on the reference it cools as the selection is dragged\n  // across, in half the time the drag takes.\n  const usesDrag = o.selectAt !== undefined && o.selectAt > cue;\n  const coolFrom = usesDrag ? (o.selectAt as number) : cue + o.coolDelay;\n  const coolOver = usesDrag ? (o.selectDur as number) / 2 : o.coolDur;\n  const cool = interpolate(t, [coolFrom, coolFrom + coolOver], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  return { opacity: p, cool, blur: o.blur * (1 - p) };\n}\n\n/**\n * How far right the whole line still is, in em.\n *\n * This is the measurement that took the longest to see. When the second word\n * lands, the first one moves — but so does the second, by the *same* 10px, and\n * a line that grew a word would not do that. It is not the layout re-centring\n * and it is not the new word widening into place: the entire line arrives half\n * an em to the right on every word and settles back, which reads as the line\n * being nudged along rather than stretched.\n */\nexport function lineShift(\n  t: number,\n  words: number,\n  o: { at: number; stagger: number; dur: number },\n): number {\n  if (t < o.at) return 1;\n  const landed = Math.min(words - 1, Math.floor((t - o.at) / o.stagger));\n  const cue = o.at + landed * o.stagger;\n  return (\n    1 -\n    interpolate(t, [cue, cue + o.dur], [0, 1], {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n      easing: WORD_EASE,\n    })\n  );\n}\n\n/**\n * The push forward, as a scale.\n *\n * On the reference this is a **cut**: 215px of ink on one frame and 516px on the\n * next, with the single dropped frame between them the only place an in-between\n * could have hidden. Set `dur` to 0 and you get that exactly.\n *\n * It defaults to a ramp because a cut is the one thing a 30fps composition\n * cannot make read as *coming forward* — at that frame rate a step reads as an\n * edit, not a move. The ramp is a moderate decelerate for the reason in\n * `ZOOM_EASE`, and it is a **transform**, never an animated `font-size`:\n * font-size reflows every frame *and* still hits glyph snapping.\n */\nexport function zoomScale(\n  t: number,\n  o: { at: number; dur: number; to: number },\n): number {\n  if (o.dur <= 0) return t >= o.at ? o.to : 1;\n  return interpolate(t, [o.at, o.at + o.dur], [1, o.to], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: ZOOM_EASE,\n  });\n}\n\n/**\n * The selection, as a fraction of the line it has covered.\n *\n * A drag, not a wipe: it is 31% across in the first frame and spends the back\n * half of its time covering the last 5%. Fitted to the sweep edge on fourteen\n * frames, `easeOutQuint` over 220ms is what that is.\n */\nexport function selectionSweep(\n  t: number,\n  o: { at: number; dur: number },\n): number {\n  return interpolate(t, [o.at, o.at + o.dur], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: SELECT_EASE,\n  });\n}\n\n/** The sheen as a CSS gradient, in `color` at `alpha` times each stop. */\nexport function sheenGradient(\n  color: string,\n  alpha: number,\n  angle: number,\n  stops: readonly (readonly [number, number])[] = SHEEN,\n): string {\n  const list = stops\n    .map(\n      ([u, k]) =>\n        `${withAlpha(color, Math.min(1, alpha * k))} ${(u * 100).toFixed(1)}%`,\n    )\n    .join(\", \");\n  return `linear-gradient(${angle}deg, ${list})`;\n}\n\n/**\n * Where the travelling glint sits, as a fraction of the selection box — or\n * `null` on the frames between passes.\n *\n * The sheen above is *anchored* to the box: it stretches as the box grows,\n * which is what the reference does, and it is the right description of a\n * selection being pulled across. But it stops the instant the drag lands, and a\n * highlight that stops moving stops shining. So a second, narrow band is swept\n * across the finished box on its own clock.\n *\n * It travels **linearly**. A specular is a light source passing a surface, not\n * something that settles, and a constant velocity is also the only curve that\n * cannot spend frames on travel too small to rasterise: at the defaults the\n * band covers 58px a frame from the first frame to the last.\n *\n * The range is `[-width/2, 1 + width/2]` so the band enters from off the left\n * edge and leaves off the right, rather than being born and dying inside the\n * box.\n */\nexport function shineSweep(\n  t: number,\n  o: { at: number; dur: number; every: number; width: number },\n): number | null {\n  if (o.dur <= 0 || t < o.at) return null;\n  const since = t - o.at;\n  const u = (o.every > 0 ? since % o.every : since) / o.dur;\n  if (u > 1) return null;\n  return -o.width / 2 + u * (1 + o.width);\n}\n\n/**\n * The glint as a CSS gradient: a soft band centred on `pos`, `width` wide.\n *\n * It is **not** just a light band. A white band over a light page has almost no\n * room to work in — measured on the render, white at 0.34 over a fill sitting at\n * R 230 lifts it eight levels, against the static sheen's own ±15. The glint was\n * quieter than the gradient it was supposed to be shining on.\n *\n * So it is shaped like a real specular on a coloured gel: a bright core with\n * **deepened shoulders** either side, where the light compresses the film. The\n * shoulders are painted in the selection's own colour, and there is plenty of\n * room downward — the same measurement gives them −17. A 25-level band, moving.\n *\n * The ends are at **zero alpha of a real colour**, never the keyword\n * `transparent` — `transparent` is `rgba(0,0,0,0)`, so a gradient running to it\n * fades through black and leaves a grey bruise on either side of the band.\n */\nexport function shineGradient(o: {\n  /** The light itself, at the centre of the band. */\n  core: string;\n  /** What the shoulders deepen with — the selection's own colour. */\n  shoulder: string;\n  /** Strength of the core. */\n  alpha: number;\n  /** Strength of the shoulders, as a fraction of the core's. */\n  depth: number;\n  /** Centre of the band, as a fraction of the box. */\n  pos: number;\n  /** Width of the band, as a fraction of the box. */\n  width: number;\n  angle: number;\n}): string {\n  const half = o.width / 2;\n  const at = (x: number) => `${(x * 100).toFixed(1)}%`;\n  const edge = Math.min(1, o.alpha * o.depth);\n  const s0 = withAlpha(o.shoulder, 0);\n  const s1 = withAlpha(o.shoulder, edge);\n  return (\n    `linear-gradient(${o.angle}deg, ${s0} ${at(o.pos - half)}, ` +\n    `${s1} ${at(o.pos - half * 0.55)}, ` +\n    `${withAlpha(o.core, Math.min(1, o.alpha))} ${at(o.pos)}, ` +\n    `${s1} ${at(o.pos + half * 0.55)}, ${s0} ${at(o.pos + half)})`\n  );\n}\n\n// --- Props -----------------------------------------------------------------\n\nexport interface TextSelectProps {\n  /** The line that writes itself, one word at a time. */\n  headline?: string;\n\n  // --- Timing, in seconds. ---\n  /** When the first word lands, and the gap between words. */\n  wordAt?: number;\n  wordStagger?: number;\n  /** How long a word takes to resolve. */\n  wordDuration?: number;\n  /** How far right the line arrives on each word, and how far down, in em. */\n  shiftX?: number;\n  shiftY?: number;\n  /** How soft a word arrives, in px at `fontSize`. */\n  wordBlur?: number;\n  /** How long a word holds the accent, and how long it takes to cool. */\n  coolDelay?: number;\n  coolDuration?: number;\n  /** When the line starts coming forward, how long it takes, and how far. */\n  cutAt?: number;\n  zoomDuration?: number;\n  zoom?: number;\n  /** When the selection is dragged across, and how long the drag takes. */\n  selectAt?: number;\n  selectDuration?: number;\n\n  // --- Size and paint. ---\n  /** Type size before the push, in stage px. */\n  fontSize?: number;\n  fontWeight?: number;\n  /** Where the line's box sits down the frame, as a fraction. */\n  centerY?: number;\n  /** Tracking and word gap, in em. */\n  tracking?: number;\n  wordGap?: number;\n  /** Base strength of the selection, before the sheen multiplies it. */\n  selectionOpacity?: number;\n  /** Angle of the sheen. 90 is the reference's — straight across. */\n  selectionAngle?: number;\n  /** Override the sheen shape entirely: `[position, × base alpha]` pairs. */\n  selectionSheen?: readonly (readonly [number, number])[];\n  /** How far the selection reaches past the type on each side, in em. */\n  selectionPad?: number;\n  /** The travelling glint's colour. Light, not accent — a specular adds. */\n  shineColor?: string;\n  /** How strong the glint's core is. 0 turns the whole thing off. */\n  shineOpacity?: number;\n  /** How hard the shoulders either side deepen, as a fraction of the core. */\n  shineDepth?: number;\n  /** How wide the glint is, as a fraction of the selection. */\n  shineWidth?: number;\n  /** Seconds at which the first pass starts. Defaults to the drag landing. */\n  shineAt?: number;\n  /** How long one pass takes to cross. */\n  shineDuration?: number;\n  /** Seconds between passes. 0 shines once and stops. */\n  shineEvery?: number;\n  /** The soft wash behind the line. */\n  glow?: boolean;\n  glowColor?: string;\n  glowOpacity?: number;\n  /** The colour a word arrives in, before it cools. Defaults to `theme.primary`. */\n  accentColor?: string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /**\n   * The face this scene paints its words in — a label from `fonts.ts`\n   * (\"Inter\", \"Space Grotesk\", \"Instrument Serif\") or a CSS family you have\n   * loaded yourself. Unset, the scene keeps the face it was designed around.\n   *\n   * Overrides `theme.fontFamily`, which is how a brand kit re-skins a whole\n   * timeline from one value.\n   */\n  fontFamily?: string;\n  speed?: number;\n}\n\n// --- Main composition ------------------------------------------------------\n\n/**\n * A line that writes itself a word at a time, comes forward, and gets selected —\n * the beat before somebody copies it.\n *\n * ## Four measurements, and they are the component\n *\n * **The newest word is the accent.** Every word arrives in the accent colour,\n * holds it for exactly one beat — 208ms, the same as the stagger — and cools to\n * the foreground over the next. So there is always exactly one coloured word,\n * and it walks the line ahead of the reader.\n *\n * **The whole line arrives half an em to the right on every word**, not just the\n * new one. Both words move by the same ten pixels when the second lands, which a\n * line that had merely grown a word would not do.\n *\n * **The selection is a drag.** It is 31% across on its first frame and spends the\n * back half of its time on the last 5% — `easeOutQuint` over 220ms, fitted to the\n * sweep edge across fourteen frames — and it has a hard vertical right edge,\n * because that is what a caret dragging through text leaves behind.\n *\n * **And the selection is not a flat fill.** Sample it column by column with the\n * glyphs masked out and the blue deepens to a trough a third of the way across,\n * opens into a bright band at 59%, and closes again at the right edge: a 2.8×\n * swing. The band is anchored to the *box*, so it sits at 0.58 of whatever width\n * the selection has reached on every frame of the drag — which is why it reads as\n * a shine travelling ahead of the caret.\n *\n * ## The one place this departs from the reference\n *\n * On the recording the size change is a **cut** — 215px of ink on one frame and\n * 516px on the next, with a single dropped frame the only place an in-between\n * could have hidden. Here it defaults to a 420ms push instead, because at 30fps a\n * step reads as an edit rather than as the line coming forward. `zoomDuration={0}`\n * gives you the measured cut back.\n *\n * Because it is a push, it is a **transform pivoted on the baseline**, measured\n * off the font's real metrics rather than guessed from a line-height ratio. That\n * is not a detail: the rasteriser gives glyph origins no vertical sub-pixel\n * precision at all, so a scale that moves the baseline makes the type climb the\n * pixel grid in whole-pixel jumps — sit still, jump, sit still. Pivot on the\n * baseline and there is nothing to snap. `text-rendering: geometricPrecision`\n * turns hinting off for the same reason: hinting re-snaps every stem as the size\n * slides, and the letterforms boil.\n *\n * ## What is not measured\n *\n * The colours and the wording. The reference is a specific product with a\n * specific blue; none of that belongs in a component that ships to strangers\n * (design-system rule 5). The paint is the shadcn token set, the accent and the\n * selection are `theme.primary` unless you say otherwise, and the line is a prop.\n */\nexport function TextSelect({\n  headline = \"One command into your project.\",\n  wordAt = 0.066,\n  wordStagger = WORD_STAGGER,\n  wordDuration = WORD_DUR,\n  shiftX = SHIFT_X,\n  shiftY = SHIFT_Y,\n  wordBlur = WORD_BLUR,\n  coolDelay = COOL_DELAY,\n  coolDuration = COOL_DUR,\n  cutAt = 1.13,\n  zoomDuration = 0.42,\n  zoom = 2.39,\n  selectAt = 1.4,\n  selectDuration = 0.26,\n  fontSize = 20,\n  fontWeight = 500,\n  centerY = 0.5116,\n  tracking = TRACKING,\n  wordGap = WORD_GAP,\n  selectionOpacity = 0.207,\n  selectionAngle = 90,\n  selectionSheen = SHEEN,\n  selectionPad = SEL_PAD,\n  shineColor = \"#ffffff\",\n  shineOpacity = 0.5,\n  shineDepth = 0.55,\n  shineWidth = 0.3,\n  shineAt,\n  shineDuration = 0.62,\n  shineEvery = 1.1,\n  glow = true,\n  glowColor,\n  glowOpacity = 0.075,\n  accentColor,\n  theme,\n  mode,\n  fontFamily,\n  speed = 1,\n}: TextSelectProps) {\n  const frame = useCurrentFrame();\n  const { width, height, fps } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily ?? t.fontFamily) ?? SANS;\n  const accent = accentColor ?? t.primary;\n  const wash = glowColor ?? accent;\n\n  const now = (frame * speed) / fps;\n  const stageScale = Math.min(width / REF_W, height / REF_H);\n  const words = headline.split(/\\s+/).filter(Boolean);\n  const lineHeight = fontSize * 1.2;\n\n  // --- measure, once, behind delayRender ----------------------------------\n  //\n  // A hidden probe carries the *whole* line at the base size, so its width is\n  // the real rendered width — tracking, word-spacing and kerning included. That\n  // is a measurement `measureText` on a canvas cannot give: it knows nothing\n  // about CSS letter-spacing or word-spacing, and sizing the selection from it\n  // draws the box to a width the line does not have.\n  //\n  // The zero-sized inline-block inside it sits with its bottom edge on the text\n  // baseline, so its `offsetTop` reads the baseline off the font's real metrics.\n  const probeRef = useRef<HTMLSpanElement>(null);\n  const baselineRef = useRef<HTMLSpanElement>(null);\n  const [handle] = useState(() => delayRender(\"text-select: measure line\"));\n  const [metrics, setMetrics] = useState<{\n    lineWidth: number;\n    baseline: number;\n  } | null>(null);\n\n  useEffect(() => {\n    const probe = probeRef.current;\n    if (!probe) {\n      continueRender(handle);\n      return;\n    }\n    setMetrics({\n      lineWidth: probe.offsetWidth,\n      baseline: baselineRef.current?.offsetTop ?? lineHeight * 0.8,\n    });\n  }, [handle, lineHeight]);\n\n  // Release the render only once the measurement has re-rendered, so the very\n  // first captured frame already carries the correct geometry.\n  useEffect(() => {\n    if (metrics) continueRender(handle);\n  }, [metrics, handle]);\n\n  const lineWidth = metrics?.lineWidth ?? headline.length * fontSize * 0.5;\n  const baseline = metrics?.baseline ?? lineHeight * 0.8;\n  const boxTop = centerY * REF_H - lineHeight / 2;\n\n  // --- timeline -----------------------------------------------------------\n  const shift = lineShift(now, words.length, {\n    at: wordAt,\n    stagger: wordStagger,\n    dur: wordDuration,\n  });\n  const scale = zoomScale(now, { at: cutAt, dur: zoomDuration, to: zoom });\n  const sweep = selectionSweep(now, { at: selectAt, dur: selectDuration });\n\n  const pad = fontSize * selectionPad;\n  const selWidth = (lineWidth + pad * 2) * sweep;\n\n  // The glint runs on the finished box by default — during the drag the sheen\n  // is already travelling, because a gradient painted on a growing element\n  // travels for free, and two moving highlights at once is a busy frame.\n  const shinePos = shineSweep(now, {\n    at: shineAt ?? selectAt + selectDuration,\n    dur: shineDuration,\n    every: shineEvery,\n    width: shineWidth,\n  });\n\n  // `will-change` hands the scale to the compositor, which resamples a bitmap\n  // instead of re-rasterising real type. That is the right trade in the Player\n  // (one tab, an 8ms budget) and the wrong one in a render (parallel tabs, each\n  // inheriting a stale raster). And it is only worth paying *while the push is\n  // running* — hold the line at 2.39× under a promoted layer and you are looking\n  // at a 20px raster blown up.\n  const isRendering = getRemotionEnvironment().isRendering;\n  const pushing = now > cutAt && now < cutAt + zoomDuration;\n  const willChange =\n    !isRendering && pushing ? (\"transform\" as const) : undefined;\n\n  return (\n    <AbsoluteFill style={{ background: t.background }}>\n      <div\n        style={{\n          position: \"absolute\",\n          left: \"50%\",\n          top: \"50%\",\n          width: REF_W,\n          height: REF_H,\n          transform: `translate(-50%, -50%) scale(${stageScale})`,\n          overflow: \"hidden\",\n          background: t.background,\n        }}\n      >\n        {/* The wash. Static — it does not move, breathe or answer the push on\n            the reference, and a glow that animates under a headline reads as a\n            mistake rather than as light. It sits outside the scaled group\n            because it belongs to the page, not to the line. */}\n        {glow ? (\n          <div\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              background: `radial-gradient(${0.46 * REF_W}px ${0.36 * REF_H}px at 50% ${centerY * 100}%, ${withAlpha(wash, glowOpacity)} 0%, transparent 74%)`,\n            }}\n          />\n        ) : null}\n\n        {/* The hidden ruler: the full line at the base size, laid out but never\n            painted, so the width and the baseline are the real ones. */}\n        <span\n          ref={probeRef}\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n            visibility: \"hidden\",\n            whiteSpace: \"pre\",\n            fontFamily: face,\n            fontSize,\n            fontWeight,\n            lineHeight: `${lineHeight}px`,\n            letterSpacing: `${tracking}em`,\n            wordSpacing: `${wordGap}em`,\n          }}\n        >\n          {headline}\n          <span\n            style={{ display: \"inline-block\", width: 0, height: 0 }}\n            ref={baselineRef}\n          />\n        </span>\n\n        {/* The line and its selection, in one group so the push moves both.\n            The scale is pivoted on the **baseline**: the rasteriser gives glyph\n            origins no vertical sub-pixel precision, so any pivot that moves the\n            baseline makes the type climb the pixel grid in whole-pixel jumps. */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            top: boxTop,\n            height: lineHeight,\n            transform: `translate(${shift * fontSize * shiftX}px, ${shift * fontSize * shiftY}px) scale(${scale})`,\n            transformOrigin: `50% ${baseline}px`,\n            willChange,\n          }}\n        >\n          {/* The selection, behind the type. A hard right edge and no rounding:\n              this is a caret's drag, not a marker pen. The gradient is painted\n              on the box itself, so it stretches as the box grows — which is what\n              puts the sheen at 0.58 of the current width on every frame. */}\n          {sweep > 0 ? (\n            <div\n              style={{\n                position: \"absolute\",\n                left: REF_W / 2 - lineWidth / 2 - pad,\n                top: baseline + fontSize * SEL_BELOW - fontSize * SEL_HEIGHT,\n                width: selWidth,\n                height: fontSize * SEL_HEIGHT,\n                // Two layers, glint first: CSS paints background layers\n                // front to back, so the specular sits over the fill rather\n                // than replacing it.\n                background: [\n                  shinePos === null || shineOpacity <= 0\n                    ? null\n                    : shineGradient({\n                        core: shineColor,\n                        shoulder: accent,\n                        alpha: shineOpacity,\n                        depth: shineDepth,\n                        pos: shinePos,\n                        width: shineWidth,\n                        angle: selectionAngle,\n                      }),\n                  sheenGradient(\n                    accent,\n                    selectionOpacity,\n                    selectionAngle,\n                    selectionSheen,\n                  ),\n                ]\n                  .filter(Boolean)\n                  .join(\", \"),\n              }}\n            />\n          ) : null}\n\n          {/* The line. Only landed words are in the flow, so it grows and\n              re-centres exactly as the reference's does. Normal flow, not flex:\n              a flex container drops whitespace-only text nodes, so the spaces\n              between the words would vanish and the line would render narrower\n              than the ruler says it is. */}\n          <div\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              textAlign: \"center\",\n              fontFamily: face,\n              fontSize,\n              fontWeight,\n              lineHeight: `${lineHeight}px`,\n              letterSpacing: `${tracking}em`,\n              wordSpacing: `${wordGap}em`,\n              whiteSpace: \"pre\",\n              // Hinting bends each glyph so its stems land on whole pixels, so\n              // as the size slides every stem re-snaps and the letterforms boil.\n              // This turns it off and draws the outline as it actually is —\n              // fifteen times steadier, measured on the shape invariant.\n              textRendering: \"geometricPrecision\",\n            }}\n          >\n            {words.map((word, i) => {\n              const w = wordInk(now, i, {\n                at: wordAt,\n                stagger: wordStagger,\n                dur: wordDuration,\n                coolDelay,\n                coolDur: coolDuration,\n                blur: wordBlur,\n                ...(i === words.length - 1\n                  ? { selectAt, selectDur: selectDuration }\n                  : {}),\n              });\n              if (w.opacity <= 0) return null;\n              return (\n                // biome-ignore lint/suspicious/noArrayIndexKey: words in a headline repeat, so the index is what makes each key unique\n                <Fragment key={`${word}-${i}`}>\n                  {i > 0 ? \" \" : null}\n                  <span\n                    style={{\n                      display: \"inline-block\",\n                      color: mixOklch(accent, t.foreground, w.cool),\n                      opacity: w.opacity,\n                      filter: w.blur > 0.02 ? `blur(${w.blur}px)` : undefined,\n                    }}\n                  >\n                    {word}\n                  </span>\n                </Fragment>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/text-select.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/text/text-select?ref=cli — pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette and a bare Remotion render is black. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
