{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "molten-ring-carousel",
  "type": "registry:ui",
  "title": "Molten Ring Carousel",
  "description": "A carousel with no mesh and no image elements: every card is a rounded-box distance field, and one fullscreen pass takes a smooth minimum across all of them. Cards approaching fuse instead of overlapping, and cards separating trail strands that narrow, hang and part on their own, because a strand is another term in the same field. The pointer paints nothing - it widens the fusion radius beneath itself and tips nearby cards toward it.",
  "files": [
    {
      "path": "registry/crafterui/ui/molten-ring-carousel.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n// A carousel whose cards behave like drops of liquid held on glass.\n//\n// The circle is far larger than the frame and its centre sits well off to the\n// left, so only a sliver of it ever crosses the viewport - which reads as a\n// tall arc of work sweeping past with one card square to the viewer. Scroll,\n// drag or swipe turns it.\n//\n// There is no mesh here and there are no image elements. Each card is a\n// rounded-box distance field and the frame is one fullscreen pass taking a\n// smooth minimum over the lot. That single operator buys the physics: two cards\n// approaching never overlap, their fields fuse; two separating leave a strand\n// behind, because the strand is one more term in the same field and narrows,\n// hangs and finally parts of its own accord as the distance grows.\n//\n// The cursor is never painted. It widens the fusion radius beneath itself, tips\n// nearby cards toward it, elbows their neighbours aside, and draws strands out\n// between them.\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface MoltenRingItem {\n  /** Cover art. Cross-origin sources must send CORS headers. */\n  image: string\n  /** Shown to the left of the ring while this card is at the front. */\n  title: string\n  /** The line to the right of the ring - discipline, year, whatever. */\n  meta?: string\n}\n\nexport interface MoltenRingCarouselProps\n  extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  items: MoltenRingItem[]\n  /** Wordmark in the top-left. Omit to drop it. @default undefined */\n  brand?: string\n  /** Ring radius, in stage widths. Larger flattens the arc. @default 1 */\n  arc?: number\n  /** Card long edge, as a fraction of the stage width. @default 0.265 */\n  cardSize?: number\n  /** Card long edge / short edge. Art is cover-fitted into it. @default 1.5 */\n  cardRatio?: number\n  /** Fusion radius between neighbours, in card long-edges. @default 0.087 */\n  fuse?: number\n  /** String threads between cards as they pull apart. @default true */\n  threads?: boolean\n  /** Optical band that bends the image at the upper and lower borders. @default true */\n  glass?: boolean\n  /** Extra classes on the root surface. @default undefined */\n  className?: string\n}\n\n/** Each uniform-array element occupies a vec4 register; WebGL2 guarantees only\n    224. Two dozen already exceeds what the visible arc can hold. */\nconst MAX_CARDS = 24\nconst MAX_STRANDS = 24\n\n/* Every figure below is expressed as a multiple of the card's long edge, so\n   proportions survive any viewport instead of being tuned to one screen. */\nconst FUSE = 0.087 // resting blend between neighbours\nconst CORNER = 0.015\nconst CROSSFADE = 0.035 // over which neighbouring art crossfades inside the goo\nconst SPACING = 1.55 // centre to centre along the arc, in short edges\n\n/* Cursor. It contributes nothing to the picture; it only alters how the field\n   responds nearby. Take-up and let-go run at different rates on purpose - a\n   card tips toward the cursor briskly and returns at half the speed. */\nconst CURSOR_FUSE = 0.085 // blend added to the field at the cursor\nconst CURSOR_REACH = 0.65\nconst PULL = 0.065 // how far a card leans toward the cursor\nconst SWELL = 0.09\nconst REACH = 1.7 // radius of cursor influence, in card long edges\nconst GRAB = 0.14\nconst RELEASE = 0.06\nconst NEIGHBOUR_PUSH = 0.042 // how far the hovered card's neighbours get out of the way\nconst NEIGHBOUR_SCALE = 0.035\nconst NEIGHBOUR_DIM = 0.15\nconst NEIGHBOUR_REACH = 2.4\nconst WAVE = 0.01 // capillary wake off a moving cursor\nconst WAVE_FREQ = 20\nconst WAVE_SPEED = 7\n\n/* Strands. Thickest where one leaves a card, waisted at the midpoint, and\n   hanging lower the further it is drawn out. */\nconst STRAND = 0.2 // end thickness, relative to the edge it grows from\nconst STRAND_SNAP = 1.15 // gaps wider than this, in short edges, have snapped\nconst WAIST = 0.35\nconst SAG = 0.015\nconst WELD = 0.035\n\n/* Optical band running across the upper and lower borders. */\nconst BAND = 0.08 // fraction of the stage height\nconst REFRACT = 0.15\nconst SQUEEZE = 0.05\nconst RIPPLE = 0.0125\nconst RIPPLE_FREQ = 8\nconst FRINGE = 0.004\nconst SHEEN = 0.05\n\nconst WOBBLE = 0.0075 // surface tension noise while the ring is moving\n\n/* Turn. The ring eases after a target and settles with a card facing front. */\nconst WHEEL = 0.0022 // slots per px of wheel delta\nconst DRAG = 0.007 // ... and per px dragged\nconst EASE = 0.08\nconst SNAP_IDLE = 260\nconst SNAP_EASE = 0.06\nconst CLICK_SLOP = 6\nconst CLICK_MS = 700\n\n/* Arrival. The deck begins fused into a single mass at the front and the\n   circle draws it apart into slots, which is what produces the strands. */\nconst ENTRY_MS = 2600\n\nconst THEME_EVERY = 20\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))\nconst inOutCubic = (t: number) =>\n  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2\nconst outCubic = (t: number) => 1 - Math.pow(1 - clamp(t, 0, 1), 3)\n\nconst QUAD_VERT = /* glsl */ `#version 300 es\nin vec2 aPos;\nout vec2 vUv;\nvoid main() {\n  vUv = aPos;\n  gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0);\n}`\n\nconst RING_FRAG = /* glsl */ `#version 300 es\nprecision highp float;\n\n#define MAX_CARDS ${MAX_CARDS}\n#define MAX_STRANDS ${MAX_STRANDS}\n\nin vec2 vUv;\nout vec4 fragColor;\n\nuniform vec2  uResolution;   // px\nuniform vec2  uSize;         // resting card size in px - long edge, short edge\nuniform float uCorner;\n\nuniform float uCount;\nuniform vec2  uCentre[MAX_CARDS];   // centre in px, origin at the stage centre\nuniform float uAngle[MAX_CARDS];   // radians\n// xy = per-axis scale, z = brightness, w = atlas cell index. Packed together\n// because a uniform-array slot is a full vec4 register regardless of the\n// declared type, so zw are free once xy are spent.\nuniform vec4  uCardState[MAX_CARDS];\n\nuniform float uStrandCount;\nuniform vec2  uStrandA[MAX_STRANDS];\nuniform vec2  uStrandB[MAX_STRANDS];\nuniform vec4  uStrandPar[MAX_STRANDS];  // end thickness, waist, hang, weld width\n\nuniform float uFuse;            // blend strength, px\nuniform float uJitter;\nuniform float uTime;\nuniform vec3  uColor;        // untextured fallback, and the loading silhouette\n\nuniform sampler2D uAtlas;    // one sheet; sampler arrays need a constant index\nuniform vec2  uGrid;         // cells across, down\nuniform float uCrossfade;        // px over which neighbouring art crossfades\nuniform float uHasArt;\n\nuniform vec4  uCursor;        // xy in px, z = engaged 0..1, w = added fusion\nuniform vec4  uWake;         // radius px, amplitude px, spatial freq, rate\n\nuniform float uLipDepth;         // glass lip depth, px - 0 turns it off\nuniform vec4  uLip;        // refract px, squeeze, ripple px, ripple frequency\nuniform float uFringe;\nuniform float uSheen;\n\nvec2 atlasUV(vec2 uv, float idx) {\n  return (vec2(mod(idx, uGrid.x), floor(idx / uGrid.x)) + uv) / uGrid;\n}\n\n/* Bilinear value noise. The perturbation is small and rides on a surface\n   already in motion, so a simplex implementation would cost twenty more lines\n   for a difference nobody could pick out. */\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nfloat noise(vec2 p) {\n  vec2 i = floor(p), f = fract(p);\n  f = f * f * (3.0 - 2.0 * f);\n  return mix(\n    mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),\n    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x),\n    f.y\n  ) * 2.0 - 1.0;\n}\n\nfloat sdRoundBox(vec2 p, vec2 b, float r) {\n  vec2 q = abs(p) - b + r;\n  return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;\n}\n\n/* One strand spanning two cards: a slab laid centre to centre, as thick at\n   each end as the edge it grows from, waisted at the midpoint and hanging under\n   its own weight.\n\n   Swept as a box, not a capsule. A capsule's circular cross-section would\n   balloon past the cards' own flat faces once they fused; a box tucks inside\n   them, so a merged pair keeps the outline of a single card. */\nfloat sdStrand(vec2 p, vec2 a, vec2 b, float rEnd, float rMid, float sag) {\n  vec2 ba = b - a;\n  float len = length(ba);\n  if (len < 0.001) return 1e6;\n\n  vec2 dir = ba / len;\n  vec2 nrm = vec2(-dir.y, dir.x);\n  vec2 q = p - (a + b) * 0.5;\n  float along = dot(q, dir);\n  float across = dot(q, nrm);\n\n  float h = clamp(along / len + 0.5, 0.0, 1.0);\n  float bell = sin(3.14159265 * h);        // peaks mid-span, vanishes at both ends\n  across += sag * bell * nrm.y;            // hang, projected onto the perpendicular\n  float r = mix(rMid, rEnd, pow(1.0 - bell, 1.7));\n\n  // Square ends, which finish inside the cards and are never on screen.\n  return max(abs(along) - len * 0.5, abs(across) - r);\n}\n\n/* Smooth minimum - the one operator the whole look rests on. Against the 1e6\n   sentinel it degrades cleanly to an ordinary min(). */\nfloat smin(float a, float b, float k) {\n  if (k <= 0.0001) return min(a, b);\n  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);\n  return mix(b, a, h) - k * h * (1.0 - h);\n}\n\n/* The upper and lower margins behave like the ground edge of a thick pane.\n   Since the whole scene is evaluated from p, displacing p at this point bends\n   cards and strands together in one pass - no extra target, no second warp. */\nfloat lipWarp(inout vec2 p) {\n  if (uLipDepth <= 0.5) return 0.0;\n  float dy = abs(p.y) - (uResolution.y * 0.5 - uLipDepth);\n  if (dy <= 0.0) return 0.0;\n\n  float t = clamp(dy / uLipDepth, 0.0, 1.0);\n  // A circular falloff: almost flat where the band begins and dropping away\n  // steeply at the boundary, which is what sells depth over a plain gradient.\n  float bend = 1.0 - sqrt(max(0.0, 1.0 - t * t));\n\n  // Sampling from deeper inside displaces detail toward the margin, so the\n  // image elongates into the band and grows as it nears the boundary.\n  p.y -= sign(p.y) * bend * (uLip.x + sin(p.x * uLip.w) * uLip.z);\n  p.x *= 1.0 - bend * uLip.y;\n  return bend;\n}\n\nvoid main() {\n  vec2 p = (vUv - 0.5) * uResolution;\n  float bend = lipWarp(p);\n\n  // Measured after the displacement so the cursor lives in the same warped\n  // space the cards do - carried into the band, its influence bends along with\n  // them instead of lying flat across the top.\n  float toCursor = length(p - uCursor.xy);\n\n  // Fusion radius rises within a pool centred on the pointer, slackening the\n  // field just where contact occurs while it stays taut elsewhere. Computed once\n  // per pixel rather than per card, costing a single length() for the loop.\n  float k = uFuse;\n  if (uCursor.z > 0.001) {\n    float t = 1.0 - smoothstep(0.0, max(uWake.x, 1.0), toCursor);\n    k += uCursor.w * uCursor.z * t * t;\n  }\n\n  float d = 1e6;\n\n  // The nearest two cards, carried alongside the distance so colour resolves in\n  // the same loop rather than a second one. Where the field bridges a pair both\n  // register as close, which is precisely where the crossfade should sit.\n  float d0 = 1e6, d1 = 1e6;\n  vec2 uv0 = vec2(0.5), uv1 = vec2(0.5);\n  float im0 = 0.0, im1 = 0.0;\n  float dm0 = 1.0, dm1 = 1.0;\n\n  float halfSpan = length(uSize) * 0.5;\n\n  for (int i = 0; i < MAX_CARDS; i++) {\n    if (float(i) >= uCount) break;\n\n    vec4 st = uCardState[i];\n    float grown = max(st.x, st.y);\n    if (grown <= 0.0001) continue;\n\n    vec2 q = p - uCentre[i];\n    // Beyond this radius a card cannot reach the surface, so it is rejected\n    // before any transcendentals run - which is what makes two dozen of them\n    // affordable. Sized off the card itself, since one swollen beneath the\n    // cursor covers more ground than its resting footprint, as does the wider\n    // fusion radius around it.\n    float cull = halfSpan * grown + k + uJitter + 8.0;\n    if (dot(q, q) > cull * cull) continue;\n\n    float ca = cos(uAngle[i]), sa = sin(uAngle[i]);\n    q = vec2(q.x * ca + q.y * sa, -q.x * sa + q.y * ca);\n\n    vec2 halfSize = max(uSize * 0.5 * st.xy, vec2(0.0001));\n    // Opens as a lozenge and settles into the rounded rectangle as it grows, so\n    // arrival reads as a droplet finding its form, not a box being scaled.\n    float rMax = min(halfSize.x, halfSize.y);\n    float r = min(rMax, mix(rMax, uCorner, smoothstep(0.30, 1.0, min(st.x, st.y))));\n\n    float di = sdRoundBox(q, halfSize, r);\n    d = smin(d, di, k);\n\n    // Clamped so that fused area beyond a card's own bounds takes that card's\n    // edge pixels instead of tiling or spilling into the adjacent atlas cell.\n    vec2 luv = clamp(q / (2.0 * halfSize) + 0.5, 0.004, 0.996);\n    luv.y = 1.0 - luv.y;\n\n    if (di < d0) {\n      d1 = d0; uv1 = uv0; im1 = im0; dm1 = dm0;\n      d0 = di; uv0 = luv; im0 = st.w; dm0 = st.z;\n    } else if (di < d1) {\n      d1 = di; uv1 = luv; im1 = st.w; dm1 = st.z;\n    }\n  }\n\n  for (int i = 0; i < MAX_STRANDS; i++) {\n    if (float(i) >= uStrandCount) break;\n    vec4 par = uStrandPar[i];\n    // Negative radii are allowed, and wanted: they raise the strand's field\n    // above the surface so it withdraws smoothly instead of stalling at zero\n    // and leaving a half-resolved hairline behind.\n    if (par.x <= -3.0) continue;\n    vec2 a = uStrandA[i], b = uStrandB[i];\n    vec2 mid = (a + b) * 0.5;\n    float span = length(b - a) * 0.5 + par.x + par.w + 8.0;\n    if (dot(p - mid, p - mid) > span * span) continue;\n    d = smin(d, sdStrand(p, a, b, par.x, par.y, par.z), par.w);\n  }\n\n  // Surface tension, scaled to nothing at rest so a settled ring is perfectly\n  // smooth.\n  if (uJitter > 0.001) {\n    d += noise(p * 0.012 + vec2(uTime * 0.22, uTime * -0.17)) * uJitter;\n  }\n\n  // A wake trailing the cursor, expanding outward and decaying over the same\n  // radius the slackening uses, so a quick pass leaves a disturbance that\n  // persists a moment after the gesture ends.\n  if (uWake.y > 0.001) {\n    d += sin(toCursor * uWake.z - uTime * uWake.w)\n       * uWake.y * exp(-toCursor / max(uWake.x, 1.0));\n  }\n\n  // Bounded at both ends rather than only below. The rejection test above puts\n  // a discontinuity in the field, and fwidth measured across it would return a\n  // huge derivative, tracing a translucent seam along every rejection boundary.\n  float aa = clamp(fwidth(d), 0.5, 2.0);\n  float alpha = 1.0 - smoothstep(-aa, aa, d);\n  if (alpha <= 0.001) discard;\n\n  // Equal weight where the two nearest cards tie, settling on whichever leads\n  // once the margin exceeds the crossfade width. Artwork and brightness both\n  // ride this weight, so neither can leave a visible join through fused area.\n  float nearest = smoothstep(-uCrossfade, uCrossfade, d1 - d0);\n\n  vec3 col = uColor;\n  if (uHasArt > 0.5) {\n    // Branch on a uniform, keeping derivatives well defined across the quad.\n    // The offset scales with band depth, so outside it all three taps collapse\n    // onto one texel.\n    vec2 fr = vec2(uFringe * bend, 0.0);\n    vec3 c0 = vec3(\n      texture(uAtlas, atlasUV(uv0 + fr, im0)).r,\n      texture(uAtlas, atlasUV(uv0, im0)).g,\n      texture(uAtlas, atlasUV(uv0 - fr, im0)).b\n    );\n    vec3 c1 = vec3(\n      texture(uAtlas, atlasUV(uv1 + fr, im1)).r,\n      texture(uAtlas, atlasUV(uv1, im1)).g,\n      texture(uAtlas, atlasUV(uv1 - fr, im1)).b\n    );\n    col = mix(c1, c0, nearest);\n  }\n\n  // Every card except the selected one is attenuated, leaving that card the\n  // only fully lit surface in the frame.\n  col *= mix(dm1, dm0, nearest);\n\n  // A little brightening where the band is steepest, so it reads as a surface\n  // taking light and not purely as a distortion.\n  col += bend * uSheen;\n\n  fragColor = vec4(col, alpha);\n}`\n\nfunction build(gl: WebGL2RenderingContext, vert: string, frag: string) {\n  const program = gl.createProgram()\n  if (!program) return null\n  for (const [type, source] of [\n    [gl.VERTEX_SHADER, vert],\n    [gl.FRAGMENT_SHADER, frag],\n  ] as const) {\n    const shader = gl.createShader(type)\n    if (!shader) return null\n    gl.shaderSource(shader, source)\n    gl.compileShader(shader)\n    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n      console.error(gl.getShaderInfoLog(shader))\n      return null\n    }\n    gl.attachShader(program, shader)\n    gl.deleteShader(shader)\n  }\n  gl.bindAttribLocation(program, 0, \"aPos\")\n  gl.linkProgram(program)\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    console.error(gl.getProgramInfoLog(program))\n    return null\n  }\n  return program\n}\n\n/** Cached uniform handles. getUniformLocation performs a string lookup on every\n    call, and this sits inside the per-frame path. */\nfunction uniforms(gl: WebGL2RenderingContext, program: WebGLProgram) {\n  const cache = new Map<string, WebGLUniformLocation | null>()\n  return (name: string) => {\n    let loc = cache.get(name)\n    if (loc === undefined) {\n      loc = gl.getUniformLocation(program, name)\n      cache.set(name, loc)\n    }\n    return loc\n  }\n}\n\n/** Resolves any CSS colour to 0-1 RGB by asking the browser instead of parsing\n    it. Theme tokens here are written in oklch, and naive comma-splitting of the\n    computed string yields a confidently incorrect near-black. */\nfunction colorReader() {\n  const probe = document.createElement(\"canvas\")\n  probe.width = probe.height = 1\n  const ctx = probe.getContext(\"2d\", { willReadFrequently: true })\n  return (css: string): [number, number, number] => {\n    if (!ctx) return [0, 0, 0]\n    ctx.fillStyle = \"#000\"\n    ctx.fillStyle = css\n    ctx.fillRect(0, 0, 1, 1)\n    const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data\n    return [r / 255, g / 255, b / 255]\n  }\n}\n\n/** A single sheet, each cell cover-fitted. ESSL forbids indexing a sampler\n    array with a non-constant expression, ruling out one texture per card. */\nfunction packAtlas(images: HTMLImageElement[], cols: number, cell: number, ratio: number) {\n  const rows = Math.ceil(images.length / cols)\n  const sheet = document.createElement(\"canvas\")\n  sheet.width = cols * cell\n  sheet.height = rows * Math.round(cell / ratio)\n  const ctx = sheet.getContext(\"2d\")\n  if (!ctx) return sheet\n  const cellH = Math.round(cell / ratio)\n  images.forEach((image, i) => {\n    // A card that never decoded has no natural size; scaling by it yields\n    // Infinity and drawImage throws, taking every later cell with it.\n    if (!image.naturalWidth || !image.naturalHeight) return\n    const x = (i % cols) * cell\n    const y = Math.floor(i / cols) * cellH\n    const scale = Math.max(cell / image.naturalWidth, cellH / image.naturalHeight)\n    const w = image.naturalWidth * scale\n    const h = image.naturalHeight * scale\n    ctx.save()\n    ctx.beginPath()\n    ctx.rect(x, y, cell, cellH)\n    ctx.clip()\n    ctx.drawImage(image, x + (cell - w) / 2, y + (cellH - h) / 2, w, h)\n    ctx.restore()\n  })\n  return sheet\n}\n\nexport function MoltenRingCarousel({\n  items,\n  brand,\n  arc = 1,\n  cardSize = 0.265,\n  cardRatio = 1.5,\n  fuse = FUSE,\n  threads = true,\n  glass = true,\n  className,\n  ...props\n}: MoltenRingCarouselProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const [active, setActive] = React.useState(0)\n  const [reduced, setReduced] = React.useState(false)\n  const [supported, setSupported] = React.useState(true)\n\n  const settings = React.useRef({ arc, cardSize, cardRatio, fuse, threads, glass })\n  settings.current = { arc, cardSize, cardRatio, fuse, threads, glass }\n  /** Populated by the render loop so keyboard input drives the same rotation the\n    wheel does. */\n  const step = React.useRef<(by: number) => void>(() => {})\n\n  const count = Math.min(items.length, MAX_CARDS)\n  const sources = items.map((item) => item.image).join(\" \")\n\n  React.useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const read = () => setReduced(query.matches)\n    read()\n    query.addEventListener(\"change\", read)\n    return () => query.removeEventListener(\"change\", read)\n  }, [])\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas || !count) return\n    // Unpremultiplied alpha, so whatever the ring does not cover is simply the\n    // page underneath - which is how the shader stays theme-agnostic without\n    // ever being handed the palette.\n    const gl = canvas.getContext(\"webgl2\", {\n      alpha: true,\n      premultipliedAlpha: false,\n      antialias: false,\n    })\n    if (!gl) {\n      setSupported(false)\n      return\n    }\n\n    const readColor = colorReader()\n    const program = build(gl, QUAD_VERT, RING_FRAG)\n    if (!program) return\n    const u = uniforms(gl, program)\n\n    const quad = gl.createVertexArray()\n    gl.bindVertexArray(quad)\n    const buffer = gl.createBuffer()\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n    gl.bufferData(\n      gl.ARRAY_BUFFER,\n      new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]),\n      gl.STATIC_DRAW\n    )\n    gl.enableVertexAttribArray(0)\n    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)\n\n    // --- art --------------------------------------------------------------\n    const COLS = Math.min(4, count)\n    const CELL = 512\n    let atlas: WebGLTexture | null = null\n    let loaded = 0\n    const images = items.slice(0, count).map((item) => {\n      const image = new Image()\n      image.crossOrigin = \"anonymous\"\n      image.decoding = \"async\"\n      // Settled, not loaded. The sheet is all-or-nothing, so a single URL that\n      // 404s or fails CORS would otherwise hold every card untextured for the\n      // life of the component - the failure mode is a blank white ring with\n      // nothing in the console.\n      const settle = () => {\n        if (++loaded < count) return\n        // Assembled once the last image settles; packing early would leave unset\n        // cells sampling as solid black cards.\n        atlas = gl.createTexture()\n        gl.bindTexture(gl.TEXTURE_2D, atlas)\n        gl.texImage2D(\n          gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE,\n          packAtlas(images, COLS, CELL, settings.current.cardRatio)\n        )\n        // Mipmaps are skipped - lower levels would average across cell borders,\n        // and cards occupy enough pixels that minification never applies.\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n      }\n      image.onload = settle\n      image.onerror = () => {\n        console.warn(`molten-ring-carousel: ${item.image} failed to load`)\n        settle()\n      }\n      image.src = item.image\n      return image\n    })\n\n    // --- state ------------------------------------------------------------\n    let width = 0\n    let height = 0\n    let progress = 0\n    let goal = 0\n    let lastInput = 0\n    let snapped = true\n    let hovered = -1\n    let pointerX = -1\n    let pointerY = -1\n    let pointerSpeed = 0\n    let entry = 0\n    let clock = 0\n    let previous = 0\n    let ticks = 0\n    let frame = 0\n    let tween: { from: number; to: number; at: number } | null = null\n    let ink: [number, number, number] = [0, 0, 0]\n\n    const leanX = new Float32Array(count)\n    const leanY = new Float32Array(count)\n    const swell = new Float32Array(count)\n    const dim = new Float32Array(count)\n\n    const pos = new Float32Array(MAX_CARDS * 2)\n    const rot = new Float32Array(MAX_CARDS)\n    const scale = new Float32Array(MAX_CARDS * 4)\n    const strandA = new Float32Array(MAX_STRANDS * 2)\n    const strandB = new Float32Array(MAX_STRANDS * 2)\n    const strandPar = new Float32Array(MAX_STRANDS * 4)\n\n    /** Per-frame screen placement for every card, used by hit-testing and by the\n        strand solver below. */\n    const at = Array.from({ length: count }, () => ({ x: 0, y: 0, angle: 0, scale: 1 }))\n    /** Index of the slot squared up to the viewer. Half the count would land\n        between two slots whenever the count is odd, so it is floored. */\n    const FRONT = Math.floor(count / 2)\n\n    const resize = () => {\n      const w = canvas.clientWidth\n      const h = canvas.clientHeight\n      if (!w || !h) return\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\n      width = w\n      height = h\n      canvas.width = Math.round(w * dpr)\n      canvas.height = Math.round(h * dpr)\n    }\n    resize()\n    const observer = new ResizeObserver(resize)\n    observer.observe(canvas)\n\n    // --- input ------------------------------------------------------------\n    const onWheel = (event: WheelEvent) => {\n      event.preventDefault()\n      tween = null\n      goal += event.deltaY * WHEEL\n      lastInput = performance.now()\n      snapped = false\n    }\n    canvas.addEventListener(\"wheel\", onWheel, { passive: false })\n\n    step.current = (by: number) => {\n      tween = { from: goal, to: Math.round(goal) + by, at: performance.now() }\n      lastInput = performance.now()\n      snapped = true\n    }\n\n    let dragFrom: number | null = null\n    let dragTravel = 0\n    const onDown = (event: PointerEvent) => {\n      dragFrom = event.clientY\n      dragTravel = 0\n      tween = null\n      canvas.setPointerCapture(event.pointerId)\n    }\n    const onMove = (event: PointerEvent) => {\n      const box = canvas.getBoundingClientRect()\n      const nx = event.clientX - box.left\n      const ny = event.clientY - box.top\n      pointerSpeed = Math.hypot(nx - pointerX, ny - pointerY)\n      pointerX = nx\n      pointerY = ny\n      if (dragFrom !== null) {\n        const travel = dragFrom - event.clientY\n        dragTravel += Math.abs(travel)\n        dragFrom = event.clientY\n        goal += travel * DRAG\n        lastInput = performance.now()\n        snapped = false\n      }\n    }\n    const onUp = () => {\n      const wasClick = dragFrom !== null && dragTravel < CLICK_SLOP\n      dragFrom = null\n      if (!wasClick || hovered < 0) return\n      // Rotate to the front slot, taking whichever direction is shorter.\n      const want = (((hovered - FRONT) % count) + count) % count\n      tween = {\n        from: goal,\n        to: want + Math.round((goal - want) / count) * count,\n        at: performance.now(),\n      }\n      snapped = true\n    }\n    const onLeave = () => {\n      pointerX = -1\n      pointerY = -1\n      hovered = -1\n    }\n\n    canvas.addEventListener(\"pointerdown\", onDown)\n    canvas.addEventListener(\"pointermove\", onMove)\n    canvas.addEventListener(\"pointerup\", onUp)\n    canvas.addEventListener(\"pointercancel\", onUp)\n    canvas.addEventListener(\"pointerleave\", onLeave)\n\n    // --- frame ------------------------------------------------------------\n    const draw = (now: number) => {\n      frame = requestAnimationFrame(draw)\n      if (!width || !height) return\n      const dt = previous ? Math.min((now - previous) / 1000, 1 / 20) : 0\n      previous = now\n      clock += dt\n      const config = settings.current\n\n      if (ticks++ % THEME_EVERY === 0) ink = readColor(getComputedStyle(canvas).color)\n\n      if (atlas) entry = reduced ? 1 : Math.min(1, entry + (dt * 1000) / ENTRY_MS)\n      const spread = inOutCubic(entry)\n\n      // --- turn -----------------------------------------------------------\n      if (tween) {\n        const t = clamp((now - tween.at) / CLICK_MS, 0, 1)\n        goal = tween.from + (tween.to - tween.from) * outCubic(t)\n        if (t >= 1) tween = null\n      } else if (!snapped && now - lastInput > SNAP_IDLE) {\n        goal = Math.round(goal)\n        snapped = true\n      }\n      progress += (goal - progress) * (reduced ? 1 : snapped ? SNAP_EASE : EASE)\n      const speed = Math.abs(goal - progress)\n\n      const near = ((Math.round(goal) + FRONT) % count + count) % count\n      setActive((prev) => (prev === near ? prev : near))\n\n      // --- geometry --------------------------------------------------------\n      const long = width * config.cardSize\n      const short = long / config.cardRatio\n      const radius = width * config.arc\n      const angleStep = (short * SPACING) / radius\n      const centreX = -radius // the ring's near point lands on the stage centre\n\n      for (let i = 0; i < count; i++) {\n        const slot = ((((i - progress) % count) + count) % count) - FRONT\n        // The deck begins collapsed at the front slot and spreads outward into\n        // position, which is the motion that pulls the strands.\n        const angle = slot * angleStep * spread\n        at[i].angle = angle\n        at[i].x = centreX + Math.cos(angle) * radius\n        at[i].y = Math.sin(angle) * radius\n      }\n\n      // --- pointer ---------------------------------------------------------\n      // Cursor mapped into shader space - origin mid-stage, y increasing upward.\n      const mx = pointerX >= 0 ? pointerX - width / 2 : 0\n      const my = pointerY >= 0 ? height / 2 - pointerY : 0\n      const present = pointerX >= 0 ? 1 : 0\n\n      if (present && pointerSpeed < 24) {\n        hovered = -1\n        let best = Infinity\n        for (let i = 0; i < count; i++) {\n          const dx = Math.abs(mx - at[i].x)\n          const dy = Math.abs(my - at[i].y)\n          if (dx > long / 2 || dy > short / 2) continue\n          const distance = dx + dy\n          if (distance < best) {\n            best = distance\n            hovered = i\n          }\n        }\n      }\n      pointerSpeed *= 0.85\n\n      let strands = 0\n      for (let i = 0; i < count; i++) {\n        const dx = mx - at[i].x\n        const dy = my - at[i].y\n        const pull = present ? Math.max(0, 1 - Math.hypot(dx, dy) / (long * REACH)) : 0\n        const isHovered = i === hovered ? 1 : 0\n\n        // Tipping toward the cursor is quick and returning is slow; that\n        // asymmetry is what gives the surface a sense of mass.\n        const towardX = dx * (pull * pull) * PULL * long * 0.02\n        const towardY = dy * (pull * pull) * PULL * long * 0.02\n        leanX[i] += (towardX - leanX[i]) * (pull > 0 ? GRAB : RELEASE)\n        leanY[i] += (towardY - leanY[i]) * (pull > 0 ? GRAB : RELEASE)\n\n        // Neighbours clear a path for the pointed-at card. The block above\n        // tracks the cursor; this one tracks the card it settled on.\n        let push = 0\n        let dimTarget = 0\n        if (hovered >= 0 && i !== hovered) {\n          let gap = Math.abs(i - hovered)\n          gap = Math.min(gap, count - gap)\n          const off = Math.max(0, 1 - gap / NEIGHBOUR_REACH)\n          push = Math.sign(at[i].y - at[hovered].y || 1) * off * NEIGHBOUR_PUSH * long\n          dimTarget = off * NEIGHBOUR_DIM\n        }\n        dim[i] += (dimTarget - dim[i]) * (dimTarget > dim[i] ? GRAB : RELEASE)\n\n        // The pointed-at card grows and its neighbours shed the same amount.\n        const wantSwell = pull * pull * SWELL + isHovered * NEIGHBOUR_SCALE - dimTarget * (NEIGHBOUR_SCALE / NEIGHBOUR_DIM)\n        swell[i] += (wantSwell - swell[i]) * (wantSwell > swell[i] ? GRAB : RELEASE)\n\n        at[i].x += leanX[i]\n        at[i].y += leanY[i] + push\n        at[i].scale = (0.18 + 0.82 * spread) * (1 + swell[i])\n\n        pos[i * 2] = at[i].x\n        pos[i * 2 + 1] = at[i].y\n        rot[i] = at[i].angle\n        scale[i * 4] = at[i].scale\n        scale[i * 4 + 1] = at[i].scale\n        scale[i * 4 + 2] = 1 - dim[i]\n        scale[i * 4 + 3] = i\n      }\n\n      // --- strands ----------------------------------------------------------\n      // Narrowing, hanging and parting all fall out of the field itself - none\n      // of it is animated, because a strand is a term in the same equation\n      // rather than a shape drawn between two cards.\n      if (config.threads) {\n        for (let i = 0; i < count && strands < MAX_STRANDS; i++) {\n          const j = (i + 1) % count\n          // Only neighbours that are actually adjacent on the visible arc.\n          const gap = Math.hypot(at[j].x - at[i].x, at[j].y - at[i].y)\n          const opening = (gap - short) / (short * STRAND_SNAP)\n          if (opening > 1 || opening < -1) continue\n          // Present while the deck is still spreading, and anywhere the cursor\n          // is holding a pair apart.\n          const strength = Math.max(1 - spread, hovered === i || hovered === j ? 1 : 0)\n          if (strength < 0.02) continue\n          const rEnd = short * 0.5 * STRAND * strength * (1 - clamp(opening, 0, 1))\n          if (rEnd <= 0.5) continue\n          strandA[strands * 2] = at[i].x\n          strandA[strands * 2 + 1] = at[i].y\n          strandB[strands * 2] = at[j].x\n          strandB[strands * 2 + 1] = at[j].y\n          strandPar[strands * 4] = rEnd\n          strandPar[strands * 4 + 1] = rEnd * WAIST\n          strandPar[strands * 4 + 2] = SAG * long * clamp(opening, 0, 1)\n          strandPar[strands * 4 + 3] = WELD * long\n          strands++\n        }\n      }\n\n      // --- draw -------------------------------------------------------------\n      gl.viewport(0, 0, canvas.width, canvas.height)\n      gl.clearColor(0, 0, 0, 0)\n      gl.clear(gl.COLOR_BUFFER_BIT)\n      gl.useProgram(program)\n      gl.bindVertexArray(quad)\n\n      gl.uniform2f(u(\"uResolution\"), width, height)\n      gl.uniform2f(u(\"uSize\"), long, short)\n      gl.uniform1f(u(\"uCorner\"), CORNER * long)\n      gl.uniform1f(u(\"uCount\"), count)\n      gl.uniform2fv(u(\"uCentre\"), pos)\n      gl.uniform1fv(u(\"uAngle\"), rot)\n      gl.uniform4fv(u(\"uCardState\"), scale)\n      gl.uniform1f(u(\"uStrandCount\"), strands)\n      gl.uniform2fv(u(\"uStrandA\"), strandA)\n      gl.uniform2fv(u(\"uStrandB\"), strandB)\n      gl.uniform4fv(u(\"uStrandPar\"), strandPar)\n      gl.uniform1f(u(\"uFuse\"), config.fuse * long)\n      // Falls to zero at rest, leaving a settled ring perfectly smooth.\n      gl.uniform1f(u(\"uJitter\"), reduced ? 0 : WOBBLE * long * clamp(speed * 2 + (1 - spread), 0, 1))\n      gl.uniform1f(u(\"uTime\"), reduced ? 0 : clock)\n      gl.uniform3fv(u(\"uColor\"), ink)\n      gl.uniform1f(u(\"uCrossfade\"), CROSSFADE * long)\n      gl.uniform1f(u(\"uHasArt\"), atlas ? 1 : 0)\n      gl.uniform2f(u(\"uGrid\"), COLS, Math.ceil(count / COLS))\n      gl.uniform4f(u(\"uCursor\"), mx, my, present, CURSOR_FUSE * long)\n      gl.uniform4f(\n        u(\"uWake\"),\n        CURSOR_REACH * long,\n        reduced ? 0 : WAVE * long * clamp(pointerSpeed / 40, 0, 1),\n        WAVE_FREQ / long,\n        WAVE_SPEED\n      )\n      gl.uniform1f(u(\"uLipDepth\"), config.glass ? BAND * height : 0)\n      gl.uniform4f(u(\"uLip\"), REFRACT * long, SQUEEZE, RIPPLE * long, RIPPLE_FREQ / long)\n      gl.uniform1f(u(\"uFringe\"), FRINGE)\n      gl.uniform1f(u(\"uSheen\"), SHEEN)\n\n      gl.activeTexture(gl.TEXTURE0)\n      gl.bindTexture(gl.TEXTURE_2D, atlas)\n      gl.uniform1i(u(\"uAtlas\"), 0)\n      gl.drawArrays(gl.TRIANGLES, 0, 6)\n    }\n    frame = requestAnimationFrame(draw)\n\n    return () => {\n      cancelAnimationFrame(frame)\n      observer.disconnect()\n      canvas.removeEventListener(\"wheel\", onWheel)\n      canvas.removeEventListener(\"pointerdown\", onDown)\n      canvas.removeEventListener(\"pointermove\", onMove)\n      canvas.removeEventListener(\"pointerup\", onUp)\n      canvas.removeEventListener(\"pointercancel\", onUp)\n      canvas.removeEventListener(\"pointerleave\", onLeave)\n      for (const image of images) image.onload = null\n      if (atlas) gl.deleteTexture(atlas)\n      gl.deleteBuffer(buffer)\n      gl.deleteVertexArray(quad)\n      gl.deleteProgram(program)\n    }\n    // `sources` stands in for `items`: the loop owns the atlas, so it must\n    // rebuild when the pictures change and must not when a label does.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [sources, count, reduced])\n\n  const item = items[active]\n\n  // No WebGL2 - a blank rectangle is the one outcome worse than no effect. The\n  // ring without its shader is still the work: a native snap scroller of the\n  // same pictures, in the same order.\n  if (!supported) {\n    return (\n      <section\n        aria-roledescription=\"carousel\"\n        aria-label={brand ?? \"Gallery\"}\n        className={cn(\"bg-background text-foreground relative h-full min-h-[24rem] w-full\", className)}\n        {...props}\n      >\n        <ul className=\"flex h-full snap-y snap-mandatory flex-col items-center gap-3 overflow-y-auto py-[6%]\">\n          {items.map((entry) => (\n            <li key={entry.image} className=\"w-[62%] shrink-0 snap-center\">\n              <img\n                src={entry.image}\n                alt={entry.title}\n                className=\"bg-muted w-full rounded-lg object-cover\"\n                style={{ aspectRatio: cardRatio }}\n              />\n            </li>\n          ))}\n        </ul>\n      </section>\n    )\n  }\n\n  return (\n    <section\n      aria-roledescription=\"carousel\"\n      aria-label={brand ?? \"Gallery\"}\n      className={cn(\n        \"bg-background text-foreground relative h-full min-h-[24rem] w-full overflow-hidden select-none\",\n        className\n      )}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        tabIndex={0}\n        role=\"listbox\"\n        aria-label={brand ?? \"Gallery\"}\n        aria-activedescendant={`molten-ring-${active}`}\n        className=\"text-foreground focus-visible:outline-foreground absolute inset-0 h-full w-full cursor-grab touch-pan-x outline-none focus-visible:outline-2 focus-visible:-outline-offset-4 active:cursor-grabbing\"\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\") step.current(1)\n          else if (event.key === \"ArrowUp\") step.current(-1)\n          else return\n          event.preventDefault()\n        }}\n      />\n\n      {/* Everything visible is painted into the canvas, so assistive technology\n          and keyboard users are given this equivalent instead: the same entries\n          in the same sequence. */}\n      <ul className=\"sr-only\">\n        {items.map((entry, i) => (\n          <li key={entry.image} id={`molten-ring-${i}`} role=\"option\" aria-selected={i === active}>\n            {entry.title}\n            {entry.meta ? `. ${entry.meta}` : \"\"}\n          </li>\n        ))}\n      </ul>\n\n      {brand ? (\n        <div className=\"pointer-events-none absolute top-[6%] left-[5%] text-sm font-medium tracking-tight\">\n          {brand}\n        </div>\n      ) : null}\n\n      {/* Labels flank the arc the way a plate caption does - index and title to\n          the left, classification to the right. */}\n      <div className=\"pointer-events-none absolute top-1/2 left-[5%] -translate-y-1/2\">\n        <div className=\"text-muted-foreground text-xs tabular-nums\">\n          {String(active + 1).padStart(2, \"0\")}\n        </div>\n        <div className=\"mt-1 text-xl leading-none font-medium tracking-tight\">{item?.title}</div>\n      </div>\n\n      {item?.meta ? (\n        <div className=\"text-muted-foreground pointer-events-none absolute top-1/2 right-[5%] -translate-y-1/2 text-right text-xs\">\n          {item.meta}\n        </div>\n      ) : null}\n    </section>\n  )\n}\n"
    }
  ]
}
