{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "check-cycle",
  "title": "Check Cycle",
  "description": "Rotating words beside a checkbox: a sentence that finishes itself several ways. \"Create your own\" sits left of a ticked shadcn-style checkbox and the word to its right rolls up out of a slot — Intros, Demos, Launches, Changelogs, Tutorials, And more — then the line steps back a little and rests. Every number came off a recording, frame by frame: the two words in the slot move as one strip on the in-out cubic over exactly half a second, a roll every twenty frames. The type moves by fractions of a pixel instead of the whole-pixel steps a browser snaps text to.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/check-cycle/index.tsx",
      "content": "// Check Cycle · snapcn — https://snapcn.dev/docs/text/check-cycle\n\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/Inter\";\nimport { type CSSProperties, useEffect, useId, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n} from \"@/lib/snap-cn-ui\";\n\n/**\n * A sentence that finishes itself over and over: \"Create your own ☑ Intros\",\n * then Demos, Launches, Changelogs, Tutorials, \"And more\" — each word rolling\n * up out of a slot beside a ticked checkbox — then the line steps back a little\n * and rests.\n *\n * ## Everything here was measured off a recording, frame by frame\n *\n * The numbers below are not taste. They were recovered from a 2.17 second,\n * 718 × 394 screen capture (65 frames) by fitting renders of this scene to it\n * in BT.709 Y′CbCr, through a model of the capture's own resampling kernel:\n *\n *   - the recording is a 30fps video captured at a jittery ~30Hz: its\n *     timestamps wander ±8ms, but snapped to a 30fps grid every capture frame\n *     is exactly one source frame — none doubled, none dropped. Fitted on that\n *     clock instead of the stamps, every curve below came out clean.\n *   - the two words in the slot move as ONE strip — the gap between the word\n *     leaving and the word arriving is constant to 0.2px on every frame of a\n *     roll — so it is one curve, not an ease-in exit and an ease-out entrance.\n *     It is `cubic-bezier(0.65, 0, 0.35, 1)` over exactly 0.5s, a roll every\n *     20 frames: 0.076px RMS across both rolls. The runner-up, quart in-out,\n *     is 0.10; the CSS `ease-in-out` is 0.69.\n *   - the strip does not travel the same distance every time: 44.19px, then\n *     42.27px. Both are shipped, in `TRAVEL`, and cycle.\n *\n * ## The type is Inter 3, and that was measured too\n *\n * Fingerprinted on the five words the recording shows, at matched width:\n * Inter 500 is 0.45px RMS from the reference, the next face (Be Vietnam Pro)\n * 0.48, Helvetica 0.50, Geist 0.61. SF Pro, fitted outline against outline,\n * is 37% worse than Inter. And it is Inter *3*: the same fit against Inter\n * 3.19 is 12% closer across all four lines than against the Inter 4 Google\n * serves today, and a third closer on the lead-in alone. So 3.19 is loaded\n * (OFL, from fontsource), with Google's Inter behind it as the fallback.\n *\n * ## The checkbox sits still, and everything is laid out from it\n *\n * The lead-in is right-aligned to the box and the word left-aligned from it, so\n * the sentence survives new copy: the box never moves, only the words around it\n * change width — which is also what the reference does when \"Docs\" becomes\n * \"And more\".\n */\n\nconst { fontFamily: SANS } = loadSans(\"normal\", {\n  weights: [\"500\"],\n  subsets: [\"latin\"],\n});\n\nconst FACE = \"Check Cycle Inter\";\nconst FACE_URL =\n  \"https://cdn.jsdelivr.net/npm/@fontsource/inter@4.5.15/files/inter-latin-500-normal.woff2\";\nlet faceLoad: Promise<unknown> | null = null;\nfunction loadFace(): Promise<unknown> {\n  if (faceLoad) return faceLoad;\n  if (typeof FontFace === \"undefined\") {\n    faceLoad = Promise.resolve();\n    return faceLoad;\n  }\n  const face = new FontFace(FACE, `url(${FACE_URL}) format(\"woff2\")`, {\n    weight: \"500\",\n  });\n  document.fonts.add(face);\n  // A face that fails to load leaves Google's Inter to paint — close, not exact.\n  faceLoad = face.load().catch(() => undefined);\n  return faceLoad;\n}\n\n/* ─────────────────────────────────────────────────────────────────────────\n   The scene, in the reference's own 718 × 394 pixels\n   ───────────────────────────────────────────────────────────────────────── */\n\nexport const REF_W = 718;\nexport const REF_H = 394;\n\n/** Type size of both halves of the sentence. Fitted per line: 21.62–21.97. */\nexport const SIZE = 21.78;\n/** Where the lead-in's advance ends — it is right-aligned to this x. */\nexport const LEAD_END = 340.04;\n/** The lead-in's baseline. */\nexport const LEAD_BASE = 203.01;\n/** Where the word's advance starts. */\nexport const WORD_X = 379.1;\n/** The word's baseline: 1.5px below the lead-in's, on every word shown. */\nexport const WORD_BASE = 204.97;\n\n/** The checkbox, as x, y, width, height, corner radius. */\nexport const BOX = { x: 345.11, y: 183.71, w: 27.03, h: 27.59, r: 6.53 };\n/** The tick, as three points in the box's own coordinates, and its stroke. */\nexport const TICK: readonly (readonly [number, number])[] = [\n  [5.468, 13.785],\n  [11.827, 18.695],\n  [20.72, 8.888],\n];\nexport const TICK_W = 2.72;\n\n/**\n * The slot the words roll through: a hard clip, top and bottom. Fitted as a\n * feathered mask across eight frames with both words visible — the feathers\n * came back at 2.1px and 1.0px, which is the capture's blur, not a fade.\n */\nexport const SLOT = [171.37, 223.62] as const;\n\n/** How far the strip travels on each roll, in turn. */\nexport const TRAVEL = [44.19, 42.27] as const;\n/** When the first roll starts, how often a roll comes, and how long it takes. */\nexport const ROLL_AT = 0.0324;\nexport const ROLL_EVERY = 0.667;\nexport const ROLL_FOR = 0.5;\n/** The roll's curve: the in-out cubic. */\nexport const ROLL_EASE = Easing.bezier(0.65, 0, 0.35, 1);\n\n/** How long the last word holds after its roll starts, before the step back. */\nexport const HOLD = 1.004;\n\n/**\n * The step back: the line shrinks about the frame's centre to `ZOOM_TO` and\n * rests there. The recording falls all the way to nothing; this keeps only the\n * start of that — a settle, not an exit.\n */\nexport const PIVOT = [359.14, 197.52] as const;\nexport const ZOOM_TO = 0.8;\nexport const ZOOM_FOR = 0.6;\nexport const ZOOM_EASE = Easing.bezier(0.65, 0, 0.35, 1);\n/** Frames' worth of rest at the smaller size before the scene ends. */\nexport const ZOOM_REST = 0.2;\n\nconst unit = (v: number) => Math.min(1, Math.max(0, v));\n\n/** Strip travel at `now` seconds, for a list of `n` words. */\nexport function stripAt(now: number, n: number): number {\n  let s = 0;\n  for (let k = 0; k < n - 1; k++) {\n    const p = unit((now - (ROLL_AT + k * ROLL_EVERY)) / ROLL_FOR);\n    s += (TRAVEL[k % TRAVEL.length] ?? 0) * ROLL_EASE(p);\n  }\n  return s;\n}\n\n/** Where word `k` rests on the strip. */\nexport function restOf(k: number): number {\n  let c = 0;\n  for (let j = 0; j < k; j++) c += TRAVEL[j % TRAVEL.length] ?? 0;\n  return c;\n}\n\n/** When the step back starts, for `n` words. */\nexport function zoomStart(n: number): number {\n  return ROLL_AT + Math.max(0, n - 2) * ROLL_EVERY + HOLD;\n}\n\n/** Camera scale at `now`: 1, easing down to `ZOOM_TO`. */\nexport function zoomAt(now: number, n: number): number {\n  return 1 - (1 - ZOOM_TO) * ZOOM_EASE(unit((now - zoomStart(n)) / ZOOM_FOR));\n}\n\n/** Seconds from the first frame to the end: rolls, hold, step back, rest. */\nexport function sceneLength(n: number): number {\n  return zoomStart(n) + ZOOM_FOR + ZOOM_REST;\n}\n\n/**\n * A line of type that can sit — and move — a fraction of a pixel.\n *\n * Chrome rounds a glyph run's baseline to a whole device pixel, in HTML and\n * SVG alike, with or without `will-change`. On the roll's slow lift-off that\n * is measured, not theoretical: the word held still for four frames and then\n * jumped a pixel, three times, before it started to move at all.\n *\n * So the line is painted twice, one device pixel apart, both copies placed ON\n * a whole pixel so there is nothing left to round, and cross-faded by the\n * fraction in between. `plus-lighter` adds the two copies' coverage instead of\n * stacking one over the other, so the blend is exactly linear: a baseline at\n * 0.25 of a pixel reads as 0.25, and the ink's centroid moves in steps of\n * 1/8 px when it is asked to (measured). That is the resample a video of this\n * line would have — which is what the reference is.\n */\nfunction Line({\n  x,\n  y,\n  dev,\n  anchor,\n  style,\n  children,\n}: {\n  x: number;\n  y: number;\n  dev: { a: number; b: number };\n  anchor?: \"end\";\n  style: CSSProperties;\n  children: string;\n}) {\n  const d = dev.a * y + dev.b;\n  const lo = Math.floor(d);\n  const f = d - lo;\n  // A thousandth of a pixel inside the row, so the rounding has nowhere to go.\n  const at = (row: number) => (row + 1e-3 - dev.b) / dev.a;\n  return (\n    <g style={{ isolation: \"isolate\" }}>\n      {[\n        [lo, 1 - f],\n        [lo + 1, f],\n      ].map(([row, w]) => (\n        <text\n          key={row}\n          x={x}\n          y={at(row ?? 0)}\n          textAnchor={anchor}\n          style={{ ...style, opacity: w, mixBlendMode: \"plus-lighter\" }}\n        >\n          {children}\n        </text>\n      ))}\n    </g>\n  );\n}\n\nexport interface CheckCycleProps {\n  /** The part of the sentence that never changes. */\n  headline?: string;\n  /** The words that finish it, in order, comma separated. */\n  words?: string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  fontFamily?: string;\n}\n\nexport function CheckCycle({\n  headline = \"Create your own\",\n  words = \"Intros, Demos, Launches, Changelogs, Tutorials, And more\",\n  theme,\n  mode = \"light\",\n  fontFamily = \"Default\",\n}: CheckCycleProps) {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily) ?? `\"${FACE}\", ${SANS}`;\n  const [handle] = useState(() => delayRender(\"check-cycle: loading Inter 3\"));\n  useEffect(() => {\n    loadFace().then(() => continueRender(handle));\n  }, [handle]);\n  const id = useId().replace(/:/g, \"\");\n\n  const list = words\n    .split(\",\")\n    .map((w) => w.trim())\n    .filter(Boolean);\n  const n = Math.max(1, list.length);\n  const now = frame / fps;\n\n  /**\n   * One reference pixel, and the offset that centres the layout. Scaled to\n   * whichever side is tighter, so a render at the reference's dimensions *is*\n   * the reference's dimensions and nothing resamples mid-comparison.\n   */\n  const u = Math.min(width / REF_W, height / REF_H);\n  const s = stripAt(now, n);\n  const z = zoomAt(now, n);\n\n  const type = {\n    fontFamily: face,\n    fontWeight: 500,\n    fontSize: SIZE,\n    fill: t.foreground,\n    // Unhinted outlines, so the sub-pixel `Line` blend moves the shape it\n    // drew rather than a re-hinted one.\n    textRendering: \"geometricPrecision\",\n  } as const;\n\n  const top = (height - REF_H * u) / 2;\n  // A Remotion render's `scale` is the page's devicePixelRatio.\n  const dpr = typeof window === \"undefined\" ? 1 : window.devicePixelRatio || 1;\n  // A baseline, y in scene units, lands on device row a·y + b.\n  const [x0, y0] = PIVOT;\n  const dev = { a: dpr * u * z, b: dpr * (top + u * y0 * (1 - z)) };\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: t.background }}>\n      <svg\n        width={REF_W * u}\n        height={REF_H * u}\n        viewBox={`0 0 ${REF_W} ${REF_H}`}\n        style={{\n          position: \"absolute\",\n          left: (width - REF_W * u) / 2,\n          top: (height - REF_H * u) / 2,\n          overflow: \"visible\",\n        }}\n      >\n        <defs>\n          <clipPath id={`${id}-slot`}>\n            <rect x={0} y={SLOT[0]} width={REF_W} height={SLOT[1] - SLOT[0]} />\n          </clipPath>\n        </defs>\n\n        <g\n          transform={`translate(${x0} ${y0}) scale(${z}) translate(${-x0} ${-y0})`}\n        >\n          <Line x={LEAD_END} y={LEAD_BASE} dev={dev} anchor=\"end\" style={type}>\n            {headline}\n          </Line>\n\n          <rect\n            x={BOX.x}\n            y={BOX.y}\n            width={BOX.w}\n            height={BOX.h}\n            rx={BOX.r}\n            fill={t.primary}\n          />\n          <polyline\n            points={TICK.map(([a, b]) => `${BOX.x + a},${BOX.y + b}`).join(\" \")}\n            fill=\"none\"\n            stroke={t.primaryForeground}\n            strokeWidth={TICK_W}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n\n          <g clipPath={`url(#${id}-slot)`}>\n            {list.map((word, k) => {\n              const y = WORD_BASE + restOf(k) - s;\n              // Off the slot entirely: nothing to paint.\n              if (Math.abs(y - WORD_BASE) > SLOT[1] - SLOT[0] + SIZE)\n                return null;\n              return (\n                <Line\n                  key={`${k}-${word}`}\n                  x={WORD_X}\n                  y={y}\n                  dev={dev}\n                  style={type}\n                >\n                  {word}\n                </Line>\n              );\n            })}\n          </g>\n        </g>\n      </svg>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/check-cycle.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/text/check-cycle?ref=cli — headline is the part of the sentence that never changes; words is what finishes it, comma separated, rolled through in order beside a ticked checkbox, then the line steps back to 80% and rests. Pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
