# Molten Ring Carousel

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.

- Demo: https://crafterui.com/components/molten-ring-carousel
- Install: `npx @crafterui/cli@latest components add molten-ring-carousel` - or `npx shadcn@latest add https://crafterui.com/r/molten-ring-carousel.json`
- Installs to: `registry/crafterui/ui/molten-ring-carousel.tsx`

## Usage

```tsx
"use client"

import {
  MoltenRingCarousel,
  type MoltenRingItem,
} from "@/registry/crafterui/ui/molten-ring-carousel"

// Placeholder imagery collected from Pinterest, saved under public/work. Swap
// the paths and the copy for your own index - the list is the ring order, so
// reordering these rows reorders the arc and the numbering together.
const WORK: MoltenRingItem[] = [
  { image: "/work/verdant.jpg", title: "Verdant", meta: "Editorial · 2026" },
  { image: "/work/ember-field.jpg", title: "Ember Field", meta: "Campaign · 2026" },
  { image: "/work/northbound.jpg", title: "Northbound", meta: "Campaign · 2026" },
  { image: "/work/marigold.jpg", title: "Marigold", meta: "Editorial · 2025" },
  { image: "/work/signal-grid.jpg", title: "Signal Grid", meta: "Identity · 2025" },
  { image: "/work/riot-press.jpg", title: "Riot Press", meta: "Print · 2025" },
  { image: "/work/nightshift.jpg", title: "Nightshift", meta: "Automotive · 2025" },
  { image: "/work/cold-chrome.jpg", title: "Cold Chrome", meta: "Automotive · 2024" },
  { image: "/work/spectral.jpg", title: "Spectral", meta: "Motion · 2024" },
  { image: "/work/dune-study.jpg", title: "Dune Study", meta: "Art direction · 2024" },
  { image: "/work/afterlight.jpg", title: "Afterlight", meta: "Film · 2023" },
  { image: "/work/redstone.jpg", title: "Redstone", meta: "Campaign · 2023" },
]

export default function MoltenRingCarouselDemo() {
  return (
    <MoltenRingCarousel
      items={WORK}
      brand="crafterui"
      arc={1.05}
      cardSize={0.3}
      cardRatio={1.5}
      fuse={0.09}
    />
  )
}
```

## Source - `registry/crafterui/ui/molten-ring-carousel.tsx`

```tsx
"use client"

// A carousel whose cards behave like drops of liquid held on glass.
//
// The circle is far larger than the frame and its centre sits well off to the
// left, so only a sliver of it ever crosses the viewport - which reads as a
// tall arc of work sweeping past with one card square to the viewer. Scroll,
// drag or swipe turns it.
//
// There is no mesh here and there are no image elements. Each card is a
// rounded-box distance field and the frame is one fullscreen pass taking a
// smooth minimum over the lot. That single operator buys the physics: two cards
// approaching never overlap, their fields fuse; two separating leave a strand
// behind, because the strand is one more term in the same field and narrows,
// hangs and finally parts of its own accord as the distance grows.
//
// The cursor is never painted. It widens the fusion radius beneath itself, tips
// nearby cards toward it, elbows their neighbours aside, and draws strands out
// between them.
import * as React from "react"

import { cn } from "@/lib/utils"

export interface MoltenRingItem {
  /** Cover art. Cross-origin sources must send CORS headers. */
  image: string
  /** Shown to the left of the ring while this card is at the front. */
  title: string
  /** The line to the right of the ring - discipline, year, whatever. */
  meta?: string
}

export interface MoltenRingCarouselProps
  extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
  items: MoltenRingItem[]
  /** Wordmark in the top-left. Omit to drop it. @default undefined */
  brand?: string
  /** Ring radius, in stage widths. Larger flattens the arc. @default 1 */
  arc?: number
  /** Card long edge, as a fraction of the stage width. @default 0.265 */
  cardSize?: number
  /** Card long edge / short edge. Art is cover-fitted into it. @default 1.5 */
  cardRatio?: number
  /** Fusion radius between neighbours, in card long-edges. @default 0.087 */
  fuse?: number
  /** String threads between cards as they pull apart. @default true */
  threads?: boolean
  /** Optical band that bends the image at the upper and lower borders. @default true */
  glass?: boolean
  /** Extra classes on the root surface. @default undefined */
  className?: string
}

/** Each uniform-array element occupies a vec4 register; WebGL2 guarantees only
    224. Two dozen already exceeds what the visible arc can hold. */
const MAX_CARDS = 24
const MAX_STRANDS = 24

/* Every figure below is expressed as a multiple of the card's long edge, so
   proportions survive any viewport instead of being tuned to one screen. */
const FUSE = 0.087 // resting blend between neighbours
const CORNER = 0.015
const CROSSFADE = 0.035 // over which neighbouring art crossfades inside the goo
const SPACING = 1.55 // centre to centre along the arc, in short edges

/* Cursor. It contributes nothing to the picture; it only alters how the field
   responds nearby. Take-up and let-go run at different rates on purpose - a
   card tips toward the cursor briskly and returns at half the speed. */
const CURSOR_FUSE = 0.085 // blend added to the field at the cursor
const CURSOR_REACH = 0.65
const PULL = 0.065 // how far a card leans toward the cursor
const SWELL = 0.09
const REACH = 1.7 // radius of cursor influence, in card long edges
const GRAB = 0.14
const RELEASE = 0.06
const NEIGHBOUR_PUSH = 0.042 // how far the hovered card's neighbours get out of the way
const NEIGHBOUR_SCALE = 0.035
const NEIGHBOUR_DIM = 0.15
const NEIGHBOUR_REACH = 2.4
const WAVE = 0.01 // capillary wake off a moving cursor
const WAVE_FREQ = 20
const WAVE_SPEED = 7

/* Strands. Thickest where one leaves a card, waisted at the midpoint, and
   hanging lower the further it is drawn out. */
const STRAND = 0.2 // end thickness, relative to the edge it grows from
const STRAND_SNAP = 1.15 // gaps wider than this, in short edges, have snapped
const WAIST = 0.35
const SAG = 0.015
const WELD = 0.035

/* Optical band running across the upper and lower borders. */
const BAND = 0.08 // fraction of the stage height
const REFRACT = 0.15
const SQUEEZE = 0.05
const RIPPLE = 0.0125
const RIPPLE_FREQ = 8
const FRINGE = 0.004
const SHEEN = 0.05

const WOBBLE = 0.0075 // surface tension noise while the ring is moving

/* Turn. The ring eases after a target and settles with a card facing front. */
const WHEEL = 0.0022 // slots per px of wheel delta
const DRAG = 0.007 // ... and per px dragged
const EASE = 0.08
const SNAP_IDLE = 260
const SNAP_EASE = 0.06
const CLICK_SLOP = 6
const CLICK_MS = 700

/* Arrival. The deck begins fused into a single mass at the front and the
   circle draws it apart into slots, which is what produces the strands. */
const ENTRY_MS = 2600

const THEME_EVERY = 20

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
const inOutCubic = (t: number) =>
  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
const outCubic = (t: number) => 1 - Math.pow(1 - clamp(t, 0, 1), 3)

const QUAD_VERT = /* glsl */ `#version 300 es
in vec2 aPos;
out vec2 vUv;
void main() {
  vUv = aPos;
  gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0);
}`

const RING_FRAG = /* glsl */ `#version 300 es
precision highp float;

