{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ribbon-trail",
  "type": "registry:ui",
  "title": "Ribbon Trail",
  "description": "Silky spring-chain ribbons that chase the pointer, tapering and twisting, and drift on a Lissajous path when idle.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/ribbon-trail.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface RibbonTrailProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of ribbons. Each one is a little looser than the last. */\n  ribbonCount?: number;\n  /** Ribbon width at the head, in px. */\n  thickness?: number;\n  /** Points per ribbon. More points make longer tails. */\n  length?: number;\n  /** How tightly the ribbons follow, 0–1. */\n  stiffness?: number;\n  /** Colour of the first ribbon (any CSS colour). */\n  accent?: string;\n  /** Colour of the last ribbon; the ones between blend (any CSS colour). */\n  secondary?: string;\n  /** Drift along a slow Lissajous path while no pointer is present. */\n  idle?: boolean;\n}\n\ntype RGB = [number, number, number];\n\n/* ---------------------------------- Colour --------------------------------- */\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction resolveColor(input: string, fallback: RGB): RGB {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input; // invalid colours are ignored → transparent → fallback\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nconst luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n\n/** On light themes, very pale colours are deepened (keeping their hue) so they stay visible. */\nfunction legible(color: RGB, ink: RGB): RGB {\n  if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;\n  return color.map((v) => Math.round(v * 0.7)) as RGB;\n}\n\n/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */\nfunction observeTheme(el: Element, onChange: () => void) {\n  const mo = new MutationObserver(onChange);\n  const options = { attributes: true, attributeFilter: [\"class\", \"data-theme\"] };\n  mo.observe(document.documentElement, options);\n  const scoped = el.parentElement?.closest(\"[data-theme]\");\n  if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);\n  const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => {\n    mo.disconnect();\n    mq.removeEventListener(\"change\", onChange);\n  };\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- Physics --------------------------------- */\n\nconst STEP_MS = 1000 / 60;\n\ninterface Strand {\n  x: Float32Array;\n  y: Float32Array;\n  vx: Float32Array;\n  vy: Float32Array;\n  /** Head spring stiffness / damping and follower stiffness for this strand. */\n  head: number;\n  headDamp: number;\n  follow: number;\n  /** Fixed offset of this strand's head around the target, so ribbons don't sit on top of each other. */\n  ox: number;\n  oy: number;\n  /** Twist phase: where along the ribbon it turns edge-on. */\n  twist: number;\n  /** Gradient stops: head, middle, tail. */\n  color: string;\n  mid: string;\n  fade: string;\n}\n\n/**\n * Silky ribbons chasing the pointer. Each ribbon is a chain of springs: the head springs toward\n * the pointer, every following point springs toward the one before it, and each ribbon gets a\n * different stiffness so they fan out and overtake each other. Ribbons are drawn as tapered,\n * gently twisting curves with a head-to-tail gradient. With no pointer they drift along a slow\n * Lissajous path. Physics runs at a fixed 60Hz step, so the motion is identical at any refresh rate.\n */\nexport function RibbonTrail({\n  ribbonCount = 5,\n  thickness = 18,\n  length = 34,\n  stiffness = 0.5,\n  accent = \"#FF7448\",\n  secondary = \"#B6F3D1\",\n  idle = true,\n  className = \"\",\n  children,\n  ...props\n}: RibbonTrailProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n\n    const count = Math.max(1, Math.min(12, Math.round(ribbonCount)));\n    const points = Math.max(6, Math.min(120, Math.round(length)));\n    const width0 = Math.max(1, thickness);\n    const stiff = Math.min(1, Math.max(0, stiffness));\n    const segment = 9 + width0 * 0.2; // max link length in px\n    const rest = segment * 0.8; // resting link length in px\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let raf = 0;\n    let last = 0;\n    let pending = 0;\n    let moving = true;\n    let inView = false;\n    let clock = 0; // simulation time, in steps\n    let strands: Strand[] = [];\n    const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, down: false };\n\n    /** The idle path: a slow Lissajous figure around the centre. `t` is in steps. */\n    const lissajous = (t: number): [number, number] => {\n      const s = t / 60;\n      return [\n        width / 2 + Math.sin(s * 0.53 + 0.6) * width * 0.32 + Math.sin(s * 1.37) * width * 0.04,\n        height / 2 + Math.sin(s * 0.81) * height * 0.28 + Math.cos(s * 1.71) * height * 0.04,\n      ];\n    };\n\n    const build = () => {\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      const a = legible(resolveColor(accent, [255, 116, 72]), ink);\n      const b = legible(resolveColor(secondary, [182, 243, 209]), ink);\n      const prev = strands;\n      const fresh: boolean[] = [];\n      strands = Array.from({ length: count }, (_, s): Strand => {\n        const u = count === 1 ? 0 : s / (count - 1);\n        const c = a.map((v, k) => Math.round(v + (b[k] - v) * u));\n        const angle = s * 2.39996; // golden angle spreads the head offsets evenly\n        const r = (10 + width0 * 1.4) * Math.sqrt(u);\n        const old: Strand | undefined = prev[s];\n        const keep = old !== undefined && old.x.length === points;\n        fresh[s] = !keep;\n        return {\n          x: keep ? old.x : new Float32Array(points),\n          y: keep ? old.y : new Float32Array(points),\n          vx: keep ? old.vx : new Float32Array(points),\n          vy: keep ? old.vy : new Float32Array(points),\n          head: (0.028 + stiff * 0.07) * (1 - u * 0.55),\n          headDamp: 0.86 - u * 0.05,\n          follow: (0.3 + stiff * 0.25) * (1 - u * 0.3),\n          ox: Math.cos(angle) * r,\n          oy: Math.sin(angle) * r,\n          twist: s * 1.3,\n          color: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.92)`,\n          mid: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.55)`,\n          fade: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0)`,\n        };\n      });\n      // New chains start laid out along the idle path (walking back in time, one link length per\n      // point), so the first frame — and the reduced-motion still — is already a full composition.\n      strands.forEach((st, s) => {\n        if (!fresh[s]) return;\n        let t = clock - s * 9;\n        let [px, py] = lissajous(t);\n        st.x[0] = px + st.ox;\n        st.y[0] = py + st.oy;\n        for (let i = 1, guard = 0; i < points; i++) {\n          let walked = 0;\n          while (walked < rest && guard++ < 40000) {\n            t -= 0.5;\n            const [nx, ny] = lissajous(t);\n            walked += Math.hypot(nx - px, ny - py);\n            px = nx;\n            py = ny;\n          }\n          st.x[i] = px + st.ox;\n          st.y[i] = py + st.oy;\n        }\n      });\n    };\n\n    /** One fixed 60Hz step. Returns true while anything is still moving. */\n    const step = () => {\n      clock++;\n      const following = ptr.inside || ptr.down;\n      let tx: number;\n      let ty: number;\n      let driven = following;\n      if (following) {\n        tx = ptr.x;\n        ty = ptr.y;\n      } else if (idle) {\n        [tx, ty] = lissajous(clock);\n        driven = true;\n      } else {\n        // No pointer and no idle drift: settle where the pointer was last.\n        tx = ptr.x || width / 2;\n        ty = ptr.y || height / 2;\n      }\n      let active = driven;\n      strands.forEach((st, s) => {\n        // While drifting, each ribbon chases a point a little further back along the idle path.\n        if (!following && idle) [tx, ty] = lissajous(clock - s * 9);\n        // Head: spring toward the target (plus this strand's offset, breathing slowly).\n        const breathe = 1 + Math.sin(clock * 0.02 + st.twist) * 0.25;\n        st.vx[0] = (st.vx[0] + (tx + st.ox * breathe - st.x[0]) * st.head) * st.headDamp;\n        st.vy[0] = (st.vy[0] + (ty + st.oy * breathe - st.y[0]) * st.head) * st.headDamp;\n        st.x[0] += st.vx[0];\n        st.y[0] += st.vy[0];\n        // Followers: each point springs toward the one ahead of it.\n        for (let i = 1; i < points; i++) {\n          // Each point springs toward a rest spot one link behind the point ahead (follow-the-leader),\n          // so ribbons keep their length and trace the head's path, with a little overshoot.\n          const lx = st.x[i] - st.x[i - 1];\n          const ly = st.y[i] - st.y[i - 1];\n          const ld = Math.hypot(lx, ly) || 1;\n          const rx = st.x[i - 1] + (lx / ld) * rest;\n          const ry = st.y[i - 1] + (ly / ld) * rest;\n          st.vx[i] = (st.vx[i] + (rx - st.x[i]) * st.follow) * 0.58;\n          st.vy[i] = (st.vy[i] + (ry - st.y[i]) * st.follow) * 0.58;\n          st.x[i] += st.vx[i];\n          st.y[i] += st.vy[i];\n          // Rope constraint: a link never stretches beyond `segment`, so fast moves drag the tail\n          // along instead of drawing long straight spans across the canvas.\n          const dx = st.x[i] - st.x[i - 1];\n          const dy = st.y[i] - st.y[i - 1];\n          const d = Math.hypot(dx, dy);\n          if (d > segment) {\n            st.x[i] = st.x[i - 1] + (dx / d) * segment;\n            st.y[i] = st.y[i - 1] + (dy / d) * segment;\n            st.vx[i] *= 0.6;\n            st.vy[i] *= 0.6;\n          }\n        }\n        if (!active) {\n          for (let i = 0; i < points; i += 3) {\n            if (Math.abs(st.vx[i]) + Math.abs(st.vy[i]) > 0.02) {\n              active = true;\n              break;\n            }\n          }\n        }\n      });\n      return active;\n    };\n\n    // Scratch buffers for the outline, reused every frame.\n    let lx = new Float32Array(points);\n    let ly = new Float32Array(points);\n    let rx = new Float32Array(points);\n    let ry = new Float32Array(points);\n\n    /** Builds a ribbon outline: offset both sides along the normal, tapering head → tail with a twist. */\n    const drawStrand = (st: Strand) => {\n      const n = points;\n      if (lx.length !== n) {\n        lx = new Float32Array(n);\n        ly = new Float32Array(n);\n        rx = new Float32Array(n);\n        ry = new Float32Array(n);\n      }\n      for (let i = 0; i < n; i++) {\n        const a = Math.max(0, i - 1);\n        const b = Math.min(n - 1, i + 1);\n        let nx = -(st.y[b] - st.y[a]);\n        let ny = st.x[b] - st.x[a];\n        const len = Math.hypot(nx, ny) || 1;\n        nx /= len;\n        ny /= len;\n        const u = i / (n - 1);\n        // Round-ish head, long taper to the tail, and a slow twist that narrows the ribbon edge-on.\n        const taper = Math.min(1, u * 9 + 0.25) * Math.pow(1 - u, 1.1);\n        const twist = 0.3 + 0.7 * Math.abs(Math.cos(st.twist + u * 3.2 + clock * 0.012));\n        const half = (width0 * taper * twist) / 2;\n        lx[i] = st.x[i] + nx * half;\n        ly[i] = st.y[i] + ny * half;\n        rx[i] = st.x[i] - nx * half;\n        ry[i] = st.y[i] - ny * half;\n      }\n      // Smooth outline: quadratic curves through midpoints on both sides.\n      ctx.beginPath();\n      ctx.moveTo(lx[0], ly[0]);\n      for (let i = 1; i < n - 1; i++) ctx.quadraticCurveTo(lx[i], ly[i], (lx[i] + lx[i + 1]) / 2, (ly[i] + ly[i + 1]) / 2);\n      ctx.lineTo(lx[n - 1], ly[n - 1]);\n      ctx.lineTo(rx[n - 1], ry[n - 1]);\n      for (let i = n - 2; i > 0; i--) ctx.quadraticCurveTo(rx[i], ry[i], (rx[i] + rx[i - 1]) / 2, (ry[i] + ry[i - 1]) / 2);\n      ctx.lineTo(rx[0], ry[0]);\n      ctx.closePath();\n      const grad = ctx.createLinearGradient(st.x[0], st.y[0], st.x[n - 1], st.y[n - 1]);\n      grad.addColorStop(0, st.color);\n      grad.addColorStop(0.55, st.mid);\n      grad.addColorStop(1, st.fade);\n      ctx.fillStyle = grad;\n      ctx.fill();\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      // Loosest ribbon first, so the tightest one rides on top.\n      for (let s = strands.length - 1; s >= 0; s--) drawStrand(strands[s]);\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : STEP_MS);\n      last = now;\n      if (ptr.inside || ptr.down) {\n        const rect = canvas.getBoundingClientRect();\n        ptr.x = ptr.clientX - rect.left;\n        ptr.y = ptr.clientY - rect.top;\n      }\n      // Fixed 60Hz steps. A short (120/144Hz) frame may run no step at all; it then keeps the\n      // previous \"still moving\" verdict instead of concluding the ribbons have settled.\n      pending = Math.min(pending + dt, STEP_MS * 4);\n      if (pending >= STEP_MS) moving = false;\n      while (pending >= STEP_MS) {\n        moving = step() || moving;\n        pending -= STEP_MS;\n      }\n      draw();\n      if (moving) schedule();\n      else last = 0;\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !document.hidden && !reduceMotion) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && strands.length) return;\n      const first = strands.length === 0;\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      if (first) clock = 1440; // a spot on the idle path where the opening (and reduced-motion) pose sweeps across the centre\n      build();\n      draw();\n      moving = true;\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      build();\n      if (!raf) draw();\n    };\n\n    const locate = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      ptr.clientX = e.clientX;\n      ptr.clientY = e.clientY;\n      ptr.x = e.clientX - rect.left;\n      ptr.y = e.clientY - rect.top;\n      return ptr.x >= 0 && ptr.y >= 0 && ptr.x <= rect.width && ptr.y <= rect.height;\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && !ptr.down) return;\n      const inside = locate(e);\n      if (e.pointerType === \"mouse\") ptr.inside = inside;\n      if (inside) {\n        moving = true;\n        schedule();\n      }\n    };\n    // Touch and pen steer the ribbons while pressed; listeners are passive, so scrolling still works.\n    const onPointerDown = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\") return;\n      if (!locate(e)) return;\n      ptr.down = true;\n      moving = true;\n      schedule();\n    };\n    const onPointerUp = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\") return;\n      ptr.down = false;\n      moving = true;\n      schedule();\n    };\n    const onPointerLeave = () => {\n      ptr.inside = false;\n      moving = true;\n      schedule();\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    const passive = { passive: true } as const;\n    const listen = (on: boolean) => {\n      if (on) {\n        window.addEventListener(\"pointermove\", onPointerMove, passive);\n        window.addEventListener(\"pointerdown\", onPointerDown, passive);\n        window.addEventListener(\"pointerup\", onPointerUp, passive);\n        window.addEventListener(\"pointercancel\", onPointerUp, passive);\n        document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        window.removeEventListener(\"pointerdown\", onPointerDown);\n        window.removeEventListener(\"pointerup\", onPointerUp);\n        window.removeEventListener(\"pointercancel\", onPointerUp);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n      }\n    };\n\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        if (!reduceMotion) listen(true);\n        moving = true;\n        schedule();\n      } else {\n        listen(false);\n        ptr.inside = false;\n        ptr.down = false;\n        stop();\n      }\n    });\n\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    io.observe(canvas);\n    const unobserveTheme = observeTheme(canvas, onThemeChange);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      unobserveTheme();\n      listen(false);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [ribbonCount, thickness, length, stiffness, accent, secondary, idle, reduceMotion]);\n\n  return (\n    <div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100\"\n      />\n      {children != null && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/ribbon-trail"
}
