shadimage
spaceui
blockblock-dither-carouselsource: ok · hit

Dither Carousel Block

3D cylindrical spiral carousel in WebGL2 with curved cards, axial directional blur, and matrix dither pattern.

npx shadcn add "https://www.spaceui.one/r/block-dither-carousel.json"
free

Visual style

glass-blur3d

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

https://www.spaceui.one/r/hooks-utils-logger.jsonhttps://www.spaceui.one/r/lib-utils.jsonhttps://www.spaceui.one/r/lib-next-image-url.json

Files4 · source cached 10/9/2026, 1:26:59 PM

src/registry/blocks/dither-carousel/index.tsx
'use client'

import * as React from 'react'
import { DitherCarousel } from './dither-carousel'
import type { DitherCarouselItem } from './types'

export const WORK_DITHER_ITEMS: DitherCarouselItem[] = [
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-42.png',
    title: 'Cloud Reverie',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-19.png',
    title: 'Floral Veil',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-27.png',
    title: 'Prism Portrait',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-32.png',
    title: 'Sunlit Freckles',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-33.png',
    title: 'Hydrangea Profile',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-34.png',
    title: 'Glass Ruffle',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-35.png',
    title: 'Cyber Visor',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-36.png',
    title: 'Jellyfish Veil',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-37.png',
    title: 'Light Streak',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-38.png',
    title: 'Amber Silhouette',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-39.png',
    title: 'Kinetic Sprint',
  },
  {
    image: 'https://cdn.spaceui.one/atom/samples/image-40.png',
    title: 'Astronaut Visor',
  },
]

export default function BlockDitherCarousel() {
  return (
    <div className="relative h-dvh w-full overflow-hidden rounded-3xl bg-background">
      <DitherCarousel
        items={WORK_DITHER_ITEMS}
        cell={7.5}
        focusBand={0.32}
        twist={0.8}
        rise={0.79}
        cardHeight={2.2}
        cardRatio={0.82}
        gooey={0.4}
        ditherShape="dots"
      />
    </div>
  )
}
src/registry/blocks/dither-carousel/dither-carousel.tsx
'use client'

import * as React from 'react'
import NextImage from 'next/image'
import { cn } from '@/lib/utils'
import { getOptimizedImageUrl } from '@/lib/next-image-url'
import { logger } from '@/utils/logger'
import {
  BLUR_FRAG,
  CARD_FRAG,
  CARD_H,
  CARD_VERT,
  CAMERA_Z,
  COMPOSITE_FRAG,
  FOV,
  NEAR,
  QUAD_VERT,
  RADIUS,
  RADIUS_STEP,
} from './shaders'
import type { DitherCarouselItem, DitherCarouselProps } from './types'

const WHEEL = 0.0022
const DRAG = 0.007
const EASE = 0.075
const SNAP_IDLE = 300
const SNAP_EASE = 0.055
const CLICK_SLOP = 6

const BEND_EASE = 0.12
const BEND_MAX = 0.07

const HOVER_IN = 0.095
const HOVER_OUT = 0.07
const HOVER_SETTLE = 8
const FOCUS_FALLOFF = 0.7

const ENTRY_MS = 1050
const ENTRY_STAGGER_MS = 60
const ENTRY_SPIN = 9.4
const ENTRY_SPIN_MS = 2400

const CLICK_MS = 1300
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)

function buildProgram(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)) {
      logger.error('DitherCarousel shader 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)) {
    logger.error('DitherCarousel program link error:', gl.getProgramInfoLog(program))
    return null
  }
  return program
}

function getUniforms(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
  }
}

function createColorReader() {
  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]
  }
}

function createTexture(gl: WebGL2RenderingContext, w: number, h: number) {
  const tex = gl.createTexture()
  gl.bindTexture(gl.TEXTURE_2D, tex)
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
  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)
  return tex
}

const ATMOSPHERIC_PALETTES: [string, string, string, string][] = [
  ['#F0E6EB', '#B05A49', '#E9C88F', '#30334A'],
  ['#8FAAD7', '#B0CDF2', '#D4ECFC', '#7089B6'],
  ['#D0AE93', '#FAEDCE', '#D5CDCA', '#8C7874'],
  ['#EDF5F8', '#C8D5DA', '#2A2728', '#B6AAB0'],
  ['#EDEDEE', '#332D2D', '#CDB7AC', '#534B4B'],
  ['#FAF7EE', '#EBCA94', '#D5D0CF', '#CFAA78'],
  ['#524F4A', '#75706A', '#979287', '#B6B3A8'],
  ['#4D4C48', '#757167', '#959288', '#B7B3A7'],
  ['#4D4B46', '#D8D3C8', '#777066', '#B9B2A7'],
  ['#8999A7', '#F3EACF', '#4F4C34', '#D0C9B2'],
  ['#F3FBFE', '#B7C6D3', '#59564D', '#ECD8B5'],
  ['#D3E5F0', '#AFC8DC', '#7193B0', '#B4CFE6'],
]

