{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-highlight",
  "title": "Text Highlight",
  "description": "Animated emphasis on one span inside a static sentence — marker sweep, color shift, underline, strikethrough-replace, or shimmer.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/text-highlight/index.tsx",
      "content": "\"use client\";\n\nimport {\n  Easing,\n  getRemotionEnvironment,\n  Img,\n  interpolate,\n  interpolateColors,\n  spring,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  parseColor,\n  rgbToOklch,\n  type SnapCnTheme,\n  useSnapCnTheme,\n  withAlpha,\n} from \"@/lib/snap-cn-ui\";\n\nexport type TextHighlightPreset =\n  | \"logo-wipe\"\n  | \"marker\"\n  | \"color\"\n  | \"underline\"\n  | \"strikethrough\"\n  | \"shimmer\";\n\nexport interface TextHighlightSpringConfig {\n  damping?: number;\n  mass?: number;\n  stiffness?: number;\n}\n\n/**\n * Rewrite root-relative assets through staticFile only while rendering.\n *\n * A page serves `/logo/mark.png` from `public/`; a Remotion bundle does not —\n * it 404s, and `<Img>` turns that into a `cancelRender` that kills the whole\n * render rather than showing a broken image. Same helper as `logo-flicker`,\n * `logo-assemble` and `moodboard-reveal`; kept per-file on purpose, because a\n * registry component has to stand alone in whatever project copies it.\n */\nfunction resolveSrc(src: string): string {\n  const isLocal = src.startsWith(\"/\") && !src.startsWith(\"//\");\n  if (isLocal && getRemotionEnvironment().isRendering) {\n    return staticFile(src.replace(/^\\/+/, \"\"));\n  }\n  return src;\n}\n\n/** The lighter of two colours — a specular highlight follows the light, not the mode. */\nfunction lighterOf(a: string, b: string): string {\n  return (rgbToOklch(parseColor(a)).l ?? 0) >=\n    (rgbToOklch(parseColor(b)).l ?? 0)\n    ? a\n    : b;\n}\n\n/** Default line thickness for underline/strikethrough, derived from font size. */\nexport function defaultThickness(fontSize: number): number {\n  return Math.max(2, Math.round(fontSize * 0.08));\n}\n\n/**\n * Strikethrough timeline. The line draws over `drawDuration` frames (0–40% of\n * the emphasis window), then the old text crossfades into the replacement\n * over half that long (40–60%), then holds.\n */\nexport function strikethroughPhases(\n  startAt: number,\n  drawDuration: number,\n): { drawStart: number; drawEnd: number; fadeStart: number; fadeEnd: number } {\n  const drawEnd = startAt + drawDuration;\n  return {\n    drawStart: startAt,\n    drawEnd,\n    fadeStart: drawEnd,\n    fadeEnd: drawEnd + drawDuration * 0.5,\n  };\n}\n\n/**\n * Apparent size of something travelling toward the camera. `travel` is 0 at rest\n * and 1 at the eye. Size goes as `1 / (1 - travel)`, so it creeps for most of the\n * trip and then blows up right at the end — which is the whole character of a\n * thing coming at your face, and is not something an easing curve can fake. The\n * reference bears this out: the mark sat at 1.4x for over half the rush and then\n * went to 12x in the last few frames.\n */\nexport function perspectiveScale(travel: number, maxScale: number): number {\n  if (maxScale <= 1) return 1;\n  const p = 1 - 1 / maxScale;\n  return 1 / (1 - p * Math.min(Math.max(travel, 0), 0.9999));\n}\n\n/**\n * The mark's lateral position along the swing, in *world* units — before the projection\n * multiplies it by the apparent scale.\n *\n * `drift · travel` is the crossing: it carries the mark from the lockup out past the lens\n * on the far side. `out · sin(π · travel)` is the bow: nought at both ends, widest in the\n * middle, and it is the whole difference between a line and an arc. Subtract it and the\n * mark swings wide on the way out, comes round, and the crossing brings it back through\n * and away — one curve, no corner in it.\n *\n * It turns, and turning is not doubling back. A pendulum at the end of its swing has not\n * changed its mind; it is still going, and the moment its lateral speed passes through\n * nought it is travelling at you faster than ever. What you must not do — what a *drift*\n * does — is take it off one way and then send something else back the other.\n */\nexport function swingWorldX(\n  travel: number,\n  drift: number,\n  out: number,\n): number {\n  return drift * travel - out * Math.sin(Math.PI * travel);\n}\n\n/**\n * How much the projection magnifies the bow at its widest.\n *\n * The swing is authored in world space and what you *see* is that world path multiplied by\n * the apparent scale — so the arc is at its widest on screen not where the bow is widest\n * (halfway) but where `sin(π · travel) · perspectiveScale(travel)` peaks, out around\n * travel ≈ 0.8, by which point the mark is already several times its resting size. That is\n * the arc doing what an arc coming at your face does: it opens up as it gets near.\n *\n * This finds that peak, and it is the only reason `swingOut` can be quoted in the unit\n * anyone actually thinks in — a fraction of the frame — instead of in world units nobody\n * can picture. It is a good approximation rather than an identity: by the time the bow is\n * at its widest the rightward crossing is already pulling against it, and eats about a\n * fifth. Hence \"about\" in the prop.\n */\nexport function swingGain(maxScale: number): number {\n  let peak = 1;\n  for (let i = 1; i < 128; i++) {\n    const t = i / 128;\n    const v = Math.sin(Math.PI * t) * perspectiveScale(t, maxScale);\n    if (v > peak) peak = v;\n  }\n  return peak;\n}\n\n/**\n * How far the ink has to reach to close the frame.\n *\n * Nothing here measures the word, so nothing knows where the mark actually *is* — only\n * that the lockup is centred and the mark sits at its left end, which puts its resting\n * centre somewhere in `[0, width / 2]` for any string. The swing then carries it\n * `driftPx` further. This is the distance from the worst of those positions to the\n * furthest corner of the frame, so the last frame is flat colour for a two-letter\n * wordmark and a frame-wide one alike.\n *\n * The old code used a flat `0.85 × diagonal`, which was true only while the mark stayed\n * near the middle. Send it out to one side and the far corner is further away than that\n * — the ink stops just short, and a wedge of backdrop survives in the corner.\n */\nexport function inkReach(\n  width: number,\n  height: number,\n  driftPx: number,\n): number {\n  const nearest = driftPx; // a wordmark that fills the frame: the mark rests at its left edge\n  const furthest = width / 2 + driftPx; // an empty one: the mark rests at the centre\n  const horizontal = Math.max(\n    Math.abs(nearest),\n    Math.abs(furthest),\n    Math.abs(width - nearest),\n    Math.abs(width - furthest),\n  );\n  return Math.hypot(horizontal, height / 2) * 1.05;\n}\n\n/**\n * How much of the mark's swing the ink *does not* follow.\n *\n * Ink is emitted, not carried: the mark punches away and its colour is left in the wake.\n * Pin the disc to the mark and it flies out to the side with it, which drags the burst\n * off-centre — and then the furthest corner of the frame is the one *behind* the mark, so\n * the last thing on screen is a colour edge closing backwards, against the direction the\n * whole shot is travelling.\n *\n * Not 1, though. At nought lag the disc is born under the mark's centre knot, where there\n * is nothing to see; every step away from that walks its birth out toward the mark's flank,\n * where there is.\n */\nconst INK_LAG = 0.7;\n\n/** The wipe: the mark leaves and arrives at a standstill, and actually arrives. */\nconst WIPE_EASE = Easing.bezier(0.2, 0.6, 0.35, 1);\n/**\n * The rush — this is the *travel*, not the size. Measured off the reference it is\n * very nearly a constant approach with a little acceleration off the mark; all the\n * drama is the perspective above, not the curve.\n */\nconst RUSH_EASE = Easing.bezier(0.45, 0.2, 0.45, 1);\n\n/**\n * Default mark: a four-point sparkle. Each quadrant is a single curve pulled in\n * toward the centre, which is what gives the points their concave flanks.\n * Override it with the `logo` prop — anything that renders will do.\n */\nfunction SparkMark({ size, color }: { size: number; color: string }) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 100 100\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      style={{ display: \"block\" }}\n    >\n      <title>mark</title>\n      <path\n        d=\"M50 0C50 27.6 27.6 50 0 50c27.6 0 50 22.4 50 50 0-27.6 22.4-50 50-50-27.6 0-50-22.4-50-50Z\"\n        fill={color}\n      />\n    </svg>\n  );\n}\n\nexport interface TextHighlightProps {\n  before?: string;\n  highlight: string;\n  after?: string;\n  preset?: TextHighlightPreset;\n  /** Overrides the design system's `foreground`. */\n  baseColor?: string;\n  /** Overrides the design system's `primary`. */\n  accentColor?: string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /**\n   * Final color of the highlighted span. Defaults to `accentColor` for the\n   * \"color\" and \"strikethrough\" presets and to `baseColor` otherwise.\n   */\n  highlightedTextColor?: string;\n  /** Strikethrough only: text that crossfades in over the struck span. */\n  replaceWith?: string;\n  /** Frame at which the emphasis starts. */\n  startAt?: number;\n  /** Frames the draw/sweep takes. */\n  drawDuration?: number;\n  springConfig?: TextHighlightSpringConfig;\n  /** Underline/strikethrough line thickness. Defaults to round(fontSize * 0.08). */\n  thickness?: number;\n  /** Shimmer only: color of the sweeping shine. */\n  shineColor?: string;\n\n  /** logo-wipe only: the mark. Defaults to a four-point sparkle in `accentColor`. */\n  logo?: React.ReactNode;\n  /**\n   * logo-wipe only: URL of a logo image, the easy way to pass a mark. Loaded\n   * through Remotion's `<Img>`, so the frame waits for it instead of rendering a\n   * hole. A root-relative path (`/logo/mark.png`) is served by your dev server on\n   * a page and by `staticFile()` in a render — this resolves it for both, so you\n   * do not have to. An absolute URL is passed through untouched.\n   *\n   * The rush blows this up to fill the frame — around 40x. Ship art with the\n   * resolution to survive that (a few hundred pixels will turn to mush), or an\n   * SVG via `logo`, which will not. `logo` wins if you pass both.\n   */\n  logoSrc?: string;\n  /** logo-wipe only: mark size as a multiple of `fontSize`. */\n  logoScale?: number;\n  /** logo-wipe only: turns the mark spins while it wipes the word open. */\n  spinTurns?: number;\n  /** logo-wipe only: frames the finished lockup rests before the mark rushes you. */\n  holdDuration?: number;\n  /** logo-wipe only: frames the rush takes. */\n  rushDuration?: number;\n  /**\n   * logo-wipe only: how many times its resting size the mark reaches by the end of the\n   * rush.\n   *\n   * It has to be big enough that the mark's own body **overruns the frame** — at the\n   * defaults it starts about 59px across and 42x takes it past 2,400px against a 1,280px\n   * frame. That is not showmanship, it is what makes the ending possible at all. The ink\n   * has to close over the *left* of the frame while the mark is travelling *right*, and\n   * the only way to do that without something sweeping leftward across the screen — a\n   * wall, a roller, call it what you like; it is a second momentum and it looks cheap —\n   * is for the mark to already be lying over that ground when its ink spreads. Shrink\n   * this and the ending has to fetch its colour from somewhere off-stage.\n   *\n   * Note it is the mark's *box* that overruns the frame, not its ink. A logo with gaps in\n   * it scales its holes by exactly as much, so the backdrop keeps showing through them\n   * however big it gets — measured on the bundled clover, solid-ink coverage would take\n   * 396x. That is what the ink is for.\n   */\n  rushScale?: number;\n  /**\n   * logo-wipe only: where the swing *ends up*, as a fraction of the composition width.\n   * Positive is right — the mark crosses the frame and goes out past your shoulder.\n   *\n   * This is the crossing, not the arc. `swingOut` is the arc.\n   */\n  rushDrift?: number;\n  /**\n   * logo-wipe only: about how far the mark bows out to the *left* on its way, at the widest\n   * point of the arc, as a fraction of the composition width. (About: the rightward crossing\n   * is already pulling against the bow by the time it peaks, and eats roughly a fifth of it.)\n   *\n   * This is what makes the rush a swing instead of a slide. The mark leaves the lockup,\n   * arcs wide, comes round, and passes the lens on the other side — a semicircle from the\n   * screen to your eye, and it is a single curve with no corner in it.\n   *\n   * It is not free decoration. The mark has to be *lying over the left of the frame* when\n   * its ink bursts, or the colour has to come and find that ground from somewhere, which\n   * means something sweeping backwards across the screen. The bow is what puts it there:\n   * it is still left of centre at the moment it goes off, so the ink is born on the left\n   * and opens out to the right, following the mark out. Take the bow away and the ink has\n   * to close leftward against everything else in the shot.\n   *\n   * Keep it under about a third of the width. The mark rests at the left end of a centred\n   * lockup, so a long wordmark starts it near the left edge already, and a big enough bow\n   * will walk it off the side — and a mark that leaves the frame has to come back, which is\n   * the one thing this ending must never do.\n   */\n  swingOut?: number;\n  /**\n   * logo-wipe only: the colour the mark's ink floods out in, and the colour the frame\n   * ends on. Defaults to `accentColor`; set it to your mark's own colour if you pass a\n   * `logoSrc` that is not the accent. Set it to `\"transparent\"` to end on the giant mark\n   * with the backdrop still showing through it.\n   *\n   * The ink is a **shape** and it comes **out of the mark** — a hard-edged disc born\n   * inside the mark's own centre knot, where it cannot be seen, which then outruns it.\n   * Both of those are load-bearing. Slide a coloured rectangle in from an edge instead and\n   * it reads as paint being rolled onto the screen by something off-stage. Fade one up to\n   * opaque and you get a half-transparent film lying over the backdrop for several frames,\n   * which reads as nothing but \"the screen turned blue\". Ink coming out of the mark is the\n   * mark *arriving*, which is the thing that is actually happening.\n   *\n   * It is keyed to the mark's size, never to the clock, so it cannot begin before the mark\n   * already overruns the frame.\n   */\n  coverColor?: string;\n\n  fontSize?: number;\n  fontWeight?: number;\n  speed?: number;\n  className?: string;\n}\n\nexport function TextHighlight({\n  before = \"\",\n  highlight,\n  after = \"\",\n  preset = \"logo-wipe\",\n  baseColor,\n  accentColor,\n  theme,\n  mode,\n  highlightedTextColor,\n  replaceWith,\n  startAt = 6,\n  drawDuration = 14,\n  springConfig,\n  thickness,\n  shineColor,\n  logo,\n  logoSrc,\n  logoScale = 1.05,\n  spinTurns = 1,\n  holdDuration = 12,\n  rushDuration = 24,\n  rushScale = 42,\n  rushDrift = 0.55,\n  swingOut = 0.25,\n  coverColor,\n  fontSize = 56,\n  fontWeight = 600,\n  speed = 1,\n  className,\n}: TextHighlightProps) {\n  const frame = useCurrentFrame() * speed;\n  const { fps, width, height } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const base = baseColor ?? t.foreground;\n  const accent = accentColor ?? t.primary;\n  // A gloss sweep is white light in both modes, so it takes whichever of the\n  // page's two extremes is actually the lighter one rather than a fixed token.\n  const shine = shineColor ?? lighterOf(t.card, t.foreground);\n\n  const emphasisColor =\n    highlightedTextColor ??\n    (preset === \"color\" || preset === \"strikethrough\" ? accent : base);\n  const lineThickness = thickness ?? defaultThickness(fontSize);\n\n  // High-damping sweep (no bounce) shared by marker + underline.\n  const sweep = spring({\n    frame: frame - startAt,\n    fps,\n    config: { damping: 18, ...springConfig },\n    durationInFrames: drawDuration,\n  });\n\n  const textStyle: React.CSSProperties = {\n    fontSize,\n    fontWeight,\n    color: base,\n    letterSpacing: \"-0.02em\",\n    lineHeight: 1.25,\n    fontFamily:\n      \"var(--font-geist-sans), Inter, -apple-system, BlinkMacSystemFont, sans-serif\",\n    whiteSpace: \"pre-wrap\",\n    textAlign: \"center\",\n    maxWidth: \"80%\",\n  };\n\n  if (preset === \"logo-wipe\") {\n    // 0 → 1 as the mark travels from the word's right end to its left end.\n    const wipe = interpolate(frame, [startAt, startAt + drawDuration], [0, 1], {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n      easing: WIPE_EASE,\n    });\n    // The wipe edge, as a fraction of the word's own width. It is the one number\n    // the whole reveal turns on: the word is clipped to everything right of it,\n    // and the mark rides it. Everything is a percentage of the word's box, so\n    // nothing here needs measuring.\n    const edge = (1 - wipe) * 100;\n\n    const rushStart = startAt + drawDuration + holdDuration;\n    const travel = interpolate(\n      frame,\n      [rushStart, rushStart + rushDuration],\n      [0, 1],\n      {\n        extrapolateLeft: \"clamp\",\n        extrapolateRight: \"clamp\",\n        easing: RUSH_EASE,\n      },\n    );\n\n    const markSize = fontSize * logoScale;\n    // How big the mark is *right now*, as a multiple of its resting size.\n    const rushed = perspectiveScale(travel, rushScale);\n    const markBox = markSize * rushed;\n    const diagonal = Math.hypot(width, height);\n\n    // The swing, and it is built where it belongs: in *world* space, then projected.\n    //\n    // The world path is a crossing with a bow pulled out of it (`swingWorldX`), and the\n    // projection multiplies it by the apparent scale. That one multiply is the whole\n    // effect. The same world path is a nudge while the mark is far away and a whip across\n    // the frame once it is at the lens, which is what an arc coming at your face actually\n    // does — it opens up as it arrives. Author the offset in screen pixels instead and no\n    // curve you pick will fix it: you get a shape sliding around on glass, in front of the\n    // scene rather than through it. I shipped that too.\n    //\n    // `swingGain` is what lets `swingOut` be quoted as a fraction of the frame: it is the\n    // factor the projection applies to the bow at its widest, so dividing it back out here\n    // means the arc lands exactly as wide on screen as the prop asked for.\n    const worldDrift = (rushDrift * width) / rushScale;\n    const worldBow = (swingOut * width) / swingGain(rushScale);\n    const swingPx = rushed * swingWorldX(travel, worldDrift, worldBow);\n\n    // The ink, keyed to the mark's size — never to the clock, so it cannot begin while\n    // the mark is small.\n    //\n    // Where the window *opens* is the whole ballgame, and the honest answer came out of\n    // rendering the frames rather than reasoning about them. Under a perspective rush the\n    // apparent size goes as 1/(1-travel), so every size threshold near the top is crossed\n    // within a frame or two of every other one, and the disc has to lay down the entire\n    // frame in whatever frames are left. Measured, on the fill it puts on screen:\n    //\n    //   gate at 1.00 × diagonal    52 → 88 → 100        a cut, in two frames\n    //   gate at 0.50 × diagonal     6 → 54 → 83 → 100   still a cut, in three\n    //   gate at 0.25 × diagonal     4 → 17 → 49 → 77 → 93 → 100\n    //\n    // So it opens at a quarter of the diagonal, which sounds far too early and is not:\n    // the disc is *born inside the mark's own centre knot* (radius = 3% of the box) where\n    // there is nothing to see, and it does not clear the mark's ink for another three\n    // frames. It cannot look like a blue screen with a logo on it, because at the moment\n    // it starts it is smaller than the logo.\n    const flood = interpolate(\n      markBox,\n      [diagonal * 0.55, diagonal * 1.45],\n      [0, 1],\n      { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n    );\n    // Where the ink actually sits, which is behind the mark — so the reach it has to\n    // manage is measured from there, not from wherever the mark has whipped off to.\n    const inkPx = swingPx * (1 - INK_LAG);\n    const floodRadius =\n      flood <= 0\n        ? 0\n        : interpolate(\n            flood,\n            [0, 1],\n            [\n              markBox * 0.03,\n              inkReach(width, height, rushDrift * width * (1 - INK_LAG)),\n            ],\n          );\n\n    return (\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          // The mark grows well past the frame. Nothing may crop it.\n          overflow: \"visible\",\n        }}\n      >\n        {/* The lockup. The left padding reserves the mark's slot, so flex centres\n            [mark][word] as one object rather than centring the word alone and\n            leaving the mark hanging off the side. */}\n        <div\n          className={className}\n          style={{\n            position: \"relative\",\n            paddingLeft: markSize + fontSize * 0.3,\n          }}\n        >\n          <span\n            style={{\n              ...textStyle,\n              position: \"relative\",\n              display: \"inline-block\",\n              maxWidth: undefined,\n              whiteSpace: \"nowrap\",\n            }}\n          >\n            {/* Only what is right of the edge exists yet. A clip, not a fade —\n                the letters arrive already solid, the way a lid sliding off a\n                thing reveals it. Percentages resolve against this span's own box,\n                which is what makes the whole reveal measurement-free. */}\n            <span\n              style={{\n                display: \"block\",\n                clipPath: `inset(0 0 0 ${edge}%)`,\n                // The word does not leave — it gets swallowed. The mark blows up straight\n                // through it on its way out, and the ink takes what is left, so nothing\n                // fades: a word that is *erased* is worth more than a word that was\n                // already gone. It also means the frame is never empty, which is what\n                // fading it cost when the mark went with it.\n                //\n                // The reference runs the wordmark from the accent into the base\n                // colour, left to right. Painted through the glyphs, so it moves\n                // with them and needs no second element.\n                backgroundImage: `linear-gradient(90deg, ${accent}, ${emphasisColor})`,\n                backgroundClip: \"text\",\n                WebkitBackgroundClip: \"text\",\n                color: \"transparent\",\n              }}\n            >\n              {highlight}\n            </span>\n\n            {/* The mark. `left` is a percentage of the word's box, so it rides the\n                wipe edge exactly; `-100%` of its own width parks it just clear of\n                the letters, which is where it comes to rest as the logo.\n\n                Two nested elements, and the nesting is load-bearing. The outer one\n                *places* the mark — including the sideways swing of the rush, which\n                has to live out here in frame pixels. Put the swing inside the\n                scale and it would be multiplied by it, so a 200px swing becomes a\n                2,800px one the moment the mark is 14x. The inner one only spins\n                and grows, about its own centre. */}\n            <div\n              aria-hidden=\"true\"\n              style={{\n                position: \"absolute\",\n                top: \"50%\",\n                left: `${edge}%`,\n                // `max-content`, and it is not optional. This box is absolutely\n                // positioned at `left: 100%` for most of the wipe, so the width\n                // available to it is (containing block - left) = *zero*, and an\n                // auto-width box there is shrink-to-fit: it asks its content for a\n                // minimum width and collapses to that. An <svg width={58}> answers\n                // \"58\" and holds it open. An <img> under Tailwind's preflight\n                // (`img { max-width: 100% }`) answers \"I can be 0\" — so the box\n                // collapses, 100% of 0 is 0, and the mark renders zero pixels wide.\n                // Which is a blank frame and a Player with nothing to draw.\n                //\n                // It does not reproduce in a Remotion render: a bundle has no\n                // preflight. It reproduces in every project that installs this.\n                width: \"max-content\",\n                translate: `calc(-100% - ${fontSize * 0.16}px + ${swingPx}px) -50%`,\n              }}\n            >\n              {/* The last beat, and it is a shape — not an opacity, and not something\n                  arriving from off-stage.\n\n                  The mark cannot close its own holes by growing, because they grow with\n                  it. So its ink floods out from the middle of it: a disc, in the mark's\n                  own colour, centred on the mark and carried along by it. It starts life\n                  *inside* the mark's centre knot, where it cannot be seen, and then\n                  outruns it.\n\n                  This is also the only way the frame can end on flat colour while the mark\n                  is on its way *out to the right*. The colour has to close over the left of\n                  the frame too, and there are exactly two ways to get it there: something\n                  travelling leftwards across the screen — which is a second momentum, and\n                  it reads as paint being rolled on by an off-stage hand — or the mark\n                  already lying over that ground when its ink spreads. Hence the size: by\n                  the time the ink moves, the mark's arms are past all four corners, and\n                  what closes the left of the frame is the gaps between them filling in.\n                  That is ink arriving, and it is the only thing that reads as one gesture.\n\n                  Both ends are keyed to the mark's *size*, never to the clock, so it cannot\n                  start before the mark has already overrun the frame. */}\n              <div\n                aria-hidden=\"true\"\n                style={{\n                  position: \"absolute\",\n                  left: \"50%\",\n                  top: \"50%\",\n                  // Back out the share of the swing the ink does not follow (INK_LAG),\n                  // in frame pixels — the mark pulls ahead of its own colour.\n                  translate: `calc(-50% - ${swingPx - inkPx}px) -50%`,\n                  borderRadius: \"50%\",\n                  background: coverColor ?? accent,\n                  width: 2 * floodRadius,\n                  height: 2 * floodRadius,\n                }}\n              />\n              <div\n                style={{\n                  transformOrigin: \"50% 50%\",\n                  rotate: `${spinTurns * 360 * wipe + 30 * travel}deg`,\n                  // Grows into the lockup, rests, then comes at you. The two are\n                  // separate — the first clamps once the wipe lands, the second is\n                  // 1 until the rush starts — so they simply multiply.\n                  scale: `${\n                    interpolate(wipe, [0, 1], [0.55, 1], {\n                      extrapolateLeft: \"clamp\",\n                      extrapolateRight: \"clamp\",\n                    }) * rushed\n                  }`,\n                }}\n              >\n                {/* `logo` (any node — an SVG scales forever) beats `logoSrc` (a\n                    URL, the easy path) beats the built-in sparkle. */}\n                {logo ??\n                  (logoSrc ? (\n                    // Remotion's <Img>, not <img>: it holds the frame until the\n                    // image has decoded. A bare <img> renders frame 0 as a hole\n                    // and you find out from someone else's screenshot.\n                    <Img\n                      src={resolveSrc(logoSrc)}\n                      alt=\"\"\n                      style={{\n                        display: \"block\",\n                        width: markSize,\n                        height: markSize,\n                        // Tailwind's preflight caps every img at `max-width: 100%`.\n                        // Here that is a bug, not a safety net — see the note on\n                        // the placement box above. Opt out explicitly.\n                        maxWidth: \"none\",\n                        objectFit: \"contain\",\n                      }}\n                    />\n                  ) : (\n                    <SparkMark size={markSize} color={accent} />\n                  ))}\n              </div>\n            </div>\n          </span>\n        </div>\n      </div>\n    );\n  }\n\n  let highlightNode: React.ReactNode;\n\n  if (preset === \"marker\") {\n    highlightNode = (\n      <span style={{ position: \"relative\", display: \"inline-block\" }}>\n        <span\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            inset: \"-0.02em -0.12em\",\n            background: withAlpha(accent, 0.16),\n            borderRadius: \"0.12em\",\n            transformOrigin: \"left center\",\n            transform: `scaleX(${sweep})`,\n            zIndex: 0,\n          }}\n        />\n        <span\n          style={{\n            position: \"relative\",\n            zIndex: 1,\n            color: interpolateColors(\n              interpolate(sweep, [0.5, 0.85], [0, 1], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              }),\n              [0, 1],\n              [base, emphasisColor],\n            ),\n          }}\n        >\n          {highlight}\n        </span>\n      </span>\n    );\n  } else if (preset === \"color\") {\n    highlightNode = (\n      <span\n        style={{\n          color: interpolateColors(\n            interpolate(frame, [startAt, startAt + drawDuration], [0, 1], {\n              extrapolateLeft: \"clamp\",\n              extrapolateRight: \"clamp\",\n            }),\n            [0, 1],\n            [base, emphasisColor],\n          ),\n        }}\n      >\n        {highlight}\n      </span>\n    );\n  } else if (preset === \"underline\") {\n    highlightNode = (\n      <span style={{ position: \"relative\", display: \"inline-block\" }}>\n        <span\n          style={{\n            color: interpolateColors(\n              interpolate(sweep, [0.5, 0.85], [0, 1], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              }),\n              [0, 1],\n              [base, emphasisColor],\n            ),\n          }}\n        >\n          {highlight}\n        </span>\n        <span\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            bottom: \"-0.06em\",\n            height: lineThickness,\n            background: accent,\n            borderRadius: lineThickness / 2,\n            transformOrigin: \"left center\",\n            transform: `scaleX(${sweep})`,\n          }}\n        />\n      </span>\n    );\n  } else if (preset === \"strikethrough\") {\n    const phases = strikethroughPhases(startAt, drawDuration);\n    highlightNode = (\n      <span\n        style={{\n          display: \"inline-grid\",\n          verticalAlign: \"baseline\",\n          justifyItems: \"center\",\n        }}\n      >\n        <span\n          style={{\n            gridArea: \"1 / 1\",\n            position: \"relative\",\n            whiteSpace: \"pre\",\n            opacity: interpolate(\n              frame,\n              [phases.fadeStart, phases.fadeEnd],\n              [1, 0],\n              { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n            ),\n          }}\n        >\n          {highlight}\n          <span\n            aria-hidden\n            style={{\n              position: \"absolute\",\n              left: 0,\n              top: \"50%\",\n              height: lineThickness,\n              width: `${interpolate(\n                frame,\n                [phases.drawStart, phases.drawEnd],\n                [0, 100],\n                { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n              )}%`,\n              background: accent,\n              transform: \"translateY(-50%)\",\n              borderRadius: lineThickness / 2,\n            }}\n          />\n        </span>\n        <span\n          style={{\n            gridArea: \"1 / 1\",\n            whiteSpace: \"pre\",\n            color: emphasisColor,\n            opacity: interpolate(\n              frame,\n              [phases.fadeStart, phases.fadeEnd],\n              [0, 1],\n              { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n            ),\n            transform: `translateY(${interpolate(\n              frame,\n              [phases.fadeStart, phases.fadeEnd],\n              [8, 0],\n              { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n            )}px)`,\n          }}\n        >\n          {replaceWith ?? highlight}\n        </span>\n      </span>\n    );\n  } else {\n    // shimmer\n    highlightNode = (\n      <span style={{ position: \"relative\", display: \"inline-block\" }}>\n        <span style={{ color: emphasisColor }}>{highlight}</span>\n        <span\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            color: \"transparent\",\n            backgroundClip: \"text\",\n            WebkitBackgroundClip: \"text\",\n            backgroundImage: `linear-gradient(110deg, transparent 30%, ${shine} 50%, transparent 70%)`,\n            backgroundSize: \"200% 100%\",\n            backgroundPosition: `${interpolate(\n              frame,\n              [startAt, startAt + drawDuration],\n              [200, -100],\n              { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n            )}% 50%`,\n          }}\n        >\n          {highlight}\n        </span>\n      </span>\n    );\n  }\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n      }}\n    >\n      <span className={className} style={textStyle}>\n        {before}\n        {highlightNode}\n        {after}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/text-highlight.tsx"
    }
  ],
  "type": "registry:component"
}