{
  "name": "curve-heading-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "files": [
    {
      "path": "components/craftui/blocks/curve-heading/default.tsx",
      "content": "\"use client\";\n\nimport React, {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  animate,\n  cancelFrame,\n  frame,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n  useVelocity,\n  type MotionValue,\n  type Transition,\n} from \"motion/react\";\n\n/* ─────────────────────────────────────────────────────────\n * ANIMATION STORYBOARD — Curve heading card\n *\n *    0ms   card below the fold. The headline is not a straight\n *          line of text — it is laid out along a bell curve, its\n *          middle held level and both ends drooping away.\n *  enter   card crosses the viewport threshold → card springs up\n * +leadIn  the curve FLATTENS: its amplitude A springs from A₀ to\n *          0. Because every glyph is positioned by the curve\n *          equation, they realign onto a flat baseline for free —\n *          no per-glyph target positions anywhere in this file.\n *   exit   card leaves the viewport → the curve re-bows, so the\n *          whole thing replays on every re-entry. It re-bows towards\n *          whichever edge it left by, so scrolling back up plays the\n *          mirrored pose: card from above, line falling, hump inverted.\n *\n * THE MATH (see `buildBell` / `bellOffset`)\n *   The baseline is a bell curve — a Gaussian — not a circle. Flat tails\n *   at both ends, one smooth hump through the middle:\n *\n *     y(x) = A·(1 − e^(−x²∕2σ²))\n *\n *   Written that way the CENTRE is pinned at y = 0 and the tails sag by\n *   A, which is the shape the reference frame shows: the middle of the\n *   word holds its place while the two ends droop away from it. The\n *   `anchor` dial then slides the whole curve by −anchor·A, so the\n *   middle carries a little travel of its own — anchor = 1 pins the\n *   tails instead and lifts the middle by the full A. Either way the\n *   term is proportional to A, so it resolves to nothing at rest. σ sets\n *   how wide the hump is — small σ gives a narrow bump with long flat\n *   tails, large σ approaches the circular arc this replaced.\n *\n *   Glyphs are distributed along that curve BY ARC LENGTH, so the word\n *   never stretches or compresses along its own baseline — it only\n *   bends. Since a curve is longer than its horizontal span, the text's\n *   horizontal extent has to shrink to keep its length fixed, which is\n *   what makes the bowed word look elastically narrowed. Concretely:\n *\n *     s(x) = ∫₀ˣ √(1 + y′(t)²) dt        cumulative arc length\n *     y′(x) = A·(x∕σ²)·e^(−x²∕2σ²)\n *\n *   `buildBell` tabulates s(x) once per frame and inverts it, so a glyph\n *   whose flat centre sits u·(L/2) along the line is placed at the point\n *   that far ALONG THE CURVE, then rotated by atan(y′) to stay tangent.\n *\n *   A is the single animated value. As A → 0: y → 0, y′ → 0, s(x) → x.\n *   The curve becomes a straight line and every glyph lands exactly\n *   where the browser's own centred layout already put it. That limit is\n *   why the text realigns itself rather than being animated into place.\n *\n *   `spread` optionally delays the outer glyphs, so the ends trail the\n *   middle. At spread = 0 the curve stays rigid and flattens as one.\n * ───────────────────────────────────────────────────────── */\n\nconst HEADLINE = \"Design Engineer\";\n\nconst COPY =\n  \"I am design engineer who loves to craft for details and pixel perfect design.\";\n\n/* Tuned in the DialKit panel, then frozen here. Every value that used to\n * be a dial keeps its name and its place in the tree, so the panel can be\n * put back by wrapping this object in useDialKit and restoring the\n * [default, min, max] tuples. */\nconst CONFIG = {\n  card: {\n    /* How far below its resting spot the card starts. */\n    enterY: 72,\n    /* Card opacity while parked. */\n    enterOpacity: 0.73,\n    /* Visible fraction that arms the sequence — and, falling back\n     * through it on the way out, re-arms it. */\n    viewportAmount: 0.1,\n    spring: { type: \"spring\", visualDuration: 0.8, bounce: 0.2 } as Transition,\n  },\n  arc: {\n    /* How far the ends of the line droop, in px, before entry.\n     * Negative bows the curve upward instead of down. */\n    amplitude: 64,\n    /* Width of the bell, as a fraction of the line's half-width.\n     * Low = a narrow hump through the middle with long flat tails;\n     * high = a broad bow approaching a circular arc. */\n    sigma: 0.6,\n    /* Where the curve hangs from, as a fraction of its amplitude.\n     * 0 pins the middle dead still and only the ends move; 1 pins the\n     * flat tails and lifts the middle by the full amplitude — the raw\n     * bell. In between, the middle gets its own subtle travel that\n     * resolves as the curve flattens. */\n    anchor: 0.22,\n    /* Scales the tangent rotation. 1 = glyphs sit truly tangent to the\n     * curve; 0 = they stay upright and only follow its path. */\n    tilt: 1,\n    /* Whole line's vertical rise, independent of the curvature. */\n    riseY: 130,\n    /* Extra delay in seconds at the outermost glyph. 0 keeps the curve\n     * rigid; higher values let the ends trail the middle. */\n    spread: 0.16,\n    /* Exponent on the distance ramp — >1 bunches the middle and throws\n     * the lag out to the very ends. */\n    curve: 1.5,\n    /* Gap between the card landing and the curve releasing. */\n    leadIn: 0.08,\n    spring: { type: \"spring\", visualDuration: 0.7, bounce: 0.28 } as Transition,\n  },\n  scroll: {\n    enabled: true,\n    /* Slope of the map at rest, in px of amplitude per px/s of velocity.\n     * A wheel notch is roughly 800px/s. */\n    gain: 0.06,\n    /* Ceiling the map curves towards but never reaches. Kept in the same\n     * neighbourhood as arc.amplitude so a hard scroll bends the word\n     * about as far as the entrance does. */\n    max: 90,\n    /* How the bend chases the scroll and relaxes back to flat. Stiffness\n     * has to be high enough to track the peaks — scroll velocity spikes\n     * are brief, and a soft spring averages them away into nothing. */\n    stiffness: 380,\n    damping: 28,\n  },\n  direction: {\n    /* Off = the card always poses the same way, whichever edge it enters\n     * from. On = the pose mirrors when entering from the top, so the\n     * motion always reads as coming from the scroll. */\n    mirrorOnScrollUp: true,\n  },\n};\n\nconst RAD = 180 / Math.PI;\n\n/* Samples used to tabulate the curve's arc length. 128 puts the\n * inversion error well under a tenth of a pixel at display sizes. */\nconst SAMPLES = 128;\n\ntype Metrics = {\n  /** Half the natural width of the line, in px. Half the arc length. */\n  half: number;\n  /** Per-glyph flat centre, normalised to −1…+1 across the line. */\n  u: number[];\n};\n\ntype Bell = {\n  /** Height of the curve at horizontal offset x, in px. */\n  y: (x: number) => number;\n  /** Gradient of the curve at x — the tangent the glyph is rotated to. */\n  slope: (x: number) => number;\n  /** Inverse arc length: the x that sits `length` px along the curve. */\n  xAtLength: (length: number) => number;\n};\n\n/* Tabulate one bell curve and its arc length. Amplitude changes every\n * frame, so this is rebuilt constantly — the cache below means glyphs\n * sharing an amplitude (i.e. spread = 0) only pay for it once. */\nfunction buildBell(half: number, amp: number, sigma: number): Bell {\n  const s2 = Math.pow(Math.max(sigma * half, 1), 2);\n\n  const y = (x: number) => amp * (1 - Math.exp((-x * x) / (2 * s2)));\n  const slope = (x: number) => (amp * x * Math.exp((-x * x) / (2 * s2))) / s2;\n\n  /* Cumulative arc length over [0, half], by trapezoid. Monotonic, so\n   * it inverts by a plain binary search. */\n  const step = half / SAMPLES;\n  const cum = new Float64Array(SAMPLES + 1);\n  let prev = Math.sqrt(1 + slope(0) ** 2);\n\n  for (let i = 1; i <= SAMPLES; i++) {\n    const next = Math.sqrt(1 + slope(i * step) ** 2);\n    cum[i] = cum[i - 1] + ((prev + next) / 2) * step;\n    prev = next;\n  }\n\n  const xAtLength = (length: number) => {\n    if (length <= 0) return 0;\n    if (length >= cum[SAMPLES]) return half;\n\n    let lo = 0;\n    let hi = SAMPLES;\n    while (hi - lo > 1) {\n      const mid = (lo + hi) >> 1;\n      if (cum[mid] > length) hi = mid;\n      else lo = mid;\n    }\n\n    /* Linear interpolation inside the bracketing sample. */\n    const span = cum[hi] - cum[lo] || 1;\n    return (lo + (length - cum[lo]) / span) * step;\n  };\n\n  return { y, slope, xAtLength };\n}\n\n/* Amplitude is a continuous animated value, so the key is quantised to\n * a tenth of a pixel — finer than the curve is ever drawn. */\nconst bellCache = new Map<string, Bell>();\n\nfunction getBell(half: number, amp: number, sigma: number): Bell {\n  const key = `${half.toFixed(1)}|${amp.toFixed(1)}|${sigma.toFixed(3)}`;\n  const hit = bellCache.get(key);\n  if (hit) return hit;\n\n  const bell = buildBell(half, amp, sigma);\n  if (bellCache.size > 240) bellCache.clear();\n  bellCache.set(key, bell);\n  return bell;\n}\n\n/* Position of one glyph on the curve, expressed as an offset from where\n * that glyph already sits in flat text flow. */\nfunction bellOffset(\n  u: number,\n  half: number,\n  amp: number,\n  sigma: number,\n  tilt: number,\n  anchor: number,\n) {\n  /* Flat curve — every term collapses to zero. */\n  if (Math.abs(amp) < 0.02 || half === 0) return { dx: 0, dy: 0, rot: 0 };\n\n  const bell = getBell(half, amp, sigma);\n  const side = u < 0 ? -1 : 1;\n  /* The glyph's flat centre, read as a distance along the curve. */\n  const x = bell.xAtLength(Math.abs(u) * half);\n\n  return {\n    dx: side * x - u * half,\n    /* `anchor` slides the whole curve vertically as a fraction of its\n     * own amplitude, so the middle travels too instead of sitting\n     * perfectly still. Scaling by amp means it unwinds to zero on its\n     * own as the curve flattens. */\n    dy: bell.y(x) - anchor * amp,\n    rot: Math.atan(side * bell.slope(x)) * RAD * tilt,\n  };\n}\n\n/* Every colour and metric the block paints with, kept as one table so the\n * card can be re-skinned without hunting hex values through the tree. The\n * fluid `clamp()` sizes of the original stylesheet are laddered across\n * Tailwind's breakpoints here — the endpoints are unchanged, each step is\n * the clamp's own value at that breakpoint. */\nconst SKIN = {\n  /* Olive page ground, near-black card — read off the reference frame. */\n  scene: \"bg-[#3d4a41]\",\n  card: \"bg-[#0a0a0a] rounded-[20px]\",\n  headlineInk: \"text-white\",\n  bodyInk: \"text-[#9b9b9b]\",\n  /* Matches the original `var(--font-sans), system-ui, sans-serif`, so the\n   * card still falls back cleanly in a project with no --font-sans. */\n  font: \"font-[var(--font-sans),system-ui,sans-serif]\",\n  /* clamp(20px, 3.2vw, 44px) */\n  cardGap: \"gap-5 md:gap-6 lg:gap-8 xl:gap-10 2xl:gap-11\",\n  /* clamp(24px, 5vw, 72px) */\n  cardPad: \"p-6 sm:p-8 md:p-10 lg:p-13 xl:p-16 2xl:p-18\",\n  /* clamp(56px, 6.4vw, 88px) — flat until 1024px, where 6.4vw clears 56px.\n   * Inherited from the tile this replaced; a bare line drawing carries the\n   * same footprint without the filled square's visual weight. */\n  markSize: \"size-14 lg:size-16 xl:size-20 2xl:size-22\",\n  /* The one size that can't be a viewport ladder. The line is\n   * `whitespace-nowrap` inside a card that is `min(1120px, 100%)` wide, so\n   * what it has to fit is the CARD's content box — and a viewport\n   * breakpoint doesn't know that width. Drop the card into a gallery well\n   * or any narrower shell and a vw-derived size overshoots and the ends of\n   * the word get clipped by the card's `overflow-hidden`.\n   *\n   * `cqw` resolves against the content box of the nearest size container —\n   * the card itself, see `@container` on it below — so this is measured\n   * against exactly the space the line has, with the padding ladder\n   * already subtracted. \"Design Engineer\" sets ~7.3em wide in Inter at\n   * -0.03em tracking, so 13cqw lands the line at ~95% of the content box\n   * at every card width, mobile through 2xl, and never needs a step.\n   * At the 1120px cap that works out to ~127px — the 132px the original\n   * clamp topped out at, minus the bit that was overflowing. */\n  headlineSize: \"text-[13cqw]\",\n  /* clamp(11px, 1.05vw, 14px) — 1.05vw only clears 11px past 1048px */\n  copySize: \"text-[11px] xl:text-[13px] 2xl:text-[14px]\",\n};\n\nexport type CurveHeadingProps = {\n  /** The line laid along the curve. Split per glyph, so keep it short. */\n  headline?: string;\n  /** Body copy under the headline. */\n  copy?: string;\n  /** Extra classes on the card itself. */\n  className?: string;\n  /**\n   * Scrolling ancestor to measure against. Left undefined the block reads\n   * the window and the viewport, which is what a real page wants. Point it\n   * at an overflow-y container — a gallery well, a modal — and both the\n   * entrance trigger and the velocity bend follow that container instead.\n   */\n  scrollRoot?: React.RefObject<HTMLElement | null>;\n  /**\n   * Optional nudge centred in the top runway, e.g. \"Scroll me\". The runway\n   * is a blank field of scene colour, so on first paint there is nothing on\n   * screen telling you the card is below it. Left undefined the runway stays\n   * empty, which is what a real page wants — the page's own content is the\n   * cue there.\n   */\n  hint?: string;\n};\n\nexport default function CurveHeading({\n  headline = HEADLINE,\n  copy = COPY,\n  className = \"\",\n  scrollRoot,\n  hint,\n}: CurveHeadingProps = {}) {\n  const cardRef = useRef<HTMLDivElement>(null);\n  const headlineRef = useRef<HTMLHeadingElement>(null);\n  const glyphRefs = useRef<(HTMLSpanElement | null)[]>([]);\n\n  const [inView, setInView] = useState(false);\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  /* Which side of the viewport the card is parked on: +1 below (it will\n   * enter while scrolling down), −1 above (entering while scrolling up).\n   * Read from the observer rather than from scroll deltas, so it is the\n   * card's actual position that decides, not a guess about intent. */\n  const [entrySide, setEntrySide] = useState(1);\n  /* Reduced motion strips the movement and keeps the fade: the card\n   * still resolves in, but nothing warps, rotates, or tracks scroll. */\n  const reduceMotion = useReducedMotion() ?? false;\n\n  const chars = useMemo(() => Array.from(headline), [headline]);\n  const charCount = chars.length;\n\n  /* Live scroll velocity, in px/s, mapped to its own slice of curve\n   * amplitude. Scrolling down (positive velocity) drags the ends of the\n   * word downward — the same sense as the entrance pose — and scrolling\n   * up inverts it. Springing the mapped amplitude rather than the raw\n   * velocity means it relaxes back to flat on its own the moment the\n   * page stops moving. */\n  const { scrollY } = useScroll({ container: scrollRoot });\n  const velocity = useVelocity(scrollY);\n\n  const dragTarget = useTransform(velocity, (v) => {\n    /* Gated at the source: off-screen or reduced-motion, the target\n     * holds at 0, the spring settles and stops emitting, and the glyph\n     * subscriptions go quiet. Otherwise every scroll anywhere on the\n     * page would repaint all fifteen glyphs every frame. */\n    if (!CONFIG.scroll.enabled || !inView || reduceMotion) return 0;\n    /* Saturating map rather than a hard clamp. A clamp wastes most of\n     * its range: ordinary scrolling sits far below the ceiling and only\n     * a fling ever reaches it, so the bend reads as barely there. tanh\n     * spends the gain where the velocities actually are, curves over\n     * smoothly, and can never exceed `max` — no clipping to flat-top. */\n    const max = CONFIG.scroll.max;\n    if (max <= 0) return 0;\n    return max * Math.tanh((v * CONFIG.scroll.gain) / max);\n  });\n\n  const dragAmp = useSpring(dragTarget, {\n    stiffness: CONFIG.scroll.stiffness,\n    damping: CONFIG.scroll.damping,\n    mass: 1,\n  });\n\n  /* Read the browser's own centred layout for the line: each glyph's\n   * flat centre and the natural width of the text. Transforms don't\n   * affect offsetLeft/offsetWidth, so this stays valid mid-animation. */\n  const measure = useCallback(() => {\n    const line = headlineRef.current;\n    /* Sliced, not read whole: the ref array keeps its old length when the\n     * headline shortens, so the tail would still hold detached nodes. */\n    const glyphs = glyphRefs.current.slice(0, charCount);\n    const first = glyphs[0];\n    const last = glyphs[glyphs.length - 1];\n    if (!line || !first || !last) return;\n\n    const width = last.offsetLeft + last.offsetWidth - first.offsetLeft;\n    const half = width / 2;\n    const mid = first.offsetLeft + half;\n\n    setMetrics({\n      half,\n      u: glyphs.map((el) =>\n        el && half > 0 ? (el.offsetLeft + el.offsetWidth / 2 - mid) / half : 0,\n      ),\n    });\n  }, [charCount]);\n\n  useLayoutEffect(() => {\n    measure();\n\n    const line = headlineRef.current;\n    if (!line) return;\n\n    /* Webfont swap and container resize both change the layout the arc\n     * is derived from. */\n    const observer = new ResizeObserver(measure);\n    observer.observe(line);\n    document.fonts?.ready.then(measure).catch(() => {});\n\n    return () => observer.disconnect();\n  }, [measure]);\n\n  /* Hand-rolled observer rather than useInView: it reports which side of\n   * the viewport the card is parked on, which useInView does not. */\n  useEffect(() => {\n    const node = cardRef.current;\n    if (!node) return;\n\n    const threshold = Math.min(Math.max(CONFIG.card.viewportAmount, 0), 1);\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        const armed = entry.intersectionRatio >= threshold;\n        /* Compared against the root's own top rather than 0, so the side\n         * is read correctly inside a scroll container too — with no root\n         * `rootBounds.top` is the viewport's, i.e. 0. */\n        const rootTop = entry.rootBounds?.top ?? 0;\n        /* Only update the side while the card is parked — once it is on\n         * screen the pose is committed and must not flip mid-flight. */\n        if (!armed)\n          setEntrySide(entry.boundingClientRect.top > rootTop ? 1 : -1);\n        setInView(armed);\n      },\n      {\n        root: scrollRoot?.current ?? null,\n        threshold: [0, threshold, 1].sort((a, b) => a - b),\n      },\n    );\n\n    observer.observe(node);\n    return () => observer.disconnect();\n    /* CONFIG is a module constant, so this only re-runs if the root moves. */\n  }, [scrollRoot]);\n\n  /* Everything directional is one multiplier. Entering from the top\n   * mirrors the whole pose: the card drops in from above, the line falls\n   * rather than rises, and the curve bows the other way — the hump\n   * inverts, and with it the tangent rotation and the anchor's counter-\n   * move, since both are derived from the signed amplitude. */\n  const sign = CONFIG.direction.mirrorOnScrollUp ? entrySide : 1;\n  /* One multiplier collapses every travelling value at once, so reduced\n   * motion cannot miss one. */\n  const travel = reduceMotion ? 0 : sign;\n\n  return (\n    <>\n      {/* The scene is scrolled past, so it needs runway above and below for\n          the card to enter and exit and re-arm the animation. */}\n      <div\n        className={`flex h-[100svh] items-center justify-center ${SKIN.scene}`}\n      >\n        {hint && (\n          <p\n            className={`m-0 flex items-center gap-2 text-[13px] tracking-[0.14em] uppercase ${SKIN.font} text-white/45`}\n          >\n            {hint}\n            {/* Two dots drifting down the same 22px, the second half a beat\n                behind, so the cue reads as a direction rather than a pulse.\n                Pure CSS — the block's whole JS budget belongs to the curve. */}\n            <span\n              aria-hidden=\"true\"\n              className=\"relative inline-block h-[22px] w-[3px] overflow-hidden\"\n            >\n              <span className=\"absolute top-0 left-0 h-[3px] w-[3px] rounded-full bg-white/45 motion-safe:animate-[curve-heading-hint_1.6s_ease-in-out_infinite]\" />\n              <span className=\"absolute top-0 left-0 h-[3px] w-[3px] rounded-full bg-white/45 [animation-delay:0.8s] motion-safe:animate-[curve-heading-hint_1.6s_ease-in-out_infinite] motion-reduce:hidden\" />\n            </span>\n\n            {/* Scoped to the block so it installs with the component — a\n                single @keyframes is not worth a stylesheet entry in the\n                registry. Under reduced motion nothing references it: the\n                lead dot just rests at the top of its track. */}\n            <style>{`@keyframes curve-heading-hint {\n              0%   { transform: translateY(0);    opacity: 0; }\n              25%  { opacity: 1; }\n              75%  { opacity: 1; }\n              100% { transform: translateY(19px); opacity: 0; }\n            }`}</style>\n          </p>\n        )}\n      </div>\n\n      <section\n        className={`flex min-h-[100svh] items-center justify-center px-[4vw] py-[6vw] ${SKIN.scene}`}\n      >\n        <motion.div\n          ref={cardRef}\n          /* `@container` (container-type: inline-size) is what makes the\n             headline's `cqw` size resolve against this card's content box\n             rather than the viewport — see SKIN.headlineSize. */\n          className={`@container flex aspect-16/10 w-[min(1120px,100%)] flex-col items-center justify-center overflow-hidden ${SKIN.card} ${SKIN.cardGap} ${SKIN.cardPad} ${className}`}\n          initial={false}\n          animate={{\n            /* Full transform string, not the `y` shorthand — this runs\n             * while the user is mid-scroll, which is exactly when the\n             * shorthand's non-accelerated path costs frames. */\n            transform: `translateY(${inView ? 0 : travel * CONFIG.card.enterY}px)`,\n            opacity: inView ? 1 : CONFIG.card.enterOpacity,\n          }}\n          transition={CONFIG.card.spring}\n        >\n          {/* No tile chrome behind it any more — the mark is a bare line\n              drawing, so the rounded-square background would only box it in. */}\n          <Mark\n            className={`block flex-none ${SKIN.headlineInk} ${SKIN.markSize}`}\n          />\n\n          {/* Per-character stagger needs each glyph to be its own box, but\n              the line must still break and centre like normal text. */}\n          <motion.h1\n            ref={headlineRef}\n            className={`relative m-0 whitespace-nowrap text-center font-medium leading-[0.95] tracking-[-0.03em] ${SKIN.headlineInk} ${SKIN.font} ${SKIN.headlineSize}`}\n            aria-label={headline}\n            initial={false}\n            animate={{\n              transform: `translateY(${inView ? 0 : travel * CONFIG.arc.riseY}px)`,\n            }}\n            transition={CONFIG.arc.spring}\n          >\n            {chars.map((char, i) => (\n              <Glyph\n                key={i}\n                char={char}\n                ref={(el) => {\n                  glyphRefs.current[i] = el;\n                }}\n                u={metrics?.u[i] ?? 0}\n                half={metrics?.half ?? 0}\n                straight={inView}\n                bowed={travel * CONFIG.arc.amplitude}\n                sigma={CONFIG.arc.sigma}\n                tilt={CONFIG.arc.tilt}\n                anchor={CONFIG.arc.anchor}\n                drag={dragAmp}\n                delay={\n                  CONFIG.arc.leadIn +\n                  Math.pow(Math.abs(metrics?.u[i] ?? 0), CONFIG.arc.curve) *\n                    CONFIG.arc.spread\n                }\n                transition={CONFIG.arc.spring}\n              />\n            ))}\n          </motion.h1>\n\n          <p\n            className={`m-0 max-w-[46ch] text-center leading-[1.65] tracking-[0.005em] ${SKIN.bodyInk} ${SKIN.font} ${SKIN.copySize}`}\n          >\n            {copy}\n          </p>\n        </motion.div>\n      </section>\n\n      <div className={`h-[100svh] ${SKIN.scene}`} />\n    </>\n  );\n}\n\ntype GlyphProps = {\n  char: string;\n  u: number;\n  half: number;\n  straight: boolean;\n  bowed: number;\n  sigma: number;\n  tilt: number;\n  anchor: number;\n  drag: MotionValue<number>;\n  delay: number;\n  transition: Transition;\n};\n\n/* One glyph riding the curve. It owns a single scalar — the curve's\n * amplitude in px — and derives its whole transform from it, so the\n * animation only ever has one degree of freedom. */\nconst Glyph = React.forwardRef<HTMLSpanElement, GlyphProps>(function Glyph(\n  {\n    char,\n    u,\n    half,\n    straight,\n    bowed,\n    sigma,\n    tilt,\n    anchor,\n    drag,\n    delay,\n    transition,\n  },\n  forwardedRef,\n) {\n  const innerRef = useRef<HTMLSpanElement>(null);\n  /* Starts bowed: the card is below the fold on first paint, so the\n   * curve must already be bent rather than animating into its bend. */\n  const amp = useMotionValue(bowed);\n\n  /* Tracks whether this glyph is currently promoted, so `will-change` is\n   * only written when it actually flips. */\n  const promoted = useRef(false);\n\n  /* Paint: amplitude → curve offset → transform. The two amplitudes\n   * simply add — the entrance pose and the scroll drag are the same\n   * quantity, so one curve carries both and there is still only one\n   * shape being solved. Repaints once immediately so a remeasure or a\n   * dial change lands without waiting for a frame. */\n  useEffect(() => {\n    const paint = () => {\n      const el = innerRef.current;\n      if (!el) return;\n\n      const total = amp.get() + drag.get();\n      const { dx, dy, rot } = bellOffset(u, half, total, sigma, tilt, anchor);\n      el.style.transform = `translate(${dx.toFixed(3)}px, ${dy.toFixed(3)}px) rotate(${rot.toFixed(3)}deg)`;\n\n      /* Promote only while the curve is actually bent. At rest the line\n       * is plain static text and holds no compositor layer — fifteen\n       * permanently promoted layers behind a 132px face is not worth\n       * paying for, so `will-change` is written from here rather than\n       * being set as a class. */\n      const active = Math.abs(total) > 0.05;\n      if (active !== promoted.current) {\n        promoted.current = active;\n        el.style.willChange = active ? \"transform\" : \"auto\";\n      }\n    };\n\n    /* Both amplitudes tick every frame, so a direct subscription would\n     * solve the curve and write the style twice per frame. Scheduling on\n     * the render step coalesces them — `frame` dedupes a callback already\n     * queued for this frame. */\n    const schedule = () => frame.render(paint, false, true);\n\n    paint();\n    const stopAmp = amp.on(\"change\", schedule);\n    const stopDrag = drag.on(\"change\", schedule);\n    return () => {\n      stopAmp();\n      stopDrag();\n      cancelFrame(paint);\n    };\n  }, [amp, drag, u, half, sigma, tilt, anchor]);\n\n  /* Drive: flatten the curve to 0, or bow it back to A₀.\n   * `transition` is a fresh object every render, so the effect keys off\n   * its contents — otherwise every unrelated render would restart the\n   * spring from wherever it had got to. */\n  const transitionKey = JSON.stringify(transition);\n\n  useEffect(() => {\n    const controls = animate(amp, straight ? 0 : bowed, {\n      ...(JSON.parse(transitionKey) as Transition),\n      delay: straight ? delay : 0,\n    });\n    return () => controls.stop();\n  }, [amp, straight, bowed, delay, transitionKey]);\n\n  return (\n    /* Outer span keeps the glyph's untransformed slot in normal text flow —\n       it is what the curve geometry is measured from. The inner span carries\n       the transform, so measuring and painting never fight. */\n    <span\n      className=\"inline-block whitespace-pre\"\n      ref={forwardedRef}\n      aria-hidden=\"true\"\n    >\n      <span\n        className=\"inline-block origin-center whitespace-pre [backface-visibility:hidden]\"\n        ref={innerRef}\n      >\n        {char}\n      </span>\n    </span>\n  );\n});\n\n/* Mark above the headline — the same little pressed-flower drawing the\n * insight-card design uses for its brand lockup, so the two read as one\n * library. Drawn rather than sourced, so the block still has no image and\n * no icon-package dependency.\n *\n * Every path is `currentColor`, so the mark takes its colour from the\n * `text-` class at the call site — white here, matching the headline. */\nfunction Mark({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      className={className}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.35}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      role=\"presentation\"\n    >\n      {/* five petals */}\n      <path d=\"M12.00 2.65C12.80 2.65 13.45 3.57 13.45 4.70C13.45 5.83 12.80 6.75 12.00 6.75C11.20 6.75 10.55 5.83 10.55 4.70C10.55 3.57 11.20 2.65 12.00 2.65ZM16.54 6.14C16.78 6.84 16.15 7.70 15.13 8.05C14.12 8.40 13.09 8.11 12.85 7.41C12.61 6.70 13.24 5.85 14.26 5.50C15.27 5.15 16.30 5.43 16.54 6.14ZM15.17 11.76C14.52 12.27 13.41 11.94 12.70 11.03C11.98 10.11 11.93 8.96 12.58 8.45C13.24 7.94 14.35 8.27 15.06 9.18C15.77 10.09 15.82 11.25 15.17 11.76ZM9.37 11.60C8.75 11.18 8.73 10.14 9.32 9.27C9.90 8.40 10.88 8.03 11.50 8.45C12.11 8.86 12.14 9.91 11.55 10.78C10.97 11.65 9.99 12.01 9.37 11.60ZM7.24 6.34C7.45 5.59 8.49 5.24 9.55 5.54C10.61 5.85 11.30 6.69 11.09 7.44C10.87 8.18 9.84 8.54 8.78 8.23C7.72 7.93 7.03 7.08 7.24 6.34Z\" />\n      {/* pistil */}\n      <circle cx=\"12\" cy=\"7.7\" r=\"1.15\" fill=\"currentColor\" stroke=\"none\" />\n      {/* stem, leaf, and the pressed-under ground lines */}\n      <path d=\"M12.15 12.4c-.35 2.2-.25 4.4 0 6.1\" />\n      <path\n        d=\"M12.05 17.1C9.85 16.7 8.3 15.15 7.95 13.05C10.35 13.15 11.75 14.85 12.05 17.1Z\"\n        fill=\"currentColor\"\n        stroke=\"none\"\n      />\n      <path d=\"M4.9 19.1c2.3-.45 4.4.2 7.2.15 2.5-.05 4.7-.55 7-.3\" />\n      <path d=\"M7.6 21.2c.95-.15 1.75-.1 2.5 0M14.2 21.15c.85-.1 1.55-.05 2.25.05\" />\n    </svg>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/curve-heading/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Curve Heading - Curve Heading",
    "description": "The headline is not a straight line of text: every glyph is positioned by a Gaussian curve equation, distributed along it by arc length and rotated to its tangent. Perfect for marketing pages.",
    "category": "creative",
    "tags": [
      "creative",
      "heading",
      "text",
      "curve",
      "scroll",
      "card",
      "animation"
    ],
    "animationType": "scroll"
  }
}