function drawAtmosphericPlaceholder(ctx: CanvasRenderingContext2D, w: number, h: number, index: number) {
  const palette = ATMOSPHERIC_PALETTES[index % ATMOSPHERIC_PALETTES.length]
  const grad = ctx.createLinearGradient(0, 0, w, h)
  grad.addColorStop(0, palette[0])
  grad.addColorStop(0.33, palette[1])
  grad.addColorStop(0.66, palette[2])
  grad.addColorStop(1, palette[3])
  ctx.fillStyle = grad
  ctx.fillRect(0, 0, w, h)

  const cx = w * 0.5
  const cy = h * 0.5
  const r = Math.hypot(w, h) * 0.55
  const radial = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
  radial.addColorStop(0, 'rgba(255, 255, 255, 0.15)')
  radial.addColorStop(0.6, 'rgba(0, 0, 0, 0.05)')
  radial.addColorStop(1, 'rgba(0, 0, 0, 0.35)')
  ctx.fillStyle = radial
  ctx.fillRect(0, 0, w, h)
}

export function DitherCarousel({
  items,
  brand,
  accent,
  cell = 7.5,
  focusBand = 0.25,
  twist = 0.8,
  rise = 0.79,
  cardHeight = CARD_H,
  cardRatio = 0.82,
  entry: playEntry = true,
  gooey = 0.4,
  ditherShape = 'dots',
  className,
  ...props
}: DitherCarouselProps) {
  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({ accent, cell, focusBand, twist, rise, cardHeight, cardRatio, gooey, ditherShape })
  settings.current = { accent, cell, focusBand, twist, rise, cardHeight, cardRatio, gooey, ditherShape }
  const step = React.useRef<(by: number) => void>(() => {})

  const count = items.length
  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
    const gl = canvas.getContext('webgl2', { alpha: false, antialias: false })
    if (!gl) {
      setSupported(false)
      return
    }

    const readColor = createColorReader()
    const cardProgram = buildProgram(gl, CARD_VERT, CARD_FRAG)
    const blurProgram = buildProgram(gl, QUAD_VERT, BLUR_FRAG)
    const compositeProgram = buildProgram(gl, QUAD_VERT, COMPOSITE_FRAG)
    if (!cardProgram || !blurProgram || !compositeProgram) return
    const cardU = getUniforms(gl, cardProgram)
    const blurU = getUniforms(gl, blurProgram)
    const compositeU = getUniforms(gl, compositeProgram)

    const COLS = 40
    const ROWS = 8
    const verts: number[] = []
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        const x0 = x / COLS
        const x1 = (x + 1) / COLS
        const y0 = y / ROWS
        const y1 = (y + 1) / ROWS
        verts.push(x0, y0, x1, y0, x0, y1, x0, y1, x1, y0, x1, y1)
      }
    }
    const cardMesh = gl.createVertexArray()
    gl.bindVertexArray(cardMesh)
    const cardBuffer = gl.createBuffer()
    gl.bindBuffer(gl.ARRAY_BUFFER, cardBuffer)
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW)
    gl.enableVertexAttribArray(0)
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
    const cardVertexCount = verts.length / 2

    const quad = gl.createVertexArray()
    gl.bindVertexArray(quad)
    const quadBuffer = gl.createBuffer()
    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer)
    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)

    const sceneFbo = gl.createFramebuffer()
    let sceneTex = createTexture(gl, 1, 1)
    let metaTex = createTexture(gl, 1, 1)
    const depth = gl.createRenderbuffer()
    const blurFbos = [0, 1, 2, 3].map(() => gl.createFramebuffer())
    let blurTex = [0, 1, 2, 3].map(() => createTexture(gl, 1, 1))

    const attach = (w: number, h: number) => {
      gl.bindFramebuffer(gl.FRAMEBUFFER, sceneFbo)
      gl.deleteTexture(sceneTex)
      gl.deleteTexture(metaTex)
      sceneTex = createTexture(gl, w, h)
      metaTex = createTexture(gl, w, h)
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, sceneTex, 0)
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, metaTex, 0)
      gl.bindRenderbuffer(gl.RENDERBUFFER, depth)
      gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h)
      gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth)
      gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1])

      blurTex.forEach((tex) => gl.deleteTexture(tex))
      blurTex = blurFbos.map((fbo, i) => {
        const tex = createTexture(gl, Math.max(1, w >> (i + 1)), Math.max(1, h >> (i + 1)))
        gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
        return tex
      })
      gl.bindFramebuffer(gl.FRAMEBUFFER, null)
    }

    const cards = items.map(() => ({ texture: null as WebGLTexture | null, aspect: 1.5 }))
    const images = items.map((item, i) => {
      const image = new Image()
      image.crossOrigin = 'anonymous'
      image.decoding = 'async'

      const bindTex = (source: TexImageSource, naturalAspect = 1.5) => {
        const tex = gl.createTexture()
        gl.bindTexture(gl.TEXTURE_2D, tex)
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source)
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_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)
        gl.generateMipmap(gl.TEXTURE_2D)
        cards[i].texture = tex
        cards[i].aspect = naturalAspect
      }

      image.onload = () => {
        bindTex(image, image.naturalWidth / Math.max(image.naturalHeight, 1))
      }
      image.onerror = () => {
        const placeholderCanvas = document.createElement('canvas')
        placeholderCanvas.width = 512
        placeholderCanvas.height = 768
        const pctx = placeholderCanvas.getContext('2d')
        if (pctx) {
          drawAtmosphericPlaceholder(pctx, 512, 768, i)
        }
        bindTex(placeholderCanvas, 512 / 768)
      }
      image.src = getOptimizedImageUrl(item.image, { width: 1200 })
      return image
    })

    let width = 0
    let height = 0
    let progress = 0
    let goal = 0
    let smoothed = 0
    let lastInput = 0
    let snapped = true
    let hovered = -1
    let pointerX = -1
    let pointerY = -1
    let pointerSpeed = 0
    let ticks = 0
    let frame = 0
    let entryStart = 0
    let background: [number, number, number] = [0, 0, 0]
    let accentRgb: [number, number, number] = [1, 1, 1]

    let tween: { from: number; to: number; at: number } | null = null

    const dim = new Float32Array(count)
    const hover = new Float32Array(count)
    const entryOf = new Float32Array(count).fill(playEntry && !reduced ? 1 : 0)
    const order = items.map((_, i) => i).sort(() => Math.random() - 0.5)

    const focal = 1 / Math.tan((FOV * Math.PI) / 360)
    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)
      attach(canvas.width, canvas.height)
    }
    resize()
    const observer = new ResizeObserver(resize)
    observer.observe(canvas)

    const project = (index: number) => {
      const { twist: turn, rise: pitch, cardHeight: cH, cardRatio: ratio } = settings.current
      const slot = ((((index - progress) % count) + count) % count) - FRONT
      const angle = slot * turn
      const y = slot * pitch * cH
      const z = Math.cos(angle) * (RADIUS + slot * RADIUS_STEP)
      const away = CAMERA_Z - z
      if (away <= NEAR) return null
      const aspect = width / Math.max(height, 1)
      const sx = ((Math.sin(angle) * (RADIUS + slot * RADIUS_STEP) * focal) / aspect / away) * 0.5 + 0.5
      const sy = 0.5 - ((y * focal) / away) * 0.5
      return {
        x: sx * width,
        y: sy * height,
        halfW: ((cH * ratio * focal) / aspect / away) * 0.5 * width * 0.5,
        halfH: ((cH * focal) / away) * 0.5 * height * 0.5,
        away,
      }
    }

    const pick = (px: number, py: number) => {
      let best = -1
      let bestAway = Infinity
      for (let i = 0; i < count; i++) {
        const at = project(i)
        if (!at) continue
        if (Math.abs(px - at.x) > at.halfW || Math.abs(py - at.y) > at.halfH) continue
        if (at.away < bestAway) {
          bestAway = at.away
          best = i
        }
      }
      return best
    }

    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 = (event: PointerEvent) => {
      const wasClick = dragFrom !== null && dragTravel < CLICK_SLOP
      dragFrom = null
      if (!wasClick) return
      const hit = pick(pointerX, pointerY)
      if (hit >= 0) {
        const want = (((hit - FRONT) % count) + count) % count
        const to = want + Math.round((goal - want) / count) * count
        tween = { from: goal, to, 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)

    const drawQuad = () => {
      gl.bindVertexArray(quad)
      gl.drawArrays(gl.TRIANGLES, 0, 6)
    }

    const draw = (now: number) => {
      frame = requestAnimationFrame(draw)
      if (!width || !height) return
      const {
        cell: cellPx,
        focusBand: band,
        twist: turn,
        rise: pitch,
        cardRatio: ratio,
        cardHeight: cH,
      } = settings.current
      const cardW = cH * ratio

      if (ticks++ % THEME_EVERY === 0) {
        background = readColor(getComputedStyle(canvas).backgroundColor)
        accentRgb = readColor(settings.current.accent ?? getComputedStyle(canvas).color)
      }

      const ready = cards.every((card) => card.texture)
      if (ready && !entryStart) entryStart = now
      const elapsed = entryStart ? now - entryStart : 0
      if (ready && playEntry && !reduced) {
        for (let rank = 0; rank < count; rank++) {
          const t = (elapsed - rank * ENTRY_STAGGER_MS) / ENTRY_MS
          entryOf[order[rank]] = 1 - clamp(t, 0, 1)
        }
        const spin = clamp(elapsed / ENTRY_SPIN_MS, 0, 1)
        progress = goal - ENTRY_SPIN * (1 - inOutCubic(spin))
      } else if (ready) {
        entryOf.fill(0)
      }
      const arriving = playEntry && !reduced && ready && elapsed < ENTRY_SPIN_MS

      if (tween) {
        const t = clamp((now - tween.at) / CLICK_MS, 0, 1)
        goal = tween.from + (tween.to - tween.from) * inOutCubic(t)
        if (t >= 1) tween = null
      } else if (!snapped && now - lastInput > SNAP_IDLE) {
        goal = Math.round(goal)
        snapped = true
      }

      const before = progress
      if (!arriving) {
        progress += (goal - progress) * (reduced ? 1 : snapped ? SNAP_EASE : EASE)
      }
      const velocity = progress - before
      smoothed += (clamp(velocity, -BEND_MAX, BEND_MAX) - smoothed) * BEND_EASE

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

      if (pointerX >= 0 && pointerSpeed < HOVER_SETTLE) hovered = pick(pointerX, pointerY)
      pointerSpeed *= 0.8
      for (let i = 0; i < count; i++) {
        const isHovered = i === hovered
        const wantHover = isHovered ? 1 : 0
        hover[i] += (wantHover - hover[i]) * (wantHover > hover[i] ? HOVER_IN : HOVER_OUT)
        let wantDim = 0
        if (hovered >= 0) {
          let gapTo = Math.abs(i - hovered)
          gapTo = Math.min(gapTo, count - gapTo)
          wantDim = clamp(gapTo / FOCUS_FALLOFF, 0, 1)
        }
        dim[i] += (wantDim - dim[i]) * (wantDim > dim[i] ? HOVER_IN : HOVER_OUT)
      }

      // --- pass one: the cards -------------------------------------------
      gl.bindFramebuffer(gl.FRAMEBUFFER, sceneFbo)
      gl.viewport(0, 0, canvas.width, canvas.height)
      gl.enable(gl.DEPTH_TEST)
      gl.clearBufferfv(gl.COLOR, 0, [background[0], background[1], background[2], 1])
      gl.clearBufferfv(gl.COLOR, 1, [0, 0, 0, 1])
      gl.clear(gl.DEPTH_BUFFER_BIT)
      gl.useProgram(cardProgram)
      gl.bindVertexArray(cardMesh)
      gl.uniform1f(cardU('uCount'), count)
      gl.uniform1f(cardU('uProgress'), progress)
      gl.uniform1f(cardU('uAngleStep'), turn)
      gl.uniform1f(cardU('uPitch'), pitch * cH)
      gl.uniform1f(cardU('uVelocity'), smoothed)
      gl.uniform2f(cardU('uCard'), cardW, cH)
      gl.uniform1f(cardU('uFocal'), focal)
      gl.uniform1f(cardU('uAspect'), width / height)
      gl.uniform3fv(cardU('uBackground'), background)
      gl.uniform1i(cardU('uMap'), 0)
      gl.uniform1f(cardU('uEntryScale'), 9.5)
      gl.uniform1f(cardU('uEntryAspect'), ratio)
      gl.activeTexture(gl.TEXTURE0)

      for (let i = 0; i < count; i++) {
        const card = cards[i]
        if (!card.texture) continue
        gl.bindTexture(gl.TEXTURE_2D, card.texture)
        gl.uniform1f(cardU('uIndex'), i)
        gl.uniform1f(cardU('uHover'), hover[i])
        gl.uniform1f(cardU('uDim'), dim[i])
        gl.uniform1f(cardU('uEntry'), entryOf[i])
        gl.uniform2f(
          cardU('uImageRatio'),
          card.aspect < ratio ? 1 : ratio / card.aspect,
          card.aspect < ratio ? card.aspect / ratio : 1,
        )
        gl.drawArrays(gl.TRIANGLES, 0, cardVertexCount)
      }
      gl.disable(gl.DEPTH_TEST)

      // --- pass two: the blur chain ---------------------------------------
      gl.useProgram(blurProgram)
      gl.uniform1i(blurU('uMap'), 0)
      gl.uniform1f(blurU('uSpread'), 4.5)
      let source = sceneTex
      for (let i = 0; i < 4; i++) {
        const w = Math.max(1, canvas.width >> (i + 1))
        const h = Math.max(1, canvas.height >> (i + 1))
        gl.bindFramebuffer(gl.FRAMEBUFFER, blurFbos[i])
        gl.viewport(0, 0, w, h)
        gl.activeTexture(gl.TEXTURE0)
        gl.bindTexture(gl.TEXTURE_2D, source)
        gl.uniform2f(blurU('uTexel'), 1 / w, 1 / h)
        drawQuad()
        source = blurTex[i]
      }

      // --- pass three: the composite --------------------------------------
      gl.bindFramebuffer(gl.FRAMEBUFFER, null)
      gl.viewport(0, 0, canvas.width, canvas.height)
      gl.useProgram(compositeProgram)
      const bound = [sceneTex, metaTex, ...blurTex]
      const names = ['uScene', 'uMeta', 'uBlur1', 'uBlur2', 'uBlur3', 'uBlur4']
      bound.forEach((tex, unit) => {
        gl.activeTexture(gl.TEXTURE0 + unit)
        gl.bindTexture(gl.TEXTURE_2D, tex)
        gl.uniform1i(compositeU(names[unit]), unit)
      })
      gl.uniform2f(compositeU('uResolution'), canvas.width, canvas.height)
      gl.uniform3fv(compositeU('uBackground'), background)
      gl.uniform3fv(compositeU('uAccent'), accentRgb)
      gl.uniform1f(compositeU('uFocusSize'), band)
      gl.uniform1f(compositeU('uDitherScale'), cellPx)
      gl.uniform1f(compositeU('uEntryScale'), 9.5)
      const currentGooey = settings.current.gooey
      const gooeyVal = typeof currentGooey === 'number' ? currentGooey : currentGooey ? 0.4 : 0.0
      const currentShape = settings.current.ditherShape ?? 'dots'
      const patternCode =
        currentShape === 'bayer'
          ? 0
          : currentShape === 'diamond'
            ? 2
            : currentShape === 'cross'
              ? 3
              : currentShape === 'fluid'
                ? 4
                : 1
      gl.uniform1f(compositeU('uGooey'), gooeyVal)
      gl.uniform1f(compositeU('uTime'), now * 0.001)
      gl.uniform1i(compositeU('uDitherPattern'), patternCode)
      drawQuad()
      gl.activeTexture(gl.TEXTURE0)
    }
    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
      for (const card of cards) if (card.texture) gl.deleteTexture(card.texture)
      gl.deleteTexture(sceneTex)
      gl.deleteTexture(metaTex)
      blurTex.forEach((tex) => gl.deleteTexture(tex))
      blurFbos.forEach((fbo) => gl.deleteFramebuffer(fbo))
      gl.deleteFramebuffer(sceneFbo)
      gl.deleteRenderbuffer(depth)
      gl.deleteBuffer(cardBuffer)
      gl.deleteBuffer(quadBuffer)
      gl.deleteVertexArray(cardMesh)
      gl.deleteVertexArray(quad)
      gl.deleteProgram(cardProgram)
      gl.deleteProgram(blurProgram)
      gl.deleteProgram(compositeProgram)
    }
  }, [sources, count, reduced, playEntry])

  if (!supported) {
    return (
      <section
        aria-roledescription="carousel"
        aria-label={brand ?? 'Gallery'}
        className={cn('bg-background text-foreground relative h-full min-h-full w-full', className)}
        {...props}
      >
        <ul className="flex h-full snap-y snap-mandatory flex-col items-center gap-4 overflow-y-auto py-[6%]">
          {items.map((entry) => (
            <li key={entry.image} className="relative w-[62%] shrink-0 snap-center" style={{ aspectRatio: cardRatio }}>
              <NextImage
                src={entry.image}
                alt={entry.title}
                fill
                sizes="(max-width: 768px) 80vw, 50vw"
                className="bg-muted rounded-xl object-cover"
              />
            </li>
          ))}
        </ul>
      </section>
    )
  }

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

      <ul className="sr-only">
        {items.map((item, i) => (
          <li key={item.image} id={`dither-carousel-${i}`} role="option" aria-selected={i === active}>
            <NextImage src={item.image} alt={item.title} width={512} height={768} />
            <h3>{item.title}</h3>
          </li>
        ))}
      </ul>

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

      <div className="pointer-events-none absolute bottom-[7%] left-[5%] text-2xl leading-none font-medium tracking-tight">
        {items[active]?.title}
      </div>

      <div className="text-muted-foreground pointer-events-none absolute right-[5%] bottom-[7%] text-sm tabular-nums">
        {String(active + 1).padStart(2, '0')}
        <span className="opacity-50"> / {String(count).padStart(2, '0')}</span>
      </div>
    </section>
  )
}
src/registry/blocks/dither-carousel/shaders.ts
export const CARD_H = 2.2
export const RADIUS = 3.8
export const RADIUS_STEP = 0.055
export const CAMERA_Z = 10
export const FOV = 48
export const NEAR = 0.1
export const FAR = 60
export const BEND = 2.7