#define MAX_CARDS ${MAX_CARDS}
#define MAX_STRANDS ${MAX_STRANDS}

in vec2 vUv;
out vec4 fragColor;

uniform vec2  uResolution;   // px
uniform vec2  uSize;         // resting card size in px - long edge, short edge
uniform float uCorner;

uniform float uCount;
uniform vec2  uCentre[MAX_CARDS];   // centre in px, origin at the stage centre
uniform float uAngle[MAX_CARDS];   // radians
// xy = per-axis scale, z = brightness, w = atlas cell index. Packed together
// because a uniform-array slot is a full vec4 register regardless of the
// declared type, so zw are free once xy are spent.
uniform vec4  uCardState[MAX_CARDS];

uniform float uStrandCount;
uniform vec2  uStrandA[MAX_STRANDS];
uniform vec2  uStrandB[MAX_STRANDS];
uniform vec4  uStrandPar[MAX_STRANDS];  // end thickness, waist, hang, weld width

uniform float uFuse;            // blend strength, px
uniform float uJitter;
uniform float uTime;
uniform vec3  uColor;        // untextured fallback, and the loading silhouette

uniform sampler2D uAtlas;    // one sheet; sampler arrays need a constant index
uniform vec2  uGrid;         // cells across, down
uniform float uCrossfade;        // px over which neighbouring art crossfades
uniform float uHasArt;

uniform vec4  uCursor;        // xy in px, z = engaged 0..1, w = added fusion
uniform vec4  uWake;         // radius px, amplitude px, spatial freq, rate

uniform float uLipDepth;         // glass lip depth, px - 0 turns it off
uniform vec4  uLip;        // refract px, squeeze, ripple px, ripple frequency
uniform float uFringe;
uniform float uSheen;

vec2 atlasUV(vec2 uv, float idx) {
  return (vec2(mod(idx, uGrid.x), floor(idx / uGrid.x)) + uv) / uGrid;
}

/* Bilinear value noise. The perturbation is small and rides on a surface
   already in motion, so a simplex implementation would cost twenty more lines
   for a difference nobody could pick out. */
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  f = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x),
    f.y
  ) * 2.0 - 1.0;
}

float sdRoundBox(vec2 p, vec2 b, float r) {
  vec2 q = abs(p) - b + r;
  return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}

/* One strand spanning two cards: a slab laid centre to centre, as thick at
   each end as the edge it grows from, waisted at the midpoint and hanging under
   its own weight.

   Swept as a box, not a capsule. A capsule's circular cross-section would
   balloon past the cards' own flat faces once they fused; a box tucks inside
   them, so a merged pair keeps the outline of a single card. */
