{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dither-helix-carousel",
  "type": "registry:ui",
  "title": "Dither Helix Carousel",
  "description": "A spiral column of cards, each mapped onto the surface of the cylinder it orbits so its outer edges retreat in depth. As cards travel away they pass through a single-axis blur and then break up into an ordered threshold pattern read off a three-point tone scale. Pointing at a card clears it and pushes the rest back. The scale is built from your own theme tokens, so it prints correctly in light and dark.",
  "files": [
    {
      "path": "registry/crafterui/ui/dither-helix-carousel.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n// A spiral column of work that breaks up into print grain as it travels away\n// from the viewer.\n//\n// No card is a flat rectangle. The vertex stage maps each one onto the surface\n// of the cylinder it orbits, so the outer edges retreat in depth while the\n// middle stays square to the camera, and the extremities trail behind the\n// centre while the column is turning.\n//\n// It is built as a pipeline rather than an overlay:\n//\n//   cards -> colour + channel data -> four axial blurs -> resolve -> screen\n//\n// One progression value drives every stage of it, taken as the larger of two\n// measurements: how close a pixel sits to the top or bottom border, and how far\n// back into the spiral it lies. The smear dominates the first half of that\n// progression and retreats as the grain claims the second, so the pair reads as\n// one continuous process instead of two treatments layered together.\n//\n// The grain itself is an ordered threshold matrix evaluated against a\n// three-point tone scale whose outer stops are both the page colour, sampled at\n// the centre of each cell so the cell fills evenly. With both ends of the scale\n// equal, the extremes of the range coincide and only a middle band departs\n// from the page - which is why the result prints as texture rather than as\n// flat posterisation.\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface DitherHelixItem {\n  /** Cover art. Cross-origin sources must send CORS headers. */\n  image: string\n  /** Shown while this card is at the front. */\n  title: string\n}\n\nexport interface DitherHelixCarouselProps\n  extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  items: DitherHelixItem[]\n  /** Wordmark in the top-left. Omit to drop it. @default undefined */\n  brand?: string\n  /** The lit tone the grain resolves to. Defaults to the theme's foreground. @default undefined */\n  accent?: string\n  /** Grain lattice pitch, measured in device pixels. Larger prints coarser. @default 7.5 */\n  cell?: number\n  /** How much of the frame's height stays sharp before the dissolve starts, 0-1. @default 0.25 */\n  focusBand?: number\n  /** Turn of the helix between one card and the next, in radians. @default 0.8 */\n  twist?: number\n  /** Rise of the helix between one card and the next, in card heights. @default 0.79 */\n  rise?: number\n  /** Card width / height. Art is cover-fitted into it, so match your own. @default 2 */\n  cardRatio?: number\n  /** Play the arrival - cards materialize out of the grain. @default true */\n  entry?: boolean\n  /** Extra classes on the root surface. @default undefined */\n  className?: string\n}\n\n/* Spiral geometry, in world units. Vertical spacing is set below the card\n   height so consecutive cards overlap and the column resolves as one continuous\n   band instead of a stack of separate tiles. */\nconst CARD_H = 1.6\nconst RADIUS = 3.8\nconst RADIUS_STEP = 0.055 // radius step per slot, or overlapping cards z-fight\nconst CAMERA_Z = 10\nconst FOV = 48\nconst NEAR = 0.1\nconst FAR = 60\n\n/* Navigation. Wheel and pointer both advance a destination value that the\n   column chases; once input has stayed quiet long enough to count as finished,\n   the destination is rounded to the nearest card. */\nconst WHEEL = 0.0022\nconst DRAG = 0.007\nconst EASE = 0.075\nconst SNAP_IDLE = 300 // ms; must exceed the spacing of events within one gesture\nconst SNAP_EASE = 0.055\nconst CLICK_SLOP = 6\n\n/* Cards deform while the column rotates. The falloff runs vertically, so upper\n   and lower edges sweep around the axis while the waist stays put and the\n   surface curves away from the direction of travel. */\nconst BEND = 2.7\nconst BEND_EASE = 0.12\nconst BEND_MAX = 0.07\n\n/* Selective focus. Whichever card the cursor rests on clears, while the others\n   darken, soften and take on grain. The selection is only reassigned once\n   pointer velocity drops to deliberate speed; without that gate, dragging\n   quickly across the column would strobe the focus card by card. */\nconst HOVER_IN = 0.095\nconst HOVER_OUT = 0.07\nconst HOVER_SETTLE = 8 // px/frame under which a movement counts as aiming\nconst FOCUS_FALLOFF = 0.7 // slots over which the dimming ramps to full\n\n/* Introduction. Un-arrived cards are omitted rather than towardPage. A card that is\n   not yet present emits nothing, so the blur stages have no light from it to\n   drag across the frame. */\nconst ENTRY_MS = 1050\nconst ENTRY_STAGGER_MS = 60\nconst ENTRY_SPIN = 9.4 // slots the helix glides in over\nconst ENTRY_SPIN_MS = 2400\n\nconst CLICK_MS = 1300\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\n\n/* Ordered threshold matrix built by recursion, yielding an 8x8 pattern in\n   [0,1) with no lookup table. Shared by the card stage and the resolve stage so\n   a card materialises on precisely the grid it will later break up on. */\nconst BAYER = /* glsl */ `\nfloat bayer2(vec2 a) { a = floor(a); return fract(a.x / 2.0 + a.y * a.y * 0.75); }\n#define bayer4(a) (bayer2(0.5 * (a)) * 0.25 + bayer2(a))\n#define bayer8(a) (bayer4(0.5 * (a)) * 0.25 + bayer2(a))\n`\n\nconst CARD_VERT = /* glsl */ `#version 300 es\nin vec2 aPos;                  // 0..1 across the card\n\nuniform float uIndex;\nuniform float uProgress;\nuniform float uCount;\nuniform float uAngleStep;\nuniform float uPitch;\nuniform float uVelocity;\nuniform vec2  uCard;           // width, height in world units\nuniform float uFocal;          // 1 / tan(fov / 2)\nuniform float uAspect;\n\nout vec2 vUv;\nout float vDepth;\n\nconst float RADIUS = ${RADIUS.toFixed(3)};\nconst float RADIUS_STEP = ${RADIUS_STEP.toFixed(4)};\nconst float CAMERA_Z = ${CAMERA_Z.toFixed(1)};\nconst float NEAR = ${NEAR.toFixed(2)};\nconst float FAR = ${FAR.toFixed(1)};\nconst float BEND = ${BEND.toFixed(2)};\n\nvoid main() {\n  vUv = vec2(aPos.x, 1.0 - aPos.y);\n  vec2 local = (aPos - 0.5) * uCard;\n\n  // Position within the cycle. The seam is never noticed because it falls in\n  // the fully dissolved region beyond the top and bottom borders.\n  // Referenced to the facing slot rather than to half the total. Half of an odd\n  // total lands between two positions, leaving nothing squared up to the camera.\n  float slot = mod(uIndex - uProgress, uCount) - floor(uCount * 0.5);\n  float baseAngle = slot * uAngleStep;\n  float baseY = slot * uPitch;\n\n  // Trailing deformation, with the falloff taken over the card's HEIGHT: upper\n  // and lower edges swing laterally about the axis while the waist holds, which\n  // curves the surface on its side. Because the angular offset varies down the\n  // height, each horizontal row is displaced as a unit and its width is\n  // untouched - a falloff taken over the width instead would elongate the card.\n  float ty = (aPos.y - 0.5) * 2.0;\n  baseAngle += uVelocity * BEND * ty * ty * uAngleStep * 0.5;\n\n  float r = RADIUS + slot * RADIUS_STEP;\n  float theta = baseAngle + local.x / r;   // distance across the card as rotation\n  vec3 p = vec3(sin(theta) * r, local.y + baseY, cos(theta) * r);\n\n  float vz = p.z - CAMERA_Z;\n  vDepth = -vz;\n  gl_Position = vec4(\n    p.x * uFocal / uAspect,\n    p.y * uFocal,\n    ((FAR + NEAR) * vz + 2.0 * FAR * NEAR) / (NEAR - FAR),\n    -vz\n  );\n}`\n\nconst CARD_FRAG = /* glsl */ `#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin float vDepth;\n\nuniform sampler2D uMap;\nuniform vec2 uImageRatio;      // cover-fit, so nothing is stretched\nuniform vec3 uBackground;\nuniform float uHover;\nuniform float uDim;\nuniform float uEntry;          // 1 = absent, 0 = arrived\nuniform float uEntryScale;\nuniform float uEntryAspect;\n\nlayout(location = 0) out vec4 outScene;   // rgb, a = nearness\nlayout(location = 1) out vec4 outMeta;    // g = dim, b = hover, a = arrived\n\nconst float FOG_NEAR = 8.0;\nconst float FOG_FAR = 20.6;\nconst float DIM_FADE = 0.67;\nconst float ENTRY_SOFTNESS = 0.45;\n\n${BAYER}\n\n/* Materialisation: a circle expanding from the card's midpoint, its perimeter\n   fragmented cell by cell against the threshold matrix. This gates presence\n   rather than opacity - a given cell carries the image at full intensity or\n   carries nothing at all, which is what stops it looking like a simple fade.\n\n   Cell size is derived from gl_FragCoord in device pixels instead of from uv,\n   keeping the pattern physically identical on every card regardless of how far\n   back it sits. */\nbool notArrived(vec2 uv) {\n  if (uEntry <= 0.0) return false;\n  vec2 offset = (uv - 0.5) * vec2(uEntryAspect, 1.0);\n  float d = length(offset) / length(vec2(uEntryAspect, 1.0) * 0.5);\n  // Extended beyond unity by the feather width so the boundary has somewhere to\n  // finish; once progress completes, the most distant cell still sits a whole\n  // feather within the card.\n  float front = (1.0 - uEntry) * (1.0 + ENTRY_SOFTNESS);\n  return (front - d) / ENTRY_SOFTNESS <= bayer8(gl_FragCoord.xy / uEntryScale);\n}\n\nvoid main() {\n  // Rejected outright instead of mixed toward the page colour. A card that has\n  // not appeared is absent, not dim; mixing would deposit its luminance into the\n  // colour target for the blur stages to drag across the whole frame.\n  if (notArrived(vUv)) discard;\n\n  vec2 uv = (vUv - 0.5) * uImageRatio + 0.5;\n  vec3 color = texture(uMap, uv).rgb;\n\n  // Depth cueing. Contrast is reduced with distance so the far side of the\n  // spiral recedes into haze, rather than presenting as equally vivid cards that\n  // are simply drawn smaller. Selection exempts a card from it entirely.\n  float fog = smoothstep(FOG_NEAR, FOG_FAR, vDepth) * (1.0 - uHover);\n  color = mix(color, uBackground, fog);\n\n  // Attenuation for unselected cards, gamma-corrected so the parameter behaves\n  // perceptually - a linear half-mix registers as roughly a quarter as dark.\n  color = mix(color, uBackground, 1.0 - pow(1.0 - uDim * DIM_FADE, 2.2));\n\n  // The alpha channel transports proximity rather than transparency; the\n  // resolve stage reads it to choose a blur level per pixel. It is stored\n  // inverted so that cleared background, having no depth, registers as close and\n  // is left sharp.\n  outScene = vec4(color, 1.0 - fog);\n  outMeta = vec4(0.0, uDim, uHover, 1.0 - uEntry);\n}`\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\n/* Single-axis blur, taken only along the vertical. A radially symmetric kernel\n   would suggest a lens out of focus; a stretched one suggests displacement. Each\n   stage also halves the target dimensions, so successive stages compound the\n   reach at no additional sampling cost. */\nconst BLUR_FRAG = /* glsl */ `#version 300 es\nprecision highp float;\nin vec2 vUv;\nuniform sampler2D uMap;\nuniform vec2 uTexel;\nuniform float uSpread;\nout vec4 fragColor;\nvoid main() {\n  vec2 stride = vec2(0.0, uTexel.y) * uSpread;\n  vec4 sum = vec4(0.0);\n  float total = 0.0;\n  for (int i = -8; i <= 8; i++) {\n    float fi = float(i);\n    float w = exp(-fi * fi / 18.0);\n    sum += texture(uMap, vUv + stride * fi) * w;\n    total += w;\n  }\n  fragColor = sum / total;\n}`\n\nconst COMPOSITE_FRAG = /* glsl */ `#version 300 es\nprecision highp float;\n\nin vec2 vUv;\n\nuniform sampler2D uScene;\nuniform sampler2D uMeta;\nuniform sampler2D uBlur1;\nuniform sampler2D uBlur2;\nuniform sampler2D uBlur3;\nuniform sampler2D uBlur4;\n\nuniform vec2  uResolution;\nuniform vec3  uBackground;\nuniform vec3  uAccent;\nuniform float uFocusSize;\nuniform float uDitherScale;\nuniform float uEntryScale;\n\nout vec4 fragColor;\n\nconst float EDGE_POWER = 1.65;\nconst float BLUR_STRENGTH = 0.47;\nconst float FADE_STRENGTH = 0.4;\nconst float DITHER_AMOUNT = 0.77;\nconst float DITHER_START = 0.64;\nconst float DITHER_POWER = 1.25;\nconst float LEVELS = 8.0;\nconst float GAMMA = 1.8;\nconst float MONO = 0.22;\n\n/* Sequencing between the two treatments. The smear governs the early portion of\n   the progression and yields ground as the grain assumes the later portion. The\n   two deliberately overlap around the smear's maximum, which is what makes the\n   transition continuous rather than one effect ending as another begins. */\nconst float STAGING = 0.55;\nconst float SMEAR_END = 0.55;\nconst float GRAIN_BEGIN = 0.45;\nconst float YIELD = 0.75;\n\nconst float HOVER_BLUR = 0.13;\nconst float HOVER_DITHER = 0.3;\nconst float HOVER_CURVE = 1.9;\nconst float HOVER_LEVELS = 8.0;\nconst float HOVER_CUTOFF = 0.22;\nconst float HOVER_GAMMA = 1.8;\nconst float ENTRY_DITHER = 0.45;\nconst float ENTRY_LEVELS = 4.0;\nconst float ENTRY_GAMMA = 1.5;\n\n${BAYER}\n\nfloat luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }\n\n/* Collapses the blur stages into a single colour at a fractional level.\n   Factored out so each grain pass can re-sample it on its own cell lattice\n   without recomputing the inter-stage weights. */\nvec3 blurStack(vec2 uv, float lvl) {\n  vec3 c = texture(uScene, uv).rgb;\n  c = mix(c, texture(uBlur1, uv).rgb, clamp(lvl - 0.0, 0.0, 1.0));\n  c = mix(c, texture(uBlur2, uv).rgb, clamp(lvl - 1.0, 0.0, 1.0));\n  c = mix(c, texture(uBlur3, uv).rgb, clamp(lvl - 2.0, 0.0, 1.0));\n  c = mix(c, texture(uBlur4, uv).rgb, clamp(lvl - 3.0, 0.0, 1.0));\n  return c;\n}\n\n/* Mixing toward the page before quantisation lets the pattern dissipate into\n   the background instead of terminating against it in visible steps. */\nvec3 towardPage(vec3 c, float fade) { return mix(c, uBackground, fade); }\n\n/* Quantises the sample position to the pattern lattice. Reading from the\n   centre of each cell holds both colour and threshold constant across it, so the\n   cell fills uniformly. Omit this and every cell retains full-resolution detail\n   underneath a dot pattern, which registers as noise deposited on the image\n   rather than the image being reproduced through a screen. */\nvec2 latticeUv(float cell) {\n  return (floor(gl_FragCoord.xy / cell) + 0.5) * cell / uResolution;\n}\n\n/* A three-point tone scale. The accent occupies a discrete position at the\n   centre of the range instead of being mixed across it, which is what makes a\n   single hue register as a deliberate choice rather than a wash over the whole\n   frame. Both outer stops hold the page colour, so the extremes of the range\n   coincide and only the middle band departs from it. */\nvec3 toneScale(float t) {\n  return t < 0.5\n    ? mix(uBackground, uAccent, t * 2.0)\n    : mix(uAccent, uBackground, (t - 0.5) * 2.0);\n}\n\n/* Reduces luminance to a small number of levels, breaks up the transitions\n   against the threshold, and looks the result up on the tone scale. The blend\n   factor retains a proportion of the source's own colour by quantising each\n   channel independently on the same levels. */\nvec3 quantise(vec3 c, float threshold, float levels, float gamma) {\n  float steps = max(levels - 1.0, 1.0);\n  vec3 toned = toneScale(floor(pow(clamp(luma(c), 0.0, 1.0), gamma) * steps + threshold) / steps);\n  vec3 quantized = floor(c * steps + threshold) / steps;\n  return mix(quantized, toned, MONO);\n}\n\nvoid main() {\n  // Zero across the middle band, rising to one at the upper and lower borders.\n  float d = abs(vUv.y - 0.5) * 2.0;\n  float edge = pow(smoothstep(uFocusSize, 1.0, d), EDGE_POWER);\n\n  vec4 scene = texture(uScene, vUv);\n  vec4 meta = texture(uMeta, vUv);\n  float dim = meta.g;\n  float entry = 1.0 - meta.a;\n\n  // Exempts the selected card from the border treatment. That treatment keys\n  // solely off where a pixel sits, and absent the exemption a card the viewer\n  // had just cleared would still smear for no reason but its position.\n  float keep = 1.0 - meta.b;\n\n  // A pixel can recede for either of two reasons - depth into the spiral, or\n  // proximity to the upper and lower borders. The greater of the two becomes the\n  // controlling progression that every later stage is sequenced against.\n  float distance = 1.0 - scene.a;\n  float dissolve = max(edge, distance);\n\n  float grainFree = pow(smoothstep(DITHER_START, 1.0, max(d, distance)), DITHER_POWER);\n  float grainStaged = pow(smoothstep(GRAIN_BEGIN, 1.0, dissolve), DITHER_POWER);\n  float smearStaged = smoothstep(0.0, SMEAR_END, dissolve) * (1.0 - grainStaged * YIELD);\n\n  float blurDrive = mix(dissolve, smearStaged, STAGING);\n  float ditherDrive = mix(grainFree, grainStaged, STAGING);\n\n  float softness = max(blurDrive, dim * HOVER_BLUR) * keep;\n  float lvl = softness * BLUR_STRENGTH * 4.0;\n  float fade = edge * FADE_STRENGTH * keep;\n\n  vec3 c = towardPage(blurStack(vUv, lvl), fade);\n\n  // Border grain, driven by depth in addition to where a pixel sits. That is\n  // what lets an attenuated card take grain too, since retreating and going\n  // unselected amount to the same input at this stage.\n  float threshold = bayer8(gl_FragCoord.xy / uDitherScale);\n  vec3 source = uDitherScale > 1.0\n    ? towardPage(blurStack(latticeUv(uDitherScale), lvl), fade)\n    : c;\n  vec3 result = mix(c, quantise(source, threshold, LEVELS, GAMMA), DITHER_AMOUNT * ditherDrive * keep);\n\n  // Selection grain is mixed in at fixed settings rather than ramped, so the\n  // pattern holds a constant coarseness throughout and merely becomes visible,\n  // instead of getting rougher as it appears.\n  //\n  // Its tail is clipped to zero. Attenuation decays exponentially, so cells fall\n  // below their individual thresholds at a halving rate and a handful of\n  // stragglers would otherwise persist as long again as the bulk took to go.\n  float hoverRamp = smoothstep(HOVER_CUTOFF, 1.0, pow(dim, HOVER_CURVE)) * HOVER_DITHER;\n  float hoverThreshold = bayer8(gl_FragCoord.xy / (uDitherScale * 1.35));\n  vec3 hoverSource = towardPage(blurStack(latticeUv(uDitherScale * 1.35), lvl), fade);\n  // Derived from the unprocessed colour rather than the border-grained result.\n  // Quantising an already-quantised pixel re-evaluates the accent's luminance\n  // against the scale, which folds that entire band back into the page colour.\n  result = mix(result, quantise(hoverSource, hoverThreshold, HOVER_LEVELS, HOVER_GAMMA), hoverRamp);\n\n  // Materialisation grain is applied last, over everything else. While a card\n  // is still arriving it is the only thing worth reading; letting the standing\n  // grain show through would present as two treatments in competition rather\n  // than a single one completing.\n  float entryThreshold = bayer8(gl_FragCoord.xy / uEntryScale);\n  vec3 entrySource = towardPage(blurStack(latticeUv(uEntryScale), lvl), fade);\n  result = mix(\n    result,\n    quantise(entrySource, entryThreshold, ENTRY_LEVELS, ENTRY_GAMMA),\n    entry * ENTRY_DITHER\n  );\n\n  fragColor = vec4(clamp(result, 0.0, 1.0), 1.0);\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\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\nfunction texture(gl: WebGL2RenderingContext, w: number, h: number) {\n  const tex = gl.createTexture()\n  gl.bindTexture(gl.TEXTURE_2D, tex)\n  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)\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  return tex\n}\n\nexport function DitherHelixCarousel({\n  items,\n  brand,\n  accent,\n  cell = 7.5,\n  focusBand = 0.25,\n  twist = 0.8,\n  rise = 0.79,\n  cardRatio = 2,\n  entry: playEntry = true,\n  className,\n  ...props\n}: DitherHelixCarouselProps) {\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({ accent, cell, focusBand, twist, rise, cardRatio })\n  settings.current = { accent, cell, focusBand, twist, rise, cardRatio }\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 = items.length\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    const gl = canvas.getContext(\"webgl2\", { alpha: false, antialias: false })\n    if (!gl) {\n      setSupported(false)\n      return\n    }\n\n    const readColor = colorReader()\n    const cardProgram = build(gl, CARD_VERT, CARD_FRAG)\n    const blurProgram = build(gl, QUAD_VERT, BLUR_FRAG)\n    const compositeProgram = build(gl, QUAD_VERT, COMPOSITE_FRAG)\n    if (!cardProgram || !blurProgram || !compositeProgram) return\n    const cardU = uniforms(gl, cardProgram)\n    const blurU = uniforms(gl, blurProgram)\n    const compositeU = uniforms(gl, compositeProgram)\n\n    // --- geometry ---------------------------------------------------------\n    // Each card is a tessellated plane requiring subdivision on both axes. The\n    // trailing deformation that sweeps its upper and lower edges around the axis\n    // varies continuously down the height, and too few rows would render that\n    // curve as a fold.\n    const COLS = 40\n    const ROWS = 8\n    const verts: number[] = []\n    for (let y = 0; y < ROWS; y++) {\n      for (let x = 0; x < COLS; x++) {\n        const x0 = x / COLS\n        const x1 = (x + 1) / COLS\n        const y0 = y / ROWS\n        const y1 = (y + 1) / ROWS\n        verts.push(x0, y0, x1, y0, x0, y1, x0, y1, x1, y0, x1, y1)\n      }\n    }\n    const cardMesh = gl.createVertexArray()\n    gl.bindVertexArray(cardMesh)\n    const cardBuffer = gl.createBuffer()\n    gl.bindBuffer(gl.ARRAY_BUFFER, cardBuffer)\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW)\n    gl.enableVertexAttribArray(0)\n    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)\n    const cardVertexCount = verts.length / 2\n\n    const quad = gl.createVertexArray()\n    gl.bindVertexArray(quad)\n    const quadBuffer = gl.createBuffer()\n    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer)\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    // --- targets ----------------------------------------------------------\n    const sceneFbo = gl.createFramebuffer()\n    let sceneTex = texture(gl, 1, 1)\n    let metaTex = texture(gl, 1, 1)\n    const depth = gl.createRenderbuffer()\n    const blurFbos = [0, 1, 2, 3].map(() => gl.createFramebuffer())\n    let blurTex = [0, 1, 2, 3].map(() => texture(gl, 1, 1))\n\n    const attach = (w: number, h: number) => {\n      gl.bindFramebuffer(gl.FRAMEBUFFER, sceneFbo)\n      gl.deleteTexture(sceneTex)\n      gl.deleteTexture(metaTex)\n      sceneTex = texture(gl, w, h)\n      metaTex = texture(gl, w, h)\n      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, sceneTex, 0)\n      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, metaTex, 0)\n      gl.bindRenderbuffer(gl.RENDERBUFFER, depth)\n      gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h)\n      gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth)\n      gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1])\n\n      // Every stage halves the target dimensions in addition to blurring, so\n      // reach compounds and the deepest stages are nearly free.\n      blurTex.forEach((tex) => gl.deleteTexture(tex))\n      blurTex = blurFbos.map((fbo, i) => {\n        const tex = texture(gl, Math.max(1, w >> (i + 1)), Math.max(1, h >> (i + 1)))\n        gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)\n        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)\n        return tex\n      })\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n    }\n\n    // --- textures ---------------------------------------------------------\n    const cards = items.map(() => ({ texture: null as WebGLTexture | null, aspect: 1.5 }))\n    const images = items.map((item, i) => {\n      const image = new Image()\n      image.crossOrigin = \"anonymous\"\n      image.decoding = \"async\"\n      image.onload = () => {\n        const tex = gl.createTexture()\n        gl.bindTexture(gl.TEXTURE_2D, tex)\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_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        gl.generateMipmap(gl.TEXTURE_2D)\n        cards[i].texture = tex\n        cards[i].aspect = image.naturalWidth / Math.max(image.naturalHeight, 1)\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 smoothed = 0 // velocity feeding the bend\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 ticks = 0\n    let frame = 0\n    let entryStart = 0\n    let background: [number, number, number] = [0, 0, 0]\n    let accentRgb: [number, number, number] = [1, 1, 1]\n\n    // Bringing a card to the front runs on a fixed duration rather than a decay\n    // rate, so the traversal takes equally long over one slot or six.\n    let tween: { from: number; to: number; at: number } | null = null\n\n    const dim = new Float32Array(count)\n    const hover = new Float32Array(count)\n    const entryOf = new Float32Array(count).fill(playEntry && !reduced ? 1 : 0)\n    // Re-randomised on every run so the effect is of individual cards\n    // appearing, not of a fixed order being replayed.\n    const order = items.map((_, i) => i).sort(() => Math.random() - 0.5)\n\n    const focal = 1 / Math.tan((FOV * Math.PI) / 360)\n    /** The slot that faces the camera. Everything is measured from here. */\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      attach(canvas.width, canvas.height)\n    }\n    resize()\n    const observer = new ResizeObserver(resize)\n    observer.observe(canvas)\n\n    /** Screen placement for a given index in CSS pixels, together with its\n        distance from the camera. The layout is columnar, so this is sufficient\n        for hit-testing without a ray cast. */\n    const project = (index: number) => {\n      const slot = ((((index - progress) % count) + count) % count) - FRONT\n      const angle = slot * settings.current.twist\n      const y = slot * settings.current.rise * CARD_H\n      const z = Math.cos(angle) * (RADIUS + slot * RADIUS_STEP)\n      const away = CAMERA_Z - z\n      if (away <= NEAR) return null\n      const aspect = width / Math.max(height, 1)\n      const sx = ((Math.sin(angle) * (RADIUS + slot * RADIUS_STEP) * focal) / aspect / away) * 0.5 + 0.5\n      const sy = 0.5 - ((y * focal) / away) * 0.5\n      return {\n        x: sx * width,\n        y: sy * height,\n        halfW: ((CARD_H * settings.current.cardRatio * focal) / aspect / away) * 0.5 * width * 0.5,\n        halfH: ((CARD_H * focal) / away) * 0.5 * height * 0.5,\n        away,\n      }\n    }\n\n    /** Closest card whose projected bounds contain the pointer. */\n    const pick = (px: number, py: number) => {\n      let best = -1\n      let bestAway = Infinity\n      for (let i = 0; i < count; i++) {\n        const at = project(i)\n        if (!at) continue\n        if (Math.abs(px - at.x) > at.halfW || Math.abs(py - at.y) > at.halfH) continue\n        if (at.away < bestAway) {\n          bestAway = at.away\n          best = i\n        }\n      }\n      return best\n    }\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 = (event: PointerEvent) => {\n      const wasClick = dragFrom !== null && dragTravel < CLICK_SLOP\n      dragFrom = null\n      if (!wasClick) return\n      const hit = pick(pointerX, pointerY)\n      // Advance to the facing slot along whichever direction is shorter.\n      if (hit >= 0) {\n        const want = (((hit - FRONT) % count) + count) % count\n        const to = want + Math.round((goal - want) / count) * count\n        tween = { from: goal, to, at: performance.now() }\n        snapped = true\n      }\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 drawQuad = () => {\n      gl.bindVertexArray(quad)\n      gl.drawArrays(gl.TRIANGLES, 0, 6)\n    }\n\n    const draw = (now: number) => {\n      frame = requestAnimationFrame(draw)\n      if (!width || !height) return\n      const { cell: cellPx, focusBand: band, twist: turn, rise: pitch, cardRatio: ratio } = settings.current\n      const cardW = CARD_H * ratio\n\n      if (ticks++ % THEME_EVERY === 0) {\n        background = readColor(getComputedStyle(canvas).backgroundColor)\n        accentRgb = readColor(settings.current.accent ?? getComputedStyle(canvas).color)\n      }\n\n      // Held back until there is artwork to reveal - an untextured card samples\n      // as black, so beginning earlier would spend the sequence on empty\n      // rectangles.\n      const ready = cards.every((card) => card.texture)\n      if (ready && !entryStart) entryStart = now\n      const elapsed = entryStart ? now - entryStart : 0\n      if (ready && playEntry && !reduced) {\n        for (let rank = 0; rank < count; rank++) {\n          const t = (elapsed - rank * ENTRY_STAGGER_MS) / ENTRY_MS\n          entryOf[order[rank]] = 1 - clamp(t, 0, 1)\n        }\n        // Rotation is timed independently of the per-card schedule: it is one\n        // gesture applied to the whole column, and it should still be slowing\n        // as the final card appears.\n        const spin = clamp(elapsed / ENTRY_SPIN_MS, 0, 1)\n        progress = goal - ENTRY_SPIN * (1 - inOutCubic(spin))\n      } else if (ready) {\n        entryOf.fill(0)\n      }\n      const arriving = playEntry && !reduced && ready && elapsed < ENTRY_SPIN_MS\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) * inOutCubic(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\n      const before = progress\n      if (!arriving) {\n        progress += (goal - progress) * (reduced ? 1 : snapped ? SNAP_EASE : EASE)\n      }\n      const velocity = progress - before\n      smoothed += (clamp(velocity, -BEND_MAX, BEND_MAX) - smoothed) * BEND_EASE\n\n      const near = ((Math.round(goal) + FRONT) % count + count) % count\n      setActive((prev) => (prev === near ? prev : near))\n\n      // --- hover ----------------------------------------------------------\n      // Reassigned only once pointer velocity drops to deliberate speed;\n      // otherwise a fast sweep would strobe the focus from card to card.\n      if (pointerX >= 0 && pointerSpeed < HOVER_SETTLE) hovered = pick(pointerX, pointerY)\n      pointerSpeed *= 0.8\n      for (let i = 0; i < count; i++) {\n        const isHovered = i === hovered\n        const wantHover = isHovered ? 1 : 0\n        hover[i] += (wantHover - hover[i]) * (wantHover > hover[i] ? HOVER_IN : HOVER_OUT)\n        let wantDim = 0\n        if (hovered >= 0) {\n          // Distance in slots, taken the short way round the loop.\n          let gapTo = Math.abs(i - hovered)\n          gapTo = Math.min(gapTo, count - gapTo)\n          wantDim = clamp(gapTo / FOCUS_FALLOFF, 0, 1)\n        }\n        dim[i] += (wantDim - dim[i]) * (wantDim > dim[i] ? HOVER_IN : HOVER_OUT)\n      }\n\n      // --- pass one: the cards -------------------------------------------\n      gl.bindFramebuffer(gl.FRAMEBUFFER, sceneFbo)\n      gl.viewport(0, 0, canvas.width, canvas.height)\n      gl.enable(gl.DEPTH_TEST)\n      gl.clearBufferfv(gl.COLOR, 0, [background[0], background[1], background[2], 1])\n      gl.clearBufferfv(gl.COLOR, 1, [0, 0, 0, 1])\n      gl.clear(gl.DEPTH_BUFFER_BIT)\n      gl.useProgram(cardProgram)\n      gl.bindVertexArray(cardMesh)\n      gl.uniform1f(cardU(\"uCount\"), count)\n      gl.uniform1f(cardU(\"uProgress\"), progress)\n      gl.uniform1f(cardU(\"uAngleStep\"), turn)\n      gl.uniform1f(cardU(\"uPitch\"), pitch * CARD_H)\n      gl.uniform1f(cardU(\"uVelocity\"), smoothed)\n      gl.uniform2f(cardU(\"uCard\"), cardW, CARD_H)\n      gl.uniform1f(cardU(\"uFocal\"), focal)\n      gl.uniform1f(cardU(\"uAspect\"), width / height)\n      gl.uniform3fv(cardU(\"uBackground\"), background)\n      gl.uniform1i(cardU(\"uMap\"), 0)\n      gl.uniform1f(cardU(\"uEntryScale\"), 9.5)\n      gl.uniform1f(cardU(\"uEntryAspect\"), ratio)\n      gl.activeTexture(gl.TEXTURE0)\n\n      for (let i = 0; i < count; i++) {\n        const card = cards[i]\n        if (!card.texture) continue\n        gl.bindTexture(gl.TEXTURE_2D, card.texture)\n        gl.uniform1f(cardU(\"uIndex\"), i)\n        gl.uniform1f(cardU(\"uHover\"), hover[i])\n        gl.uniform1f(cardU(\"uDim\"), dim[i])\n        gl.uniform1f(cardU(\"uEntry\"), entryOf[i])\n        gl.uniform2f(\n          cardU(\"uImageRatio\"),\n          card.aspect < ratio ? 1 : ratio / card.aspect,\n          card.aspect < ratio ? card.aspect / ratio : 1\n        )\n        gl.drawArrays(gl.TRIANGLES, 0, cardVertexCount)\n      }\n      gl.disable(gl.DEPTH_TEST)\n\n      // --- pass two: the blur chain ---------------------------------------\n      gl.useProgram(blurProgram)\n      gl.uniform1i(blurU(\"uMap\"), 0)\n      gl.uniform1f(blurU(\"uSpread\"), 4.5)\n      let source = sceneTex\n      for (let i = 0; i < 4; i++) {\n        const w = Math.max(1, canvas.width >> (i + 1))\n        const h = Math.max(1, canvas.height >> (i + 1))\n        gl.bindFramebuffer(gl.FRAMEBUFFER, blurFbos[i])\n        gl.viewport(0, 0, w, h)\n        gl.activeTexture(gl.TEXTURE0)\n        gl.bindTexture(gl.TEXTURE_2D, source)\n        gl.uniform2f(blurU(\"uTexel\"), 1 / w, 1 / h)\n        drawQuad()\n        source = blurTex[i]\n      }\n\n      // --- pass three: the composite --------------------------------------\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n      gl.viewport(0, 0, canvas.width, canvas.height)\n      gl.useProgram(compositeProgram)\n      const bound = [sceneTex, metaTex, ...blurTex]\n      const names = [\"uScene\", \"uMeta\", \"uBlur1\", \"uBlur2\", \"uBlur3\", \"uBlur4\"]\n      bound.forEach((tex, unit) => {\n        gl.activeTexture(gl.TEXTURE0 + unit)\n        gl.bindTexture(gl.TEXTURE_2D, tex)\n        gl.uniform1i(compositeU(names[unit]), unit)\n      })\n      gl.uniform2f(compositeU(\"uResolution\"), canvas.width, canvas.height)\n      gl.uniform3fv(compositeU(\"uBackground\"), background)\n      gl.uniform3fv(compositeU(\"uAccent\"), accentRgb)\n      gl.uniform1f(compositeU(\"uFocusSize\"), band)\n      gl.uniform1f(compositeU(\"uDitherScale\"), cellPx)\n      gl.uniform1f(compositeU(\"uEntryScale\"), 9.5)\n      drawQuad()\n      gl.activeTexture(gl.TEXTURE0)\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      for (const card of cards) if (card.texture) gl.deleteTexture(card.texture)\n      gl.deleteTexture(sceneTex)\n      gl.deleteTexture(metaTex)\n      blurTex.forEach((tex) => gl.deleteTexture(tex))\n      blurFbos.forEach((fbo) => gl.deleteFramebuffer(fbo))\n      gl.deleteFramebuffer(sceneFbo)\n      gl.deleteRenderbuffer(depth)\n      gl.deleteBuffer(cardBuffer)\n      gl.deleteBuffer(quadBuffer)\n      gl.deleteVertexArray(cardMesh)\n      gl.deleteVertexArray(quad)\n      gl.deleteProgram(cardProgram)\n      gl.deleteProgram(blurProgram)\n      gl.deleteProgram(compositeProgram)\n    }\n    // `sources` stands in for `items`: the loop owns the textures, 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, playEntry])\n\n  // No WebGL2 - a blank rectangle is the one outcome worse than no effect. The\n  // helix 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={`dither-helix-${active}`}\n        className=\"bg-background 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 receive this equivalent instead: the same entries in\n          the same sequence. */}\n      <ul className=\"sr-only\">\n        {items.map((item, i) => (\n          <li key={item.image} id={`dither-helix-${i}`} role=\"option\" aria-selected={i === active}>\n            {item.title}\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      <div className=\"pointer-events-none absolute bottom-[7%] left-[5%] text-2xl leading-none font-medium tracking-tight\">\n        {items[active]?.title}\n      </div>\n\n      <div className=\"text-muted-foreground pointer-events-none absolute right-[5%] bottom-[7%] text-sm tabular-nums\">\n        {String(active + 1).padStart(2, \"0\")}\n        <span className=\"opacity-50\"> / {String(count).padStart(2, \"0\")}</span>\n      </div>\n    </section>\n  )\n}\n"
    }
  ]
}