export const BAYER = /* glsl */ `
float bayer2(vec2 a) { a = floor(a); return fract(a.x / 2.0 + a.y * a.y * 0.75); }
#define bayer4(a) (bayer2(0.5 * (a)) * 0.25 + bayer2(a))
#define bayer8(a) (bayer4(0.5 * (a)) * 0.25 + bayer2(a))

float sampleDitherPattern(vec2 p, int mode, float time) {
  if (mode == 1) { // dots / halftone screen print
    vec2 f = fract(p) - 0.5;
    return clamp(1.0 - length(f) * 1.414, 0.0, 1.0);
  } else if (mode == 2) { // diamond matrix
    vec2 f = abs(fract(p) - 0.5);
    return clamp(1.0 - (f.x + f.y), 0.0, 1.0);
  } else if (mode == 3) { // crosshatch etching
    vec2 f = abs(fract(p) - 0.5);
    return clamp(1.0 - min(f.x, f.y) * 2.6, 0.0, 1.0);
  } else if (mode == 4) { // organic fluid cells
    vec2 i = floor(p);
    vec2 f = fract(p);
    float n = sin(i.x * 12.9898 + i.y * 78.233 + time * 1.2) * 43758.5453;
    float dist = length(f - 0.5);
    return clamp(fract(n) * 0.35 + (1.0 - dist * 1.35) * 0.65, 0.0, 1.0);
  }
  return bayer8(p);
}
`

export const CARD_VERT = /* glsl */ `#version 300 es
in vec2 aPos;

uniform float uIndex;
uniform float uProgress;
uniform float uCount;
uniform float uAngleStep;
uniform float uPitch;
uniform float uVelocity;
uniform vec2  uCard;
uniform float uFocal;
uniform float uAspect;

out vec2 vUv;
out float vDepth;

const float RADIUS = ${RADIUS.toFixed(3)};
const float RADIUS_STEP = ${RADIUS_STEP.toFixed(4)};
const float CAMERA_Z = ${CAMERA_Z.toFixed(1)};
const float NEAR = ${NEAR.toFixed(2)};
const float FAR = ${FAR.toFixed(1)};
const float BEND = ${BEND.toFixed(2)};

void main() {
  vUv = vec2(aPos.x, 1.0 - aPos.y);
  vec2 local = (aPos - 0.5) * uCard;

  float slot = mod(uIndex - uProgress, uCount) - floor(uCount * 0.5);
  float baseAngle = slot * uAngleStep;
  float baseY = slot * uPitch;

  float ty = (aPos.y - 0.5) * 2.0;
  baseAngle += uVelocity * BEND * ty * ty * uAngleStep * 0.5;

  float r = RADIUS + slot * RADIUS_STEP;
  float theta = baseAngle + local.x / r;
  vec3 p = vec3(sin(theta) * r, local.y + baseY, cos(theta) * r);

  float vz = p.z - CAMERA_Z;
  vDepth = -vz;
  gl_Position = vec4(
    p.x * uFocal / uAspect,
    p.y * uFocal,
    ((FAR + NEAR) * vz + 2.0 * FAR * NEAR) / (NEAR - FAR),
    -vz
  );
}`

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

in vec2 vUv;
in float vDepth;

uniform sampler2D uMap;
uniform vec2 uImageRatio;
uniform vec3 uBackground;
uniform float uHover;
uniform float uDim;
uniform float uEntry;
uniform float uEntryScale;
uniform float uEntryAspect;

layout(location = 0) out vec4 outScene;
layout(location = 1) out vec4 outMeta;

const float FOG_NEAR = 8.0;
const float FOG_FAR = 20.6;
const float DIM_FADE = 0.67;
const float ENTRY_SOFTNESS = 0.45;

${BAYER}

bool notArrived(vec2 uv) {
  if (uEntry <= 0.0) return false;
  vec2 offset = (uv - 0.5) * vec2(uEntryAspect, 1.0);
  float d = length(offset) / length(vec2(uEntryAspect, 1.0) * 0.5);
  float front = (1.0 - uEntry) * (1.0 + ENTRY_SOFTNESS);
  return (front - d) / ENTRY_SOFTNESS <= bayer8(gl_FragCoord.xy / uEntryScale);
}

void main() {
  if (notArrived(vUv)) discard;

  vec2 uv = (vUv - 0.5) * uImageRatio + 0.5;
  vec3 color = texture(uMap, uv).rgb;

  float fog = smoothstep(FOG_NEAR, FOG_FAR, vDepth) * (1.0 - uHover);
  color = mix(color, uBackground, fog);

  color = mix(color, uBackground, 1.0 - pow(1.0 - uDim * DIM_FADE, 2.2));

  outScene = vec4(color, 1.0 - fog);
  outMeta = vec4(0.0, uDim, uHover, 1.0 - uEntry);
}`

export 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);
}`

