{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-split-cards",
  "title": "Scroll Split Cards",
  "author": "AlgorizeTech",
  "description": "A cinematic scroll-driven storytelling card component that splits a seamless panoramic image into three panels, expands gaps, flips 180° in 3D into editorial narratives, and glides away.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/ScrollSplitCardsBlock.tsx",
      "content": "\"use client\";\n\nimport React, { useEffect, useLayoutEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { createSpringFollower } from \"./motion-curves\";\n\n// Scroll choreography: wheel and touch input move a raw 0 → 1 progress, and\n// the painted progress chases it through this smooth, damped spring stepped\n// on requestAnimationFrame for a buttery, cinematic feel.\nconst PROGRESS_SPRING = { stiffness: 70, damping: 18, mass: 1.2, restDelta: 0.0004, restSpeed: 0.004 };\n\n// ============================================================================\n// TYPES & INTERFACES\n// ============================================================================\nexport type SplitCard = {\n  title: string;\n  description: string;\n  bgColor?: string;\n  backgroundColor?: string;\n  textColor?: string;\n  kicker?: string;\n  tag?: string;\n  number?: string;\n  metric?: string;\n  metricLabel?: string;\n  location?: string;\n};\n\nexport type SplitCardItem = SplitCard;\n\nexport interface ScrollSplitCardsProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  variant?: \"default\" | \"slow-transition\";\n  items?: SplitCard[];\n  cards?: SplitCard[];\n  imageSrc?: string;\n  imageUrl?: string;\n  headerTitle?: string;\n  headerSubtitle?: string;\n  containerHeight?: string;\n  enableScrollIndicator?: boolean;\n  scrollSpeed?: number | \"slow\" | \"normal\" | \"fast\";\n  speed?: number | \"slow\" | \"normal\" | \"fast\";\n}\n\n// ============================================================================\n// DEFAULT DATA (Lago di Braies / Dolomite Alpine Lake Sanctuary)\n// ============================================================================\nexport const DEFAULT_IMAGE =\n  \"https://res.cloudinary.com/dodndg7ai/image/upload/f_webp,q_auto/v1790224415/photo-1501785888041-af3ef285b470-1790146212207-1790224416318.jpg\";\n\nconst DEFAULT_CARDS: SplitCard[] = [\n  {\n    title: \"Lago di Braies\",\n    kicker: \"SANCTUARY\",\n    metric: \"1,496 M\",\n    metricLabel: \"ELEVATION\",\n    location: \"South Tyrol, Italy\",\n    description:\n      \"Glacial emerald waters mirror sheer limestone walls in the silent morning mist before rowers set out.\",\n    bgColor: \"#13352F\",\n    textColor: \"#E6F4F1\",\n  },\n  {\n    title: \"The Boathouse\",\n    kicker: \"HERITAGE\",\n    metric: \"100+ YRS\",\n    metricLabel: \"ROWBOAT CRAFT\",\n    location: \"Prags Alpine Dock\",\n    description:\n      \"Handcrafted wooden larch skiffs moored to stilted pine docks, awaiting the gentle morning row across crystal shallows.\",\n    bgColor: \"#A45229\",\n    textColor: \"#FFF8F5\",\n  },\n  {\n    title: \"Seekofel Peak\",\n    kicker: \"UNESCO\",\n    metric: \"2,810 M\",\n    metricLabel: \"SUMMIT HEIGHT\",\n    location: \"Dolomites Park\",\n    description:\n      \"Towering pale crags catch the first golden alpine glow, standing sentinel over the emerald basin below.\",\n    bgColor: \"#141E2B\",\n    textColor: \"#ECF3F9\",\n  },\n];\n\n// ============================================================================\n// MATHEMATICAL EASING & CHOREOGRAPHY\n// ============================================================================\nfunction clamp01(n: number) {\n  return Math.min(1, Math.max(0, n));\n}\n\nfunction remap(progress: number, from: number, to: number) {\n  if (to === from) return progress >= to ? 1 : 0;\n  return clamp01((progress - from) / (to - from));\n}\n\nfunction lerp(a: number, b: number, t: number) {\n  return a + (b - a) * t;\n}\n\n/** Left/right panels shift outward, keeping a smaller peak and restGap on small screens to prevent clipping. */\nfunction panelShiftX(progress: number, index: number, isSmall = false) {\n  const dir = index === 0 ? -1 : index === 2 ? 1 : 0;\n  const out = remap(progress, 0, 0.45);\n  const settle = remap(progress, 0.45, 0.88);\n  const peak = isSmall ? 18 : 48;\n  return dir * lerp(0, peak, out) * (1 - settle * 0.45) || 0;\n}\n\nfunction panelRotateY(progress: number) {\n  return lerp(0, 180, remap(progress, 0.35, 0.85));\n}\n\nfunction panelScale(progress: number, isSmall = false) {\n  const target = isSmall ? 0.88 : 0.94;\n  return lerp(1, target, remap(progress, 0, 0.38));\n}\n\nfunction panelRadius(progress: number, index: number) {\n  const inner = lerp(0, 16, remap(progress, 0, 0.25));\n  if (index === 0) return `16px ${inner}px ${inner}px 16px`;\n  if (index === 2) return `${inner}px 16px 16px ${inner}px`;\n  return `${inner}px`;\n}\n\n// ============================================================================\n// PANEL COMPONENT (Refined Editorial Card Design with Visual Balance)\n// ============================================================================\nfunction Panel({\n  card,\n  index,\n  imageSrc,\n}: {\n  card: SplitCard;\n  index: number;\n  imageSrc: string;\n}) {\n  // The resting (progress 0) radius. The scroll loop rewrites it, along with\n  // the panel's transform, straight on the DOM every frame.\n  const radius = panelRadius(0, index);\n\n  const bgColor = card.bgColor || card.backgroundColor || \"#13352F\";\n  const textColor = card.textColor || \"#E6F4F1\";\n  const kicker = card.kicker || card.tag || \"CHAPTER\";\n  const num = card.number || String(index + 1).padStart(2, \"0\");\n\n  return (\n    <div\n      data-split-panel={index}\n      className={cn(\n        \"relative h-full min-w-0 flex-[1_1_0] [transform-style:preserve-3d]\",\n        index > 0 ? \"-ml-px\" : \"\"\n      )}\n      style={{ zIndex: index }}\n    >\n      {/* Front Face: 1/3 of the seamless panoramic photograph */}\n      <div\n        data-split-face=\"\"\n        className=\"absolute inset-0 overflow-hidden [backface-visibility:hidden]\"\n        style={{ borderRadius: radius }}\n      >\n        <div\n          className=\"absolute inset-0 h-full w-[300%]\"\n          style={{\n            left: `${-100 * index}%`,\n            backgroundImage: `url(${imageSrc})`,\n            backgroundSize: \"100% 100%\",\n            backgroundPosition: \"center\",\n          }}\n        />\n      </div>\n\n      {/* Back Face: Physical card reverse with rich editorial layout */}\n      <div\n        data-split-face=\"\"\n        className=\"absolute inset-0 flex flex-col justify-between overflow-hidden p-3 sm:p-5 md:p-7 antialiased [backface-visibility:hidden] select-none border border-white/10\"\n        style={{\n          backgroundColor: bgColor,\n          color: textColor,\n          borderRadius: radius,\n          transform: \"rotateY(180deg)\",\n          boxShadow: \"inset 0 1px 0 rgba(255,255,255,0.18)\",\n        }}\n      >\n        {/* Top Header Row: Category Badge & Numeric Index */}\n        <div className=\"flex items-center justify-between gap-1\">\n          <div className=\"inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded-full bg-white/10 backdrop-blur-sm border border-white/10 max-w-[78%]\">\n            <span className=\"size-1 sm:size-1.5 shrink-0 rounded-full bg-current opacity-80\" />\n            <span className=\"font-mono text-[7px] sm:text-[9px] md:text-[10px] font-semibold uppercase tracking-wider truncate\">\n              {kicker}\n            </span>\n          </div>\n          <span className=\"font-mono text-[9px] sm:text-[11px] md:text-[12px] font-bold tabular-nums opacity-60 shrink-0\">\n            {num}\n          </span>\n        </div>\n\n        {/* Mid Section: Feature Metric / Stat Badge */}\n        <div className=\"my-auto py-1 sm:py-2\">\n          {card.metric ? (\n            <div className=\"inline-flex flex-col\">\n              <span className=\"font-mono text-[7px] sm:text-[8.5px] md:text-[10px] uppercase tracking-wider opacity-60\">\n                {card.metricLabel || \"METRIC\"}\n              </span>\n              <span className=\"text-base sm:text-xl md:text-3xl font-extrabold tracking-tight mt-0.5\">\n                {card.metric}\n              </span>\n            </div>\n          ) : (\n            <div className=\"w-5 sm:w-6 h-[1.5px] opacity-30 bg-current\" />\n          )}\n        </div>\n\n        {/* Bottom Section: Display Title, Narrative & Location Footer */}\n        <div>\n          <h3 className=\"text-[13px] sm:text-[18px] md:text-[23px] font-bold tracking-tight leading-[1.15] text-balance\">\n            {card.title}\n          </h3>\n          <p className=\"mt-1 sm:mt-2 text-[9px] sm:text-[11.5px] md:text-[13px] leading-snug sm:leading-relaxed text-pretty opacity-85 line-clamp-2 sm:line-clamp-3 md:line-clamp-none\">\n            {card.description}\n          </p>\n\n          <div className=\"mt-2 sm:mt-3 pt-1.5 sm:pt-2.5 border-t border-current/15 flex items-center justify-between text-[7.5px] sm:text-[9.5px] md:text-[11px] font-mono uppercase tracking-wider opacity-75\">\n            <span className=\"truncate pr-1\">{card.location || \"Dolomites, Italy\"}</span>\n            <span className=\"text-[10px] sm:text-xs shrink-0\">↗</span>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// ============================================================================\n// MAIN COMPONENT\n// ============================================================================\nexport function ScrollSplitCards({\n  variant = \"default\",\n  items,\n  cards,\n  imageSrc,\n  imageUrl,\n  headerTitle = \"Explore\",\n  headerSubtitle = \"Scroll down to reveal the alpine lake narrative\",\n  containerHeight,\n  enableScrollIndicator = true,\n  scrollSpeed,\n  speed,\n  className = \"\",\n  style,\n  ...props\n}: ScrollSplitCardsProps) {\n  const cardList = (items || cards || DEFAULT_CARDS).slice(0, 3);\n  const activeImage = imageSrc || imageUrl || DEFAULT_IMAGE;\n  const defaultSpeedForVariant = variant === \"slow-transition\" ? \"slow\" : 1;\n  const activeSpeed = speed ?? scrollSpeed ?? defaultSpeedForVariant;\n  const speedFactor =\n    typeof activeSpeed === \"number\"\n      ? activeSpeed\n      : activeSpeed === \"slow\"\n      ? 0.5\n      : activeSpeed === \"fast\"\n      ? 1.8\n      : 1;\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const headerRef = useRef<HTMLDivElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  /** Repaints the current progress; set while the scroll loop is running. */\n  const repaint = useRef<(() => void) | null>(null);\n\n  // One loop owns the whole choreography: wheel / touch input moves the raw\n  // progress, a spring follower smooths it, and every frame paints the stage,\n  // header and panels from the smoothed value. The small-screen flag lives\n  // here too, so a resize across the breakpoint just repaints.\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n\n    let isSmall = false;\n    let raw = 0;\n\n    const paint = (p: number) => {\n      const stage = stageRef.current;\n      const header = headerRef.current;\n\n      if (stage) {\n        const scale = panelScale(p, isSmall);\n        stage.style.transform = scale !== 1 ? `scale(${scale})` : \"none\";\n        stage.querySelectorAll<HTMLElement>(\"[data-split-panel]\").forEach((panel) => {\n          const index = Number(panel.dataset.splitPanel);\n          const x = panelShiftX(p, index, isSmall);\n          const rotateY = panelRotateY(p);\n          const parts: string[] = [];\n          if (x !== 0) parts.push(`translateX(${x}px)`);\n          if (rotateY !== 0) parts.push(`rotateY(${rotateY}deg)`);\n          panel.style.transform = parts.length ? parts.join(\" \") : \"none\";\n          const radius = panelRadius(p, index);\n          panel.querySelectorAll<HTMLElement>(\"[data-split-face]\").forEach((face) => {\n            face.style.borderRadius = radius;\n          });\n        });\n      }\n\n      if (header) {\n        // Opacity 1 → 0.8 → 0 over [0, 0.15, 0.28]; lift 0 → -18px over [0, 0.28].\n        const opacity = p <= 0.15 ? lerp(1, 0.8, remap(p, 0, 0.15)) : lerp(0.8, 0, remap(p, 0.15, 0.28));\n        const y = lerp(0, -18, remap(p, 0, 0.28));\n        header.style.opacity = String(opacity);\n        header.style.transform = y !== 0 ? `translateY(${y}px)` : \"none\";\n      }\n    };\n\n    const spring = createSpringFollower(0, paint, PROGRESS_SPRING);\n    repaint.current = () => paint(spring.get());\n\n    const reducedMotion = () =>\n      window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true;\n\n    const moveTo = (next: number) => {\n      raw = next;\n      // Reduced motion: the panels track the input directly, with no spring\n      // carrying them on after the wheel or finger stops.\n      if (reducedMotion()) spring.jump(next);\n      else spring.set(next);\n    };\n\n    const checkSize = (width: number) => {\n      const small = width > 0 && width < 640;\n      if (small !== isSmall) {\n        isSmall = small;\n        paint(spring.get());\n      }\n    };\n\n    checkSize(el.clientWidth);\n    const observer = new ResizeObserver((entries) => {\n      for (const entry of entries) checkSize(entry.contentRect.width);\n    });\n    observer.observe(el);\n\n    // Capture wheel and touch gestures on container so everything happens in-place\n    const handleWheel = (e: WheelEvent) => {\n      const current = raw;\n      if ((e.deltaY > 0 && current < 1) || (e.deltaY < 0 && current > 0)) {\n        e.preventDefault();\n        const delta = e.deltaY * 0.00065 * speedFactor;\n        moveTo(Math.min(Math.max(current + delta, 0), 1));\n      }\n    };\n\n    let touchStartY = 0;\n    const handleTouchStart = (e: TouchEvent) => {\n      touchStartY = e.touches[0].clientY;\n    };\n    const handleTouchMove = (e: TouchEvent) => {\n      const currentY = e.touches[0].clientY;\n      const deltaY = touchStartY - currentY;\n      touchStartY = currentY;\n      const current = raw;\n      if ((deltaY > 0 && current < 1) || (deltaY < 0 && current > 0)) {\n        if (e.cancelable) e.preventDefault();\n        moveTo(Math.min(Math.max(current + deltaY * 0.0012 * speedFactor, 0), 1));\n      }\n    };\n\n    el.addEventListener(\"wheel\", handleWheel, { passive: false });\n    el.addEventListener(\"touchstart\", handleTouchStart, { passive: true });\n    el.addEventListener(\"touchmove\", handleTouchMove, { passive: false });\n\n    return () => {\n      observer.disconnect();\n      spring.destroy();\n      repaint.current = null;\n      el.removeEventListener(\"wheel\", handleWheel);\n      el.removeEventListener(\"touchstart\", handleTouchStart);\n      el.removeEventListener(\"touchmove\", handleTouchMove);\n    };\n  }, [speedFactor]);\n\n  // A re-render (new cards, say) puts fresh panels on the stage at their\n  // resting style: bring them to the current progress before they paint.\n  useLayoutEffect(() => {\n    repaint.current?.();\n  });\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative w-full rounded-none sm:rounded-3xl overflow-hidden\",\n        !containerHeight && \"h-[460px] sm:h-[620px] md:h-[650px] lg:h-[710px]\",\n        \"bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-white\",\n        \"border-0 sm:border border-neutral-200/90 dark:border-neutral-800/80\",\n        \"select-none transition-colors duration-300\",\n        className\n      )}\n      style={{\n        ...(containerHeight ? { height: containerHeight } : {}),\n        ...style,\n      }}\n      {...props}\n    >\n      {/* Top Header & Scroll Indicator */}\n      <div\n        ref={headerRef}\n        className=\"pointer-events-none absolute inset-x-0 top-4 sm:top-5 lg:top-6 z-20 flex flex-col items-center gap-1 sm:gap-1.5 px-3 text-center\"\n      >\n        <div className=\"flex max-w-full flex-col items-center gap-0.5 sm:gap-1 text-center\">\n          <p className=\"text-sm sm:text-base lg:text-lg font-medium tracking-tight text-neutral-900 dark:text-white leading-tight\">\n            {headerTitle}\n          </p>\n          <p className=\"text-[11px] sm:text-xs lg:text-sm text-neutral-500 dark:text-neutral-400 leading-tight\">\n            {headerSubtitle}\n          </p>\n        </div>\n\n        {enableScrollIndicator && (\n          <div className=\"flex h-6 sm:h-7 lg:h-8 w-2 shrink-0 flex-col items-center mt-1 sm:mt-1.5 lg:mt-2\" aria-hidden=\"true\">\n            <div className=\"size-1.5 sm:size-2 shrink-0 rounded-[2px] border border-neutral-400 dark:border-white/40\" />\n            <div className=\"w-px flex-1 bg-gradient-to-b from-neutral-400 dark:from-white/40 to-transparent\" />\n          </div>\n        )}\n      </div>\n\n      {/* Presentation Stage: Level, Straight & Top-Calibrated */}\n      <div className=\"relative h-full w-full flex flex-col items-center justify-start overflow-hidden [perspective:1400px] px-3.5 sm:px-6 pt-[106px] sm:pt-[120px] lg:pt-[132px]\">\n        <div\n          ref={stageRef}\n          className=\"flex h-[310px] sm:h-[430px] md:h-[450px] lg:h-[500px] w-full max-w-5xl px-1 sm:px-4 [transform-style:preserve-3d]\"\n        >\n          {cardList.map((card, index) => (\n            <Panel\n              key={card.title}\n              card={card}\n              index={index}\n              imageSrc={activeImage}\n            />\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default ScrollSplitCards;\n",
      "type": "registry:component",
      "target": "components/custom/blocks/ScrollSplitCardsBlock.tsx"
    },
    {
      "path": "src/components/custom/blocks/motion-curves.ts",
      "content": "/**\n * Motion curves with no animation library behind them.\n *\n * The components here ship as CSS, GSAP or Motion builds that must move\n * identically, so the curves are computed once, from the same numbers, and\n * handed to whichever engine runs them:\n *\n *   - `cubicBezier(x1, y1, x2, y2)` → an easing function (GSAP accepts any\n *     `(progress) => value` as `ease`), exact for Motion's `ease: [..]` arrays\n *     and for CSS `cubic-bezier()`.\n *   - `spring({ stiffness, damping, mass })` → the analytic solution Motion\n *     Motion uses, as an easing function plus the time it takes to settle, or\n *     as a CSS `linear()` string for a fixed start and end.\n *   - `createSpringFollower` → a value that chases a moving target through the\n *     same spring every frame, like Motion's `useSpring`, for pointer-driven\n *     motion that no fixed curve can describe.\n */\n\n// ---------------------------------------------------------------------------\n// Cubic bezier\n// ---------------------------------------------------------------------------\n\n/**\n * The CSS / Motion cubic-bezier as a function of progress. Newton-Raphson\n * with a bisection fallback, the same solver browsers use.\n */\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number) {\n  if (x1 === y1 && x2 === y2) return (t: number) => t;\n\n  const a = (p1: number, p2: number) => 1 - 3 * p2 + 3 * p1;\n  const b = (p1: number, p2: number) => 3 * p2 - 6 * p1;\n  const c = (p1: number) => 3 * p1;\n  const at = (t: number, p1: number, p2: number) =>\n    ((a(p1, p2) * t + b(p1, p2)) * t + c(p1)) * t;\n  const slope = (t: number, p1: number, p2: number) =>\n    3 * a(p1, p2) * t * t + 2 * b(p1, p2) * t + c(p1);\n\n  const solveX = (x: number) => {\n    let t = x;\n    for (let i = 0; i < 8; i++) {\n      const s = slope(t, x1, x2);\n      if (Math.abs(s) < 1e-6) break;\n      const err = at(t, x1, x2) - x;\n      if (Math.abs(err) < 1e-7) return t;\n      t -= err / s;\n    }\n    let lo = 0;\n    let hi = 1;\n    t = x;\n    for (let i = 0; i < 30 && hi - lo > 1e-7; i++) {\n      const value = at(t, x1, x2);\n      if (value < x) lo = t;\n      else hi = t;\n      t = (lo + hi) / 2;\n    }\n    return t;\n  };\n\n  return (x: number) => (x <= 0 ? 0 : x >= 1 ? 1 : at(solveX(x), y1, y2));\n}\n\n/** Motion's named eases, as the cubic-beziers they are. */\nexport const MOTION_EASE = {\n  linear: [0, 0, 1, 1],\n  easeIn: [0.42, 0, 1, 1],\n  easeOut: [0, 0, 0.58, 1],\n  easeInOut: [0.42, 0, 0.58, 1],\n  circIn: [0.55, 0, 1, 0.45],\n  circOut: [0.55, 0, 0.45, 1],\n  circInOut: [0.85, 0, 0.15, 1],\n  backIn: [0.31, 0.01, 0.66, -0.59],\n  backOut: [0.33, 1.53, 0.69, 0.99],\n  anticipate: [0.66, -0.56, 0.34, 1.35],\n} as const;\n\n/**\n * Motion's default layout transition (`layout` / `layoutId`):\n * `{ duration: 0.45, ease: [0.4, 0, 0.1, 1] }`.\n */\nexport const LAYOUT_TRANSITION = { duration: 0.45, ease: [0.4, 0, 0.1, 1] as const };\n\n// ---------------------------------------------------------------------------\n// Spring\n// ---------------------------------------------------------------------------\n\nexport interface SpringOptions {\n  /** Motion default 100. */\n  stiffness?: number;\n  /** Motion default 10. */\n  damping?: number;\n  /** Motion default 1. */\n  mass?: number;\n  /** Starting velocity, in units of the whole distance per second. */\n  velocity?: number;\n  /** How close to the end counts as settled, as a fraction of the distance. */\n  restDelta?: number;\n}\n\n/**\n * Normalised position (0 → 1) of a spring released from 0 towards 1, at time\n * `t` seconds. The closed-form solution Motion's `spring()` generator\n * uses for the under-, critically and over-damped cases.\n */\nexport function springAt(options: SpringOptions = {}) {\n  const { stiffness = 100, damping = 10, mass = 1, velocity = 0 } = options;\n  const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n  const omega0 = Math.sqrt(stiffness / mass);\n  // Motion's convention: distance still to cover, velocity toward it negated.\n  const delta = 1;\n  const v0 = -velocity;\n\n  if (zeta < 1) {\n    const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);\n    return (t: number) => {\n      const envelope = Math.exp(-zeta * omega0 * t);\n      return (\n        1 -\n        envelope *\n          (((v0 + zeta * omega0 * delta) / omegaD) * Math.sin(omegaD * t) +\n            delta * Math.cos(omegaD * t))\n      );\n    };\n  }\n  if (zeta === 1) {\n    return (t: number) => 1 - Math.exp(-omega0 * t) * (delta + (v0 + omega0 * delta) * t);\n  }\n  const omegaD = omega0 * Math.sqrt(zeta * zeta - 1);\n  return (t: number) => {\n    const envelope = Math.exp(-zeta * omega0 * t);\n    const freq = Math.min(omegaD * t, 300);\n    return (\n      1 -\n      (envelope *\n        ((v0 + zeta * omega0 * delta) * Math.sinh(freq) + omegaD * delta * Math.cosh(freq))) /\n        omegaD\n    );\n  };\n}\n\n/** Seconds until the spring stays within `restDelta` of its end. */\nexport function springDuration(options: SpringOptions = {}) {\n  const at = springAt(options);\n  const restDelta = options.restDelta ?? 0.001;\n  const step = 1 / 240;\n  let lastOutside = 0;\n  for (let t = 0; t <= 10; t += step) {\n    if (Math.abs(1 - at(t)) > restDelta) lastOutside = t;\n    else if (t - lastOutside > 0.1) break;\n  }\n  return Math.max(lastOutside + step, step);\n}\n\n/**\n * A spring as a duration and an easing function over it — what GSAP takes:\n *\n *   const { duration, ease } = springEase({ stiffness: 300, damping: 20 });\n *   gsap.to(el, { y: 0, duration, ease });\n *\n * Exact for a fixed start and end; a spring that has to react mid-flight to a\n * new target needs `createSpringFollower` instead.\n */\nexport function springEase(options: SpringOptions = {}) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  return {\n    duration,\n    ease: (progress: number) => (progress >= 1 ? 1 : at(progress * duration)),\n  };\n}\n\n/**\n * A spring as a CSS `linear()` easing and its duration in ms, for\n * `transition` / `animation` with a fixed start and end.\n *\n *   const { easing, duration } = springLinear({ stiffness: 400, damping: 30 });\n *   el.style.transition = `transform ${duration}ms ${easing}`;\n */\nexport function springLinear(options: SpringOptions = {}, pointsPerSecond = 60) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  const count = Math.max(Math.ceil(duration * pointsPerSecond), 8);\n  const points: string[] = [];\n  for (let i = 0; i <= count; i++) {\n    const t = (i / count) * duration;\n    points.push(String(Math.round((i === count ? 1 : at(t)) * 10000) / 10000));\n  }\n  return { easing: `linear(${points.join(\", \")})`, duration: Math.round(duration * 1000) };\n}\n\n// ---------------------------------------------------------------------------\n// Spring follower (useSpring equivalent)\n// ---------------------------------------------------------------------------\n\n/** Something that calls back once per frame with the time since the last, in ms. */\nexport interface FrameTicker {\n  add: (callback: (deltaMs: number) => void) => () => void;\n}\n\n/** requestAnimationFrame as a ticker — the library-free default. */\nexport const rafTicker: FrameTicker = {\n  add(callback) {\n    let last = performance.now();\n    let frame = requestAnimationFrame(function tick(now) {\n      callback(now - last);\n      last = now;\n      frame = requestAnimationFrame(tick);\n    });\n    return () => cancelAnimationFrame(frame);\n  },\n};\n\nexport interface SpringFollower {\n  /** Chase a new target from wherever the value is now, keeping its velocity. */\n  set: (target: number) => void;\n  /** Move to a value instantly, with no motion. */\n  jump: (value: number) => void;\n  get: () => number;\n  destroy: () => void;\n}\n\n/**\n * A value that follows its target through a spring, integrating the same\n * equation of motion Motion's `useSpring` solves — so a pointer-driven spring\n * reads the same in every build. Runs only while moving; `onUpdate` receives\n * each new value.\n */\nexport function createSpringFollower(\n  initial: number,\n  onUpdate: (value: number) => void,\n  options: SpringOptions & { restSpeed?: number; ticker?: FrameTicker } = {},\n): SpringFollower {\n  const { stiffness = 100, damping = 10, mass = 1, ticker = rafTicker } = options;\n  const restDelta = options.restDelta ?? 0.01;\n  const restSpeed = options.restSpeed ?? 0.01;\n  let value = initial;\n  let target = initial;\n  let velocity = 0;\n  let stop: (() => void) | null = null;\n\n  const step = (deltaMs: number) => {\n    // Fixed 1ms sub-steps keep the integration stable for stiff springs and\n    // independent of the frame rate.\n    const steps = Math.min(Math.max(Math.round(deltaMs), 1), 250);\n    for (let i = 0; i < steps; i++) {\n      const force = -stiffness * (value - target) - damping * velocity;\n      velocity += (force / mass) * 0.001;\n      value += velocity * 0.001;\n    }\n    if (Math.abs(target - value) < restDelta && Math.abs(velocity) < restSpeed) {\n      value = target;\n      velocity = 0;\n      stop?.();\n      stop = null;\n    }\n    onUpdate(value);\n  };\n\n  return {\n    set(next) {\n      target = next;\n      if (!stop && value !== target) stop = ticker.add(step);\n    },\n    jump(next) {\n      stop?.();\n      stop = null;\n      value = target = next;\n      velocity = 0;\n      onUpdate(value);\n    },\n    get: () => value,\n    destroy() {\n      stop?.();\n      stop = null;\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/motion-curves.ts"
    }
  ],
  "docs": "Named export: ScrollSplitCards.",
  "categories": [
    "scroll",
    "media",
    "marketing"
  ],
  "type": "registry:component"
}