float sdStrand(vec2 p, vec2 a, vec2 b, float rEnd, float rMid, float sag) {
  vec2 ba = b - a;
  float len = length(ba);
  if (len < 0.001) return 1e6;

  vec2 dir = ba / len;
  vec2 nrm = vec2(-dir.y, dir.x);
  vec2 q = p - (a + b) * 0.5;
  float along = dot(q, dir);
  float across = dot(q, nrm);

  float h = clamp(along / len + 0.5, 0.0, 1.0);
  float bell = sin(3.14159265 * h);        // peaks mid-span, vanishes at both ends
  across += sag * bell * nrm.y;            // hang, projected onto the perpendicular
  float r = mix(rMid, rEnd, pow(1.0 - bell, 1.7));

  // Square ends, which finish inside the cards and are never on screen.
  return max(abs(along) - len * 0.5, abs(across) - r);
}

/* Smooth minimum - the one operator the whole look rests on. Against the 1e6
   sentinel it degrades cleanly to an ordinary min(). */
float smin(float a, float b, float k) {
  if (k <= 0.0001) return min(a, b);
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}

/* The upper and lower margins behave like the ground edge of a thick pane.
   Since the whole scene is evaluated from p, displacing p at this point bends
   cards and strands together in one pass - no extra target, no second warp. */
float lipWarp(inout vec2 p) {
  if (uLipDepth <= 0.5) return 0.0;
  float dy = abs(p.y) - (uResolution.y * 0.5 - uLipDepth);
  if (dy <= 0.0) return 0.0;

  float t = clamp(dy / uLipDepth, 0.0, 1.0);
  // A circular falloff: almost flat where the band begins and dropping away
  // steeply at the boundary, which is what sells depth over a plain gradient.
  float bend = 1.0 - sqrt(max(0.0, 1.0 - t * t));

  // Sampling from deeper inside displaces detail toward the margin, so the
  // image elongates into the band and grows as it nears the boundary.
  p.y -= sign(p.y) * bend * (uLip.x + sin(p.x * uLip.w) * uLip.z);
  p.x *= 1.0 - bend * uLip.y;
  return bend;
}