export const BLUR_FRAG = /* glsl */ `#version 300 es
precision highp float;
in vec2 vUv;
uniform sampler2D uMap;
uniform vec2 uTexel;
uniform float uSpread;
out vec4 fragColor;
void main() {
  vec2 stride = vec2(0.0, uTexel.y) * uSpread;
  vec4 sum = vec4(0.0);
  float total = 0.0;
  for (int i = -8; i <= 8; i++) {
    float fi = float(i);
    float w = exp(-fi * fi / 18.0);
    sum += texture(uMap, vUv + stride * fi) * w;
    total += w;
  }
  fragColor = sum / total;
}`

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

in vec2 vUv;

uniform sampler2D uScene;
uniform sampler2D uMeta;
uniform sampler2D uBlur1;
uniform sampler2D uBlur2;
uniform sampler2D uBlur3;
uniform sampler2D uBlur4;

uniform vec2  uResolution;
uniform vec3  uBackground;
uniform vec3  uAccent;
uniform float uFocusSize;
uniform float uDitherScale;
uniform float uEntryScale;
uniform float uGooey;
uniform float uTime;
uniform int   uDitherPattern;

out vec4 fragColor;

const float EDGE_POWER = 1.65;
const float BLUR_STRENGTH = 0.47;
const float FADE_STRENGTH = 0.4;
const float DITHER_AMOUNT = 0.77;
const float DITHER_START = 0.64;
const float DITHER_POWER = 1.25;
const float LEVELS = 8.0;
const float GAMMA = 1.8;
const float MONO = 0.22;

const float STAGING = 0.55;
const float SMEAR_END = 0.55;
const float GRAIN_BEGIN = 0.45;
const float YIELD = 0.75;

const float HOVER_BLUR = 0.13;
const float HOVER_DITHER = 0.3;
const float HOVER_CURVE = 1.9;
const float HOVER_LEVELS = 8.0;
const float HOVER_CUTOFF = 0.22;
const float HOVER_GAMMA = 1.8;
const float ENTRY_DITHER = 0.45;
const float ENTRY_LEVELS = 4.0;
const float ENTRY_GAMMA = 1.5;

${BAYER}

float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }

vec3 blurStack(vec2 uv, float lvl) {
  vec3 c = texture(uScene, uv).rgb;
  c = mix(c, texture(uBlur1, uv).rgb, clamp(lvl - 0.0, 0.0, 1.0));
  c = mix(c, texture(uBlur2, uv).rgb, clamp(lvl - 1.0, 0.0, 1.0));
  c = mix(c, texture(uBlur3, uv).rgb, clamp(lvl - 2.0, 0.0, 1.0));
  c = mix(c, texture(uBlur4, uv).rgb, clamp(lvl - 3.0, 0.0, 1.0));
  return c;
}

vec3 towardPage(vec3 c, float fade) { return mix(c, uBackground, fade); }

vec2 latticeUv(float cell) {
  return (floor(gl_FragCoord.xy / cell) + 0.5) * cell / uResolution;
}

vec3 toneScale(float t) {
  return t < 0.5
    ? mix(uBackground, uAccent, t * 2.0)
    : mix(uAccent, uBackground, (t - 0.5) * 2.0);
}

vec3 quantise(vec3 c, float threshold, float levels, float gamma) {
  float steps = max(levels - 1.0, 1.0);
  vec3 toned = toneScale(floor(pow(clamp(luma(c), 0.0, 1.0), gamma) * steps + threshold) / steps);
  vec3 quantized = floor(c * steps + threshold) / steps;
  return mix(quantized, toned, MONO);
}

