{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gradient-carousel",
  "title": "Gradient Carousel",
  "description": "A looping 3D card carousel you turn by wheel, drag or arrow keys, over a blurred gradient drawn in the colours of the centre image. No dependencies.",
  "files": [
    {
      "path": "registry/mewo/ui/gradient-carousel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface GradientCarouselProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Image URLs shown as cards. The backdrop takes its colours out of whichever card is nearest the centre. */\n  images?: string[]\n  /** Most a card turns, in degrees, as it moves away from the centre. */\n  maxRotationDegrees?: number\n  /** How far forward, in pixels, the centre card sits ahead of the ones at the edge. */\n  maxDepthPx?: number\n  /** Scale cards shrink to as they move away from the centre, 0 to 1. */\n  minScale?: number\n  /** Gap between cards in pixels. */\n  cardGap?: number\n  /** Velocity kept per frame, 0.5 to 0.99. Lower values stop the glide sooner. */\n  frictionFactor?: number\n  /** Multiplier on wheel movement. 0 leaves the wheel to the page. */\n  wheelSensitivity?: number\n  /** Multiplier on drag movement. */\n  dragSensitivity?: number\n  /** Blur applied to the gradient backdrop, in pixels. */\n  backgroundBlur?: number\n  /** Radius of the backdrop's colour pools, 0 to 1. */\n  gradientSize?: number\n  /** Opacity of the backdrop's colour pools, 0 to 1. */\n  gradientIntensity?: number\n  /** Move one card per press of the left and right arrow keys while the carousel has focus. */\n  enableKeyboard?: boolean\n  /** Called with the index of the card nearest the centre whenever it changes. */\n  onCardChange?: (index: number) => void\n  /** Width to height ratio of each card. */\n  cardAspectRatio?: number\n  /** Card centred when the carousel first renders. */\n  initialIndex?: number\n}\n\ntype Rgb = [number, number, number]\ntype Palette = [Rgb, Rgb]\n\n// placeholder cats, each a fixed one from cataas.com, so the carousel shows the same pictures every time\nexport const DEFAULT_IMAGES = [\"1KeQpy7eHqi0SFmc\", \"1Y3dpssxcbHPEkfO\", \"2lnVocnpd25cUka7\", \"2xkeeR99sD9uaqre\", \"3B3hpEDNeHSVKii0\", \"AVCQprn27w2b4mLB\", \"bJCwfolcg9LYnc61\", \"5dXZ2o7tBtnsFIYH\"].map((id) => `https://cataas.com/cat/${id}`)\n\n// Scale of the centre card; it reads larger than this because it also sits forward.\nconst CENTRE_SCALE = 0.95\n// Cards soften over the last stretch before the stage edge.\nconst EDGE_BLUR = 2\nconst BLUR_START = 0.85\n// Share of a wheel tick that becomes velocity, in pixels per frame.\nconst WHEEL_IMPULSE = 0.32\n// Below this many pixels per frame the glide is over.\nconst MIN_VELOCITY = 0.02\n// A drag held still this long before release has no speed left to carry.\nconst FLING_TIMEOUT = 80\n// Images are read at this size to find their colours.\nconst SAMPLE_SIZE = 16\n// The backdrop is blurred anyway, so it is drawn at a fraction of the stage's size.\nconst CANVAS_SCALE = 4\nconst POOL_RADIUS = 0.75\nconst NEUTRAL: Palette = [\n  [128, 128, 128],\n  [96, 96, 96],\n]\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))\nconst modulo = (value: number, count: number) => ((value % count) + count) % count\n\n/** Wraps a card's distance to the centre, in cards, into the loop [-count / 2, count / 2). */\nexport function wrapOffset(value: number, count: number): number {\n  if (count <= 0) return 0\n  return modulo(value + count / 2, count) - count / 2\n}\n\n/** How a card sits for a distance of 0 at the centre to 1 at the stage edge or beyond. */\nexport function carouselPose(distance: number, maxRotation: number, maxDepth: number, minScale: number) {\n  const d = clamp(Math.abs(distance), 0, 1)\n  return {\n    rotate: maxRotation * d,\n    depth: maxDepth * (1 - d),\n    scale: CENTRE_SCALE + (minScale - CENTRE_SCALE) * d,\n    blur: EDGE_BLUR * clamp((d - BLUR_START) / (1 - BLUR_START), 0, 1),\n  }\n}\n\n/** The two most common colours in RGBA pixel data, the second chosen to differ visibly. */\nexport function extractPalette(pixels: ArrayLike<number>): Palette {\n  const buckets = new Map<number, { r: number; g: number; b: number; n: number }>()\n  for (let i = 0; i + 3 < pixels.length; i += 4) {\n    if (pixels[i + 3] < 128) continue\n    // Three bits per channel groups near-identical shades into one bucket.\n    const key = ((pixels[i] >> 5) << 6) | ((pixels[i + 1] >> 5) << 3) | (pixels[i + 2] >> 5)\n    const bucket = buckets.get(key) ?? { r: 0, g: 0, b: 0, n: 0 }\n    bucket.r += pixels[i]\n    bucket.g += pixels[i + 1]\n    bucket.b += pixels[i + 2]\n    bucket.n += 1\n    buckets.set(key, bucket)\n  }\n\n  const colours = [...buckets.values()]\n    .sort((a, b) => b.n - a.n)\n    .map(({ r, g, b, n }): Rgb => [Math.round(r / n), Math.round(g / n), Math.round(b / n)])\n  if (colours.length === 0) return NEUTRAL\n\n  const [first] = colours\n  const second = colours.find((colour) => Math.hypot(colour[0] - first[0], colour[1] - first[1], colour[2] - first[2]) > 60)\n  return [first, second ?? first]\n}\n\nconst blend = (a: Rgb, b: Rgb, t: number): Rgb => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]\n\nexport function GradientCarousel({\n  images = DEFAULT_IMAGES,\n  maxRotationDegrees = 28,\n  maxDepthPx = 140,\n  minScale = 0.92,\n  cardGap = 28,\n  frictionFactor = 0.9,\n  wheelSensitivity = 0.6,\n  dragSensitivity = 1,\n  backgroundBlur = 24,\n  gradientSize = 0.65,\n  gradientIntensity = 0.7,\n  enableKeyboard = true,\n  onCardChange,\n  cardAspectRatio = 0.8,\n  initialIndex = 0,\n  className,\n  ...props\n}: GradientCarouselProps) {\n  const trackRef = React.useRef<HTMLDivElement>(null)\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const cardRefs = React.useRef<(HTMLDivElement | null)[]>([])\n  const palettesRef = React.useRef<(Palette | null)[]>([])\n  const repaintRef = React.useRef<(() => void) | null>(null)\n  // Where the carousel has been turned to, in cards, kept across prop changes.\n  const positionRef = React.useRef<number | null>(null)\n  const activeRef = React.useRef(-1)\n  const onCardChangeRef = React.useRef(onCardChange)\n\n  React.useEffect(() => {\n    onCardChangeRef.current = onCardChange\n  }, [onCardChange])\n\n  const count = images.length\n  const start = count > 0 ? modulo(Math.round(initialIndex), count) : 0\n\n  React.useEffect(() => {\n    palettesRef.current = images.map(() => null)\n    const sample = document.createElement(\"canvas\")\n    sample.width = sample.height = SAMPLE_SIZE\n    const context = sample.getContext(\"2d\", { willReadFrequently: true })\n    if (!context) return\n\n    const loaders = images.map((src, index) => {\n      const image = new Image()\n      // Reading pixels needs the image served with CORS headers. Without them the card\n      // still shows and the backdrop keeps its neutral glow.\n      image.crossOrigin = \"anonymous\"\n      image.onload = () => {\n        try {\n          context.clearRect(0, 0, SAMPLE_SIZE, SAMPLE_SIZE)\n          context.drawImage(image, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE)\n          palettesRef.current[index] = extractPalette(context.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data)\n          repaintRef.current?.()\n        } catch {\n          // The canvas was tainted after all; the neutral palette stays.\n        }\n      }\n      image.src = src\n      return image\n    })\n\n    return () => {\n      for (const image of loaders) image.onload = null\n    }\n  }, [images])\n\n  React.useEffect(() => {\n    const track = trackRef.current\n    // The root's ref stays the consumer's, so the root is reached through the track.\n    const root = track?.parentElement\n    const canvas = canvasRef.current\n    if (!track || !root || count === 0) return\n\n    // Reduced motion keeps every control but drops the glide and the drifting backdrop.\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    const friction = clamp(frictionFactor, 0.5, 0.99)\n    const context = canvas?.getContext(\"2d\") ?? null\n\n    let step = 1\n    let half = 1\n    let position = positionRef.current ?? start\n    // Pixels per frame at 60 frames a second, the unit `frictionFactor` is defined in.\n    let velocity = 0\n    let fling = 0\n    let lastX = 0\n    let lastMove = 0\n    let dragging = false\n    let visible = true\n    let clock = 0\n    let raf = 0\n    let last = 0\n\n    const measure = () => {\n      step = (cardRefs.current[0]?.offsetWidth ?? 0) + cardGap || 1\n      half = root.clientWidth / 2 || 1\n      if (canvas) {\n        canvas.width = Math.max(1, Math.round(root.clientWidth / CANVAS_SCALE))\n        canvas.height = Math.max(1, Math.round(root.clientHeight / CANVAS_SCALE))\n      }\n    }\n\n    const pose = () => {\n      for (let i = 0; i < count; i++) {\n        const card = cardRefs.current[i]\n        if (!card) continue\n        const x = wrapOffset(i - position, count) * step\n        const { rotate, depth, scale, blur } = carouselPose(x / half, maxRotationDegrees, maxDepthPx, minScale)\n        // Cards turn to face the centre: the outer edge comes forward.\n        card.style.transform = `translate3d(calc(-50% + ${x}px), -50%, ${depth}px) rotateY(${-Math.sign(x) * rotate}deg) scale(${scale})`\n        card.style.zIndex = `${Math.round(depth)}`\n        card.style.filter = blur > 0.01 ? `blur(${blur}px)` : \"\"\n      }\n\n      const active = modulo(Math.round(position), count)\n      if (active !== activeRef.current) {\n        const mounting = activeRef.current === -1\n        activeRef.current = active\n        if (!mounting) onCardChangeRef.current?.(active)\n      }\n    }\n\n    const pool = (colour: Rgb, x: number, y: number, radius: number, alpha: number) => {\n      if (!context) return\n      const [r, g, b] = colour.map(Math.round)\n      const gradient = context.createRadialGradient(x, y, 0, x, y, radius)\n      gradient.addColorStop(0, `rgba(${r},${g},${b},${alpha})`)\n      gradient.addColorStop(1, `rgba(${r},${g},${b},0)`)\n      context.fillStyle = gradient\n      context.fillRect(0, 0, context.canvas.width, context.canvas.height)\n    }\n\n    const paint = () => {\n      if (!context) return\n      const { width, height } = context.canvas\n      context.clearRect(0, 0, width, height)\n      const radius = Math.max(width, height) * POOL_RADIUS * clamp(gradientSize, 0, 1)\n      if (radius <= 0) return\n\n      // The colours slide between the two cards either side of the centre.\n      const base = Math.floor(position)\n      const mix = position - base\n      const near = palettesRef.current[modulo(base, count)] ?? NEUTRAL\n      const next = palettesRef.current[modulo(base + 1, count)] ?? NEUTRAL\n      const alpha = clamp(gradientIntensity, 0, 1)\n      pool(blend(near[0], next[0], mix), width * (0.32 + 0.08 * Math.sin(clock * 0.31)), height * (0.4 + 0.1 * Math.cos(clock * 0.23)), radius, alpha)\n      pool(blend(near[1], next[1], mix), width * (0.68 + 0.08 * Math.cos(clock * 0.27)), height * (0.6 + 0.1 * Math.sin(clock * 0.19)), radius, alpha)\n    }\n\n    const show = () => {\n      positionRef.current = position\n      pose()\n      paint()\n    }\n\n    const tick = (now: number) => {\n      const dt = clamp((now - last) / 1000, 0, 0.1)\n      last = now\n      const frames = dt * 60\n\n      const gliding = Math.abs(velocity) > MIN_VELOCITY\n      if (gliding) {\n        // The exact distance covered while the velocity decays, whatever the frame rate.\n        position += (velocity * (Math.pow(friction, frames) - 1)) / Math.log(friction) / step\n        velocity *= Math.pow(friction, frames)\n        // The backdrop drifts while the cards move, so a carousel at rest draws nothing at all.\n        clock += dt\n      } else {\n        velocity = 0\n      }\n\n      show()\n      raf = visible && gliding ? requestAnimationFrame(tick) : 0\n    }\n\n    const wake = () => {\n      if (reduced) {\n        show()\n      } else if (!raf) {\n        last = performance.now()\n        raf = requestAnimationFrame(tick)\n      }\n    }\n\n    const onWheel = (event: WheelEvent) => {\n      if (wheelSensitivity <= 0) return\n      // The loop has no end to scroll past, so the wheel is taken while the pointer is over it.\n      event.preventDefault()\n      const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? root.clientHeight : 1\n      const delta = (Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY) * unit\n      if (reduced) position += (delta * wheelSensitivity) / step\n      else velocity += delta * wheelSensitivity * WHEEL_IMPULSE\n      wake()\n    }\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (event.pointerType === \"mouse\" && event.button !== 0) return\n      dragging = true\n      lastX = event.clientX\n      lastMove = performance.now()\n      velocity = 0\n      fling = 0\n      root.setPointerCapture?.(event.pointerId)\n      root.style.cursor = \"grabbing\"\n    }\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (!dragging) return\n      const dx = (event.clientX - lastX) * dragSensitivity\n      lastX = event.clientX\n      lastMove = performance.now()\n      position -= dx / step\n      // Averaging tames a jittery last sample before it becomes the glide's speed.\n      fling = fling * 0.6 - dx * 0.4\n      wake()\n    }\n\n    const onPointerUp = (event: PointerEvent) => {\n      if (!dragging) return\n      dragging = false\n      if (root.hasPointerCapture?.(event.pointerId)) root.releasePointerCapture(event.pointerId)\n      root.style.cursor = \"\"\n      if (!reduced && performance.now() - lastMove < FLING_TIMEOUT) velocity = fling\n      wake()\n    }\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      const direction = event.key === \"ArrowRight\" ? 1 : event.key === \"ArrowLeft\" ? -1 : 0\n      if (direction === 0) return\n      event.preventDefault()\n      // Either way a press travels exactly one card.\n      if (reduced) position = Math.round(position) + direction\n      else velocity += direction * step * -Math.log(friction)\n      wake()\n    }\n\n    measure()\n    show()\n    repaintRef.current = paint\n    wake()\n\n    const resizeObserver = new ResizeObserver(() => {\n      measure()\n      show()\n    })\n    resizeObserver.observe(root)\n    // Off screen a glide pauses where it is, and carries on when the carousel is back.\n    const intersectionObserver = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting\n      if (visible) wake()\n    })\n    intersectionObserver.observe(root)\n\n    root.addEventListener(\"wheel\", onWheel, { passive: false })\n    root.addEventListener(\"pointerdown\", onPointerDown)\n    root.addEventListener(\"pointermove\", onPointerMove)\n    root.addEventListener(\"pointerup\", onPointerUp)\n    root.addEventListener(\"pointercancel\", onPointerUp)\n    if (enableKeyboard) root.addEventListener(\"keydown\", onKeyDown)\n\n    return () => {\n      cancelAnimationFrame(raf)\n      repaintRef.current = null\n      resizeObserver.disconnect()\n      intersectionObserver.disconnect()\n      root.removeEventListener(\"wheel\", onWheel)\n      root.removeEventListener(\"pointerdown\", onPointerDown)\n      root.removeEventListener(\"pointermove\", onPointerMove)\n      root.removeEventListener(\"pointerup\", onPointerUp)\n      root.removeEventListener(\"pointercancel\", onPointerUp)\n      root.removeEventListener(\"keydown\", onKeyDown)\n    }\n  }, [count, start, maxRotationDegrees, maxDepthPx, minScale, cardGap, frictionFactor, wheelSensitivity, dragSensitivity, gradientSize, gradientIntensity, enableKeyboard])\n\n  return (\n    <div\n      data-slot=\"gradient-carousel\"\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label=\"Image carousel\"\n      tabIndex={enableKeyboard ? 0 : undefined}\n      className={cn(\n        \"relative h-[600px] w-full cursor-grab touch-pan-y overflow-hidden bg-background select-none [perspective:1800px] focus-visible:outline-2 focus-visible:outline-ring\",\n        className\n      )}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 size-full\"\n        style={{ filter: `blur(${Math.max(0, backgroundBlur)}px) saturate(1.05)` }}\n      />\n      <div ref={trackRef} className=\"absolute inset-0 [transform-style:preserve-3d]\">\n        {images.map((src, i) => (\n          <div\n            key={i}\n            ref={(el) => {\n              cardRefs.current[i] = el\n            }}\n            data-slot=\"gradient-carousel-card\"\n            aria-hidden\n            className=\"absolute top-1/2 left-1/2 w-[26%] max-w-[360px] min-w-32 rounded-[32px] bg-muted bg-cover bg-center shadow-2xl [backface-visibility:hidden] will-change-transform\"\n            style={{\n              aspectRatio: cardAspectRatio,\n              backgroundImage: `url(${JSON.stringify(src)})`,\n              // A flat row for the first paint and for scripting off; the effect poses it in 3D.\n              transform: `translate3d(calc(-50% + ${wrapOffset(i - start, count)} * (100% + ${cardGap}px)), -50%, 0)`,\n            }}\n          />\n        ))}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "added": "2026-10-01"
  },
  "categories": [
    "card"
  ],
  "type": "registry:ui"
}