void main() {
  vec2 p = (vUv - 0.5) * uResolution;
  float bend = lipWarp(p);

  // Measured after the displacement so the cursor lives in the same warped
  // space the cards do - carried into the band, its influence bends along with
  // them instead of lying flat across the top.
  float toCursor = length(p - uCursor.xy);

  // Fusion radius rises within a pool centred on the pointer, slackening the
  // field just where contact occurs while it stays taut elsewhere. Computed once
  // per pixel rather than per card, costing a single length() for the loop.
  float k = uFuse;
  if (uCursor.z > 0.001) {
    float t = 1.0 - smoothstep(0.0, max(uWake.x, 1.0), toCursor);
    k += uCursor.w * uCursor.z * t * t;
  }

  float d = 1e6;

  // The nearest two cards, carried alongside the distance so colour resolves in
  // the same loop rather than a second one. Where the field bridges a pair both
  // register as close, which is precisely where the crossfade should sit.
  float d0 = 1e6, d1 = 1e6;
  vec2 uv0 = vec2(0.5), uv1 = vec2(0.5);
  float im0 = 0.0, im1 = 0.0;
  float dm0 = 1.0, dm1 = 1.0;

  float halfSpan = length(uSize) * 0.5;

  for (int i = 0; i < MAX_CARDS; i++) {
    if (float(i) >= uCount) break;

    vec4 st = uCardState[i];
    float grown = max(st.x, st.y);
    if (grown <= 0.0001) continue;

    vec2 q = p - uCentre[i];
    // Beyond this radius a card cannot reach the surface, so it is rejected
    // before any transcendentals run - which is what makes two dozen of them
    // affordable. Sized off the card itself, since one swollen beneath the
    // cursor covers more ground than its resting footprint, as does the wider
    // fusion radius around it.
    float cull = halfSpan * grown + k + uJitter + 8.0;
    if (dot(q, q) > cull * cull) continue;

    float ca = cos(uAngle[i]), sa = sin(uAngle[i]);
    q = vec2(q.x * ca + q.y * sa, -q.x * sa + q.y * ca);

    vec2 halfSize = max(uSize * 0.5 * st.xy, vec2(0.0001));
    // Opens as a lozenge and settles into the rounded rectangle as it grows, so
    // arrival reads as a droplet finding its form, not a box being scaled.
    float rMax = min(halfSize.x, halfSize.y);
    float r = min(rMax, mix(rMax, uCorner, smoothstep(0.30, 1.0, min(st.x, st.y))));

    float di = sdRoundBox(q, halfSize, r);
    d = smin(d, di, k);

    // Clamped so that fused area beyond a card's own bounds takes that card's
    // edge pixels instead of tiling or spilling into the adjacent atlas cell.
    vec2 luv = clamp(q / (2.0 * halfSize) + 0.5, 0.004, 0.996);
    luv.y = 1.0 - luv.y;

    if (di < d0) {
      d1 = d0; uv1 = uv0; im1 = im0; dm1 = dm0;
      d0 = di; uv0 = luv; im0 = st.w; dm0 = st.z;
    } else if (di < d1) {
      d1 = di; uv1 = luv; im1 = st.w; dm1 = st.z;
    }
  }

  for (int i = 0; i < MAX_STRANDS; i++) {
    if (float(i) >= uStrandCount) break;
    vec4 par = uStrandPar[i];
    // Negative radii are allowed, and wanted: they raise the strand's field
    // above the surface so it withdraws smoothly instead of stalling at zero
    // and leaving a half-resolved hairline behind.
    if (par.x <= -3.0) continue;
    vec2 a = uStrandA[i], b = uStrandB[i];
    vec2 mid = (a + b) * 0.5;
    float span = length(b - a) * 0.5 + par.x + par.w + 8.0;
    if (dot(p - mid, p - mid) > span * span) continue;
    d = smin(d, sdStrand(p, a, b, par.x, par.y, par.z), par.w);
  }

  // Surface tension, scaled to nothing at rest so a settled ring is perfectly
  // smooth.
  if (uJitter > 0.001) {
    d += noise(p * 0.012 + vec2(uTime * 0.22, uTime * -0.17)) * uJitter;
  }

  // A wake trailing the cursor, expanding outward and decaying over the same
  // radius the slackening uses, so a quick pass leaves a disturbance that
  // persists a moment after the gesture ends.
  if (uWake.y > 0.001) {
    d += sin(toCursor * uWake.z - uTime * uWake.w)
       * uWake.y * exp(-toCursor / max(uWake.x, 1.0));
  }

  // Bounded at both ends rather than only below. The rejection test above puts
  // a discontinuity in the field, and fwidth measured across it would return a
  // huge derivative, tracing a translucent seam along every rejection boundary.
  float aa = clamp(fwidth(d), 0.5, 2.0);
  float alpha = 1.0 - smoothstep(-aa, aa, d);
  if (alpha <= 0.001) discard;

  // Equal weight where the two nearest cards tie, settling on whichever leads
  // once the margin exceeds the crossfade width. Artwork and brightness both
  // ride this weight, so neither can leave a visible join through fused area.
  float nearest = smoothstep(-uCrossfade, uCrossfade, d1 - d0);

  vec3 col = uColor;
  if (uHasArt > 0.5) {
    // Branch on a uniform, keeping derivatives well defined across the quad.
    // The offset scales with band depth, so outside it all three taps collapse
    // onto one texel.
    vec2 fr = vec2(uFringe * bend, 0.0);
    vec3 c0 = vec3(
      texture(uAtlas, atlasUV(uv0 + fr, im0)).r,
      texture(uAtlas, atlasUV(uv0, im0)).g,
      texture(uAtlas, atlasUV(uv0 - fr, im0)).b
    );
    vec3 c1 = vec3(
      texture(uAtlas, atlasUV(uv1 + fr, im1)).r,
      texture(uAtlas, atlasUV(uv1, im1)).g,
      texture(uAtlas, atlasUV(uv1 - fr, im1)).b
    );
    col = mix(c1, c0, nearest);
  }

  // Every card except the selected one is attenuated, leaving that card the
  // only fully lit surface in the frame.
  col *= mix(dm1, dm0, nearest);

  // A little brightening where the band is steepest, so it reads as a surface
  // taking light and not purely as a distortion.
  col += bend * uSheen;

  fragColor = vec4(col, alpha);
}`

function build(gl: WebGL2RenderingContext, vert: string, frag: string) {
  const program = gl.createProgram()
  if (!program) return null
  for (const [type, source] of [
    [gl.VERTEX_SHADER, vert],
    [gl.FRAGMENT_SHADER, frag],
  ] as const) {
    const shader = gl.createShader(type)
    if (!shader) return null
    gl.shaderSource(shader, source)
    gl.compileShader(shader)
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
      console.error(gl.getShaderInfoLog(shader))
      return null
    }
    gl.attachShader(program, shader)
    gl.deleteShader(shader)
  }
  gl.bindAttribLocation(program, 0, "aPos")
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
    return null
  }
  return program
}

/** Cached uniform handles. getUniformLocation performs a string lookup on every
    call, and this sits inside the per-frame path. */
function uniforms(gl: WebGL2RenderingContext, program: WebGLProgram) {
  const cache = new Map<string, WebGLUniformLocation | null>()
  return (name: string) => {
    let loc = cache.get(name)
    if (loc === undefined) {
      loc = gl.getUniformLocation(program, name)
      cache.set(name, loc)
    }
    return loc
  }
}

/** Resolves any CSS colour to 0-1 RGB by asking the browser instead of parsing
    it. Theme tokens here are written in oklch, and naive comma-splitting of the
    computed string yields a confidently incorrect near-black. */
function colorReader() {
  const probe = document.createElement("canvas")
  probe.width = probe.height = 1
  const ctx = probe.getContext("2d", { willReadFrequently: true })
  return (css: string): [number, number, number] => {
    if (!ctx) return [0, 0, 0]
    ctx.fillStyle = "#000"
    ctx.fillStyle = css
    ctx.fillRect(0, 0, 1, 1)
    const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data
    return [r / 255, g / 255, b / 255]
  }
}

/** A single sheet, each cell cover-fitted. ESSL forbids indexing a sampler
    array with a non-constant expression, ruling out one texture per card. */
function packAtlas(images: HTMLImageElement[], cols: number, cell: number, ratio: number) {
  const rows = Math.ceil(images.length / cols)
  const sheet = document.createElement("canvas")
  sheet.width = cols * cell
  sheet.height = rows * Math.round(cell / ratio)
  const ctx = sheet.getContext("2d")
  if (!ctx) return sheet
  const cellH = Math.round(cell / ratio)
  images.forEach((image, i) => {
    const x = (i % cols) * cell
    const y = Math.floor(i / cols) * cellH
    const scale = Math.max(cell / image.naturalWidth, cellH / image.naturalHeight)
    const w = image.naturalWidth * scale
    const h = image.naturalHeight * scale
    ctx.save()
    ctx.beginPath()
    ctx.rect(x, y, cell, cellH)
    ctx.clip()
    ctx.drawImage(image, x + (cell - w) / 2, y + (cellH - h) / 2, w, h)
    ctx.restore()
  })
  return sheet
}

export function MoltenRingCarousel({
  items,
  brand,
  arc = 1,
  cardSize = 0.265,
  cardRatio = 1.5,
  fuse = FUSE,
  threads = true,
  glass = true,
  className,
  ...props
}: MoltenRingCarouselProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null)
  const [active, setActive] = React.useState(0)
  const [reduced, setReduced] = React.useState(false)
  const [supported, setSupported] = React.useState(true)

  const settings = React.useRef({ arc, cardSize, cardRatio, fuse, threads, glass })
  settings.current = { arc, cardSize, cardRatio, fuse, threads, glass }
  /** Populated by the render loop so keyboard input drives the same rotation the
    wheel does. */
  const step = React.useRef<(by: number) => void>(() => {})

  const count = Math.min(items.length, MAX_CARDS)
  const sources = items.map((item) => item.image).join(" ")

  React.useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)")
    const read = () => setReduced(query.matches)
    read()
    query.addEventListener("change", read)
    return () => query.removeEventListener("change", read)
  }, [])

  React.useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas || !count) return
    // Unpremultiplied alpha, so whatever the ring does not cover is simply the
    // page underneath - which is how the shader stays theme-agnostic without
    // ever being handed the palette.
    const gl = canvas.getContext("webgl2", {
      alpha: true,
      premultipliedAlpha: false,
      antialias: false,
    })
    if (!gl) {
      setSupported(false)
      return
    }

    const readColor = colorReader()
    const program = build(gl, QUAD_VERT, RING_FRAG)
    if (!program) return
    const u = uniforms(gl, program)

    const quad = gl.createVertexArray()
    gl.bindVertexArray(quad)
    const buffer = gl.createBuffer()
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]),
      gl.STATIC_DRAW
    )
    gl.enableVertexAttribArray(0)
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)

    // --- art --------------------------------------------------------------
    const COLS = Math.min(4, count)
    const CELL = 512
    let atlas: WebGLTexture | null = null
    let loaded = 0
    const images = items.slice(0, count).map((item) => {
      const image = new Image()
      image.crossOrigin = "anonymous"
      image.decoding = "async"
      image.onload = () => {
        if (++loaded < count) return
        // Assembled once the final image lands; packing early would leave unset
        // cells sampling as solid black cards.
        atlas = gl.createTexture()
        gl.bindTexture(gl.TEXTURE_2D, atlas)
        gl.texImage2D(
          gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE,
          packAtlas(images, COLS, CELL, settings.current.cardRatio)
        )
        // Mipmaps are skipped - lower levels would average across cell borders,
        // and cards occupy enough pixels that minification never applies.
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
      }
      image.src = item.image
      return image
    })

    // --- state ------------------------------------------------------------
    let width = 0
    let height = 0
    let progress = 0
    let goal = 0
    let lastInput = 0
    let snapped = true
    let hovered = -1
    let pointerX = -1
    let pointerY = -1
    let pointerSpeed = 0
    let entry = 0
    let clock = 0
    let previous = 0
    let ticks = 0
    let frame = 0
    let tween: { from: number; to: number; at: number } | null = null
    let ink: [number, number, number] = [0, 0, 0]

    const leanX = new Float32Array(count)
    const leanY = new Float32Array(count)
    const swell = new Float32Array(count)
    const dim = new Float32Array(count)

    const pos = new Float32Array(MAX_CARDS * 2)
    const rot = new Float32Array(MAX_CARDS)
    const scale = new Float32Array(MAX_CARDS * 4)
    const strandA = new Float32Array(MAX_STRANDS * 2)
    const strandB = new Float32Array(MAX_STRANDS * 2)
    const strandPar = new Float32Array(MAX_STRANDS * 4)

    /** Per-frame screen placement for every card, used by hit-testing and by the
        strand solver below. */
    const at = Array.from({ length: count }, () => ({ x: 0, y: 0, angle: 0, scale: 1 }))
    /** Index of the slot squared up to the viewer. Half the count would land
        between two slots whenever the count is odd, so it is floored. */
    const FRONT = Math.floor(count / 2)

    const resize = () => {
      const w = canvas.clientWidth
      const h = canvas.clientHeight
      if (!w || !h) return
      const dpr = Math.min(window.devicePixelRatio || 1, 2)
      width = w
      height = h
      canvas.width = Math.round(w * dpr)
      canvas.height = Math.round(h * dpr)
    }
    resize()
    const observer = new ResizeObserver(resize)
    observer.observe(canvas)

    // --- input ------------------------------------------------------------
    const onWheel = (event: WheelEvent) => {
      event.preventDefault()
      tween = null
      goal += event.deltaY * WHEEL
      lastInput = performance.now()
      snapped = false
    }
    canvas.addEventListener("wheel", onWheel, { passive: false })

    step.current = (by: number) => {
      tween = { from: goal, to: Math.round(goal) + by, at: performance.now() }
      lastInput = performance.now()
      snapped = true
    }

    let dragFrom: number | null = null
    let dragTravel = 0
    const onDown = (event: PointerEvent) => {
      dragFrom = event.clientY
      dragTravel = 0
      tween = null
      canvas.setPointerCapture(event.pointerId)
    }
    const onMove = (event: PointerEvent) => {
      const box = canvas.getBoundingClientRect()
      const nx = event.clientX - box.left
      const ny = event.clientY - box.top
      pointerSpeed = Math.hypot(nx - pointerX, ny - pointerY)
      pointerX = nx
      pointerY = ny
      if (dragFrom !== null) {
        const travel = dragFrom - event.clientY
        dragTravel += Math.abs(travel)
        dragFrom = event.clientY
        goal += travel * DRAG
        lastInput = performance.now()
        snapped = false
      }
    }
    const onUp = () => {
      const wasClick = dragFrom !== null && dragTravel < CLICK_SLOP
      dragFrom = null
      if (!wasClick || hovered < 0) return
      // Rotate to the front slot, taking whichever direction is shorter.
      const want = (((hovered - FRONT) % count) + count) % count
      tween = {
        from: goal,
        to: want + Math.round((goal - want) / count) * count,
        at: performance.now(),
      }
      snapped = true
    }
    const onLeave = () => {
      pointerX = -1
      pointerY = -1
      hovered = -1
    }

    canvas.addEventListener("pointerdown", onDown)
    canvas.addEventListener("pointermove", onMove)
    canvas.addEventListener("pointerup", onUp)
    canvas.addEventListener("pointercancel", onUp)
    canvas.addEventListener("pointerleave", onLeave)

    // --- frame ------------------------------------------------------------
    const draw = (now: number) => {
      frame = requestAnimationFrame(draw)
      if (!width || !height) return
      const dt = previous ? Math.min((now - previous) / 1000, 1 / 20) : 0
      previous = now
      clock += dt
      const config = settings.current

      if (ticks++ % THEME_EVERY === 0) ink = readColor(getComputedStyle(canvas).color)

      if (atlas) entry = reduced ? 1 : Math.min(1, entry + (dt * 1000) / ENTRY_MS)
      const spread = inOutCubic(entry)

      // --- turn -----------------------------------------------------------
      if (tween) {
        const t = clamp((now - tween.at) / CLICK_MS, 0, 1)
        goal = tween.from + (tween.to - tween.from) * outCubic(t)
        if (t >= 1) tween = null
      } else if (!snapped && now - lastInput > SNAP_IDLE) {
        goal = Math.round(goal)
        snapped = true
      }
      progress += (goal - progress) * (reduced ? 1 : snapped ? SNAP_EASE : EASE)
      const speed = Math.abs(goal - progress)

      const near = ((Math.round(goal) + FRONT) % count + count) % count
      setActive((prev) => (prev === near ? prev : near))

      // --- geometry --------------------------------------------------------
      const long = width * config.cardSize
      const short = long / config.cardRatio
      const radius = width * config.arc
      const angleStep = (short * SPACING) / radius
      const centreX = -radius // the ring's near point lands on the stage centre

      for (let i = 0; i < count; i++) {
        const slot = ((((i - progress) % count) + count) % count) - FRONT
        // The deck begins collapsed at the front slot and spreads outward into
        // position, which is the motion that pulls the strands.
        const angle = slot * angleStep * spread
        at[i].angle = angle
        at[i].x = centreX + Math.cos(angle) * radius
        at[i].y = Math.sin(angle) * radius
      }

      // --- pointer ---------------------------------------------------------
      // Cursor mapped into shader space - origin mid-stage, y increasing upward.
      const mx = pointerX >= 0 ? pointerX - width / 2 : 0
      const my = pointerY >= 0 ? height / 2 - pointerY : 0
      const present = pointerX >= 0 ? 1 : 0

      if (present && pointerSpeed < 24) {
        hovered = -1
        let best = Infinity
        for (let i = 0; i < count; i++) {
          const dx = Math.abs(mx - at[i].x)
          const dy = Math.abs(my - at[i].y)
          if (dx > long / 2 || dy > short / 2) continue
          const distance = dx + dy
          if (distance < best) {
            best = distance
            hovered = i
          }
        }
      }
      pointerSpeed *= 0.85

      let strands = 0
      for (let i = 0; i < count; i++) {
        const dx = mx - at[i].x
        const dy = my - at[i].y
        const pull = present ? Math.max(0, 1 - Math.hypot(dx, dy) / (long * REACH)) : 0
        const isHovered = i === hovered ? 1 : 0

        // Tipping toward the cursor is quick and returning is slow; that
        // asymmetry is what gives the surface a sense of mass.
        const towardX = dx * (pull * pull) * PULL * long * 0.02
        const towardY = dy * (pull * pull) * PULL * long * 0.02
        leanX[i] += (towardX - leanX[i]) * (pull > 0 ? GRAB : RELEASE)
        leanY[i] += (towardY - leanY[i]) * (pull > 0 ? GRAB : RELEASE)

        // Neighbours clear a path for the pointed-at card. The block above
        // tracks the cursor; this one tracks the card it settled on.
        let push = 0
        let dimTarget = 0
        if (hovered >= 0 && i !== hovered) {
          let gap = Math.abs(i - hovered)
          gap = Math.min(gap, count - gap)
          const off = Math.max(0, 1 - gap / NEIGHBOUR_REACH)
          push = Math.sign(at[i].y - at[hovered].y || 1) * off * NEIGHBOUR_PUSH * long
          dimTarget = off * NEIGHBOUR_DIM
        }
        dim[i] += (dimTarget - dim[i]) * (dimTarget > dim[i] ? GRAB : RELEASE)

        // The pointed-at card grows and its neighbours shed the same amount.
        const wantSwell = pull * pull * SWELL + isHovered * NEIGHBOUR_SCALE - dimTarget * (NEIGHBOUR_SCALE / NEIGHBOUR_DIM)
        swell[i] += (wantSwell - swell[i]) * (wantSwell > swell[i] ? GRAB : RELEASE)

        at[i].x += leanX[i]
        at[i].y += leanY[i] + push
        at[i].scale = (0.18 + 0.82 * spread) * (1 + swell[i])

        pos[i * 2] = at[i].x
        pos[i * 2 + 1] = at[i].y
        rot[i] = at[i].angle
        scale[i * 4] = at[i].scale
        scale[i * 4 + 1] = at[i].scale
        scale[i * 4 + 2] = 1 - dim[i]
        scale[i * 4 + 3] = i
      }

      // --- strands ----------------------------------------------------------
      // Narrowing, hanging and parting all fall out of the field itself - none
      // of it is animated, because a strand is a term in the same equation
      // rather than a shape drawn between two cards.
      if (config.threads) {
        for (let i = 0; i < count && strands < MAX_STRANDS; i++) {
          const j = (i + 1) % count
          // Only neighbours that are actually adjacent on the visible arc.
          const gap = Math.hypot(at[j].x - at[i].x, at[j].y - at[i].y)
          const opening = (gap - short) / (short * STRAND_SNAP)
          if (opening > 1 || opening < -1) continue
          // Present while the deck is still spreading, and anywhere the cursor
          // is holding a pair apart.
          const strength = Math.max(1 - spread, hovered === i || hovered === j ? 1 : 0)
          if (strength < 0.02) continue
          const rEnd = short * 0.5 * STRAND * strength * (1 - clamp(opening, 0, 1))
          if (rEnd <= 0.5) continue
          strandA[strands * 2] = at[i].x
          strandA[strands * 2 + 1] = at[i].y
          strandB[strands * 2] = at[j].x
          strandB[strands * 2 + 1] = at[j].y
          strandPar[strands * 4] = rEnd
          strandPar[strands * 4 + 1] = rEnd * WAIST
          strandPar[strands * 4 + 2] = SAG * long * clamp(opening, 0, 1)
          strandPar[strands * 4 + 3] = WELD * long
          strands++
        }
      }

      // --- draw -------------------------------------------------------------
      gl.viewport(0, 0, canvas.width, canvas.height)
      gl.clearColor(0, 0, 0, 0)
      gl.clear(gl.COLOR_BUFFER_BIT)
      gl.useProgram(program)
      gl.bindVertexArray(quad)

      gl.uniform2f(u("uResolution"), width, height)
      gl.uniform2f(u("uSize"), long, short)
      gl.uniform1f(u("uCorner"), CORNER * long)
      gl.uniform1f(u("uCount"), count)
      gl.uniform2fv(u("uCentre"), pos)
      gl.uniform1fv(u("uAngle"), rot)
      gl.uniform4fv(u("uCardState"), scale)
      gl.uniform1f(u("uStrandCount"), strands)
      gl.uniform2fv(u("uStrandA"), strandA)
      gl.uniform2fv(u("uStrandB"), strandB)
      gl.uniform4fv(u("uStrandPar"), strandPar)
      gl.uniform1f(u("uFuse"), config.fuse * long)
      // Falls to zero at rest, leaving a settled ring perfectly smooth.
      gl.uniform1f(u("uJitter"), reduced ? 0 : WOBBLE * long * clamp(speed * 2 + (1 - spread), 0, 1))
      gl.uniform1f(u("uTime"), reduced ? 0 : clock)
      gl.uniform3fv(u("uColor"), ink)
      gl.uniform1f(u("uCrossfade"), CROSSFADE * long)
      gl.uniform1f(u("uHasArt"), atlas ? 1 : 0)
      gl.uniform2f(u("uGrid"), COLS, Math.ceil(count / COLS))
      gl.uniform4f(u("uCursor"), mx, my, present, CURSOR_FUSE * long)
      gl.uniform4f(
        u("uWake"),
        CURSOR_REACH * long,
        reduced ? 0 : WAVE * long * clamp(pointerSpeed / 40, 0, 1),
        WAVE_FREQ / long,
        WAVE_SPEED
      )
      gl.uniform1f(u("uLipDepth"), config.glass ? BAND * height : 0)
      gl.uniform4f(u("uLip"), REFRACT * long, SQUEEZE, RIPPLE * long, RIPPLE_FREQ / long)
      gl.uniform1f(u("uFringe"), FRINGE)
      gl.uniform1f(u("uSheen"), SHEEN)

      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, atlas)
      gl.uniform1i(u("uAtlas"), 0)
      gl.drawArrays(gl.TRIANGLES, 0, 6)
    }
    frame = requestAnimationFrame(draw)

    return () => {
      cancelAnimationFrame(frame)
      observer.disconnect()
      canvas.removeEventListener("wheel", onWheel)
      canvas.removeEventListener("pointerdown", onDown)
      canvas.removeEventListener("pointermove", onMove)
      canvas.removeEventListener("pointerup", onUp)
      canvas.removeEventListener("pointercancel", onUp)
      canvas.removeEventListener("pointerleave", onLeave)
      for (const image of images) image.onload = null
      if (atlas) gl.deleteTexture(atlas)
      gl.deleteBuffer(buffer)
      gl.deleteVertexArray(quad)
      gl.deleteProgram(program)
    }
    // `sources` stands in for `items`: the loop owns the atlas, so it must
    // rebuild when the pictures change and must not when a label does.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sources, count, reduced])

  const item = items[active]

  // No WebGL2 - a blank rectangle is the one outcome worse than no effect. The
  // ring without its shader is still the work: a native snap scroller of the
  // same pictures, in the same order.
  if (!supported) {
    return (
      <section
        aria-roledescription="carousel"
        aria-label={brand ?? "Gallery"}
        className={cn("bg-background text-foreground relative h-full w-full", className)}
        {...props}
      >
        <ul className="flex h-full snap-y snap-mandatory flex-col items-center gap-3 overflow-y-auto py-[6%]">
          {items.map((entry) => (
            <li key={entry.image} className="w-[62%] shrink-0 snap-center">
              <img
                src={entry.image}
                alt={entry.title}
                className="bg-muted w-full rounded-lg object-cover"
                style={{ aspectRatio: cardRatio }}
              />
            </li>
          ))}
        </ul>
      </section>
    )
  }

  return (
    <section
      aria-roledescription="carousel"
      aria-label={brand ?? "Gallery"}
      className={cn(
        "bg-background text-foreground relative h-full w-full overflow-hidden select-none",
        className
      )}
      {...props}
    >
      <canvas
        ref={canvasRef}
        tabIndex={0}
        role="listbox"
        aria-label={brand ?? "Gallery"}
        aria-activedescendant={`molten-ring-${active}`}
        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"
        onKeyDown={(event) => {
          if (event.key === "ArrowDown") step.current(1)
          else if (event.key === "ArrowUp") step.current(-1)
          else return
          event.preventDefault()
        }}
      />

      {/* Everything visible is painted into the canvas, so assistive technology
          and keyboard users are given this equivalent instead: the same entries
          in the same sequence. */}
      <ul className="sr-only">
        {items.map((entry, i) => (
          <li key={entry.image} id={`molten-ring-${i}`} role="option" aria-selected={i === active}>
            {entry.title}
            {entry.meta ? `. ${entry.meta}` : ""}
          </li>
        ))}
      </ul>

      {brand ? (
        <div className="pointer-events-none absolute top-[6%] left-[5%] text-sm font-medium tracking-tight">
          {brand}
        </div>
      ) : null}

      {/* Labels flank the arc the way a plate caption does - index and title to
          the left, classification to the right. */}
      <div className="pointer-events-none absolute top-1/2 left-[5%] -translate-y-1/2">
        <div className="text-muted-foreground text-xs tabular-nums">
          {String(active + 1).padStart(2, "0")}
        </div>
        <div className="mt-1 text-xl leading-none font-medium tracking-tight">{item?.title}</div>
      </div>

      {item?.meta ? (
        <div className="text-muted-foreground pointer-events-none absolute top-1/2 right-[5%] -translate-y-1/2 text-right text-xs">
          {item.meta}
        </div>
      ) : null}
    </section>
  )
}
```