void main() {
  vec2 uvCoord = vUv;

  float d = abs(uvCoord.y - 0.5) * 2.0;
  float edge = pow(smoothstep(uFocusSize, 1.0, d), EDGE_POWER);

  vec4 scene = texture(uScene, uvCoord);
  vec4 meta = texture(uMeta, uvCoord);
  float dim = meta.g;
  float entry = 1.0 - meta.a;

  float keep = 1.0 - meta.b;

  // Sharp silhouette from scene
  float sharpAlpha = scene.a;
  float activeAlpha = sharpAlpha;

  // True metaball gooey fusion across adjacent cards
  if (uGooey > 0.001) {
    float blurField = mix(texture(uBlur1, uvCoord).a, texture(uBlur2, uvCoord).a, 0.6);
    float threshold = mix(0.50, 0.32, uGooey);
    float span = 0.045;
    float gooeyField = smoothstep(threshold - span, threshold + span, blurField);
    activeAlpha = mix(sharpAlpha, max(sharpAlpha, gooeyField), uGooey);
  }

  float distance = 1.0 - activeAlpha;
  float dissolve = max(edge, distance);

  float grainFree = pow(smoothstep(DITHER_START, 1.0, max(d, distance)), DITHER_POWER);
  float grainStaged = pow(smoothstep(GRAIN_BEGIN, 1.0, dissolve), DITHER_POWER);
  float smearStaged = smoothstep(0.0, SMEAR_END, dissolve) * (1.0 - grainStaged * YIELD);

  float blurDrive = mix(dissolve, smearStaged, STAGING);
  float ditherDrive = mix(grainFree, grainStaged, STAGING);

  float softness = max(blurDrive, dim * HOVER_BLUR) * keep;
  float lvl = softness * BLUR_STRENGTH * 4.0;
  float fade = edge * FADE_STRENGTH * keep;

  // Sample scene or smooth blurStack inside the gooey bridge
  vec3 baseSceneColor = scene.rgb;
  if (uGooey > 0.001 && sharpAlpha < 0.5 && activeAlpha > 0.01) {
    baseSceneColor = blurStack(uvCoord, 1.6);
  }

  vec3 c = towardPage(mix(baseSceneColor, blurStack(uvCoord, lvl), min(lvl, 1.0)), fade);

  float threshold = sampleDitherPattern(gl_FragCoord.xy / uDitherScale, uDitherPattern, uTime);
  vec3 source = uDitherScale > 1.0
    ? towardPage(blurStack(latticeUv(uDitherScale), lvl), fade)
    : c;
  vec3 result = mix(c, quantise(source, threshold, LEVELS, GAMMA), DITHER_AMOUNT * ditherDrive * keep);

  float hoverRamp = smoothstep(HOVER_CUTOFF, 1.0, pow(dim, HOVER_CURVE)) * HOVER_DITHER;
  float hoverThreshold = sampleDitherPattern(gl_FragCoord.xy / (uDitherScale * 1.35), uDitherPattern, uTime);
  vec3 hoverSource = towardPage(blurStack(latticeUv(uDitherScale * 1.35), lvl), fade);
  result = mix(result, quantise(hoverSource, hoverThreshold, HOVER_LEVELS, HOVER_GAMMA), hoverRamp);

  float entryThreshold = sampleDitherPattern(gl_FragCoord.xy / uEntryScale, uDitherPattern, uTime);
  vec3 entrySource = towardPage(blurStack(latticeUv(uEntryScale), lvl), fade);
  result = mix(
    result,
    quantise(entrySource, entryThreshold, ENTRY_LEVELS, ENTRY_GAMMA),
    entry * ENTRY_DITHER
  );

  fragColor = vec4(clamp(result, 0.0, 1.0), 1.0);
}`
src/registry/blocks/dither-carousel/types.ts
import type * as React from 'react'

export interface DitherCarouselItem {
  /** Cover art. Cross-origin sources must send CORS headers. */
  image: string
  /** Shown while this card is at the front. */
  title: string
}

export interface DitherCarouselProps extends Omit<React.ComponentPropsWithoutRef<'section'>, 'children'> {
  items: DitherCarouselItem[]
  /** Wordmark in the top-left. Omit to drop it. @default undefined */
  brand?: string
  /** The lit tone the grain resolves to. Defaults to the theme's foreground. @default undefined */
  accent?: string
  /** Grain lattice pitch, measured in device pixels. Larger prints coarser. @default 7.5 */
  cell?: number
  /** How much of the frame's height stays sharp before the dissolve starts, 0-1. @default 0.25 */
  focusBand?: number
  /** Turn of the helix between one card and the next, in radians. @default 0.8 */
  twist?: number
  /** Rise of the helix between one card and the next, in card heights. @default 0.79 */
  rise?: number
  /** Card height in world units. @default 2.2 */
  cardHeight?: number
  /** Card width / height. Art is cover-fitted into it, so match your own. @default 0.82 */
  cardRatio?: number
  /** Gooey fluid melting intensity, 0-1. @default 0.35 */
  gooey?: boolean | number
  /** Dither pattern geometry shape. @default 'dots' */
  ditherShape?: 'bayer' | 'dots' | 'diamond' | 'cross' | 'fluid'
  /** Play the arrival - cards materialize out of the grain. @default true */
  entry?: boolean
  /** Extra classes on the root surface. @default undefined */
  className?: string
}

More from spaceui

Browse all components in this library →