Mikkel Manniche
Dansk English Deutsch

Widgets

Curve carousel

Cards on a curve in ten layouts, from a fan and a coverflow to a helix, a rolodex and a row that bulges when flung; drag momentum, keys and trackpad.

Preview

Usage

curve-carousel-demo.tsx
import { useState, type ReactNode } from 'react'
import { CurveCarousel } from '@/components/curve-carousel'
import { curveLayouts, type CurveLayoutName } from '@/components/layouts'
import { cn } from '@/lib/utils'

// A small seeded random generator, so the drawings are the same on every render and on the server.
function rng(seed: number) {
  return () => {
    seed |= 0
    seed = (seed + 0x6d2b79f5) | 0
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

const f = (v: number) => v.toFixed(1)
const line = (pts: [number, number][]) => pts.map(([x, y], i) => `${i ? 'L' : 'M'}${f(x)} ${f(y)}`).join('')

// The drawing's pens, from the theme tokens.
const fg = 'fill-none stroke-foreground [stroke-width:1.25]'
const mid = 'fill-none stroke-muted-foreground [stroke-width:1]'
const sig = 'fill-none stroke-primary [stroke-width:2] [stroke-linecap:round] [stroke-linejoin:round]'

function Plate({ no, title, children }: { no: number; title: string; children: ReactNode }) {
  return (
    <figure className="relative m-0 size-full bg-muted">
      {/* A little wider than the card and moved by --curve-shift, so the shingle layout can pan it. */}
      <svg viewBox="0 0 300 300" preserveAspectRatio="xMidYMid slice" aria-hidden className="absolute inset-y-0 -inset-x-[14%] h-full w-[128%] [translate:var(--curve-shift,0px)_0]">
        {children}
      </svg>
      <figcaption className="absolute inset-x-0 bottom-0 flex items-baseline justify-between gap-2 bg-card px-3.5 py-3 shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_9%,transparent)]">
        <span className="truncate text-[13px] leading-none font-semibold tracking-[-0.01em]">{title}</span>
        <span className="font-mono text-[10.5px] leading-none font-medium text-muted-foreground tabular-nums">{String(no).padStart(2, '0')}</span>
      </figcaption>
    </figure>
  )
}

function Contour() {
  const r = rng(3)
  const rings = Array.from({ length: 9 }, (_, k) => {
    const pts: [number, number][] = []
    for (let a = 0; a <= 64; a++) {
      const t = (a / 64) * Math.PI * 2
      const w = 1 + 0.18 * Math.sin(t * 3 + k * 0.4) + 0.08 * Math.sin(t * 5 + r())
      pts.push([150 + Math.cos(t) * (16 + k * 15) * w, 128 + Math.sin(t) * (12 + k * 12) * w])
    }
    return line(pts) + 'Z'
  })
  return rings.map((d, k) => <path key={k} d={d} className={k === 4 ? sig : k % 2 ? mid : fg} />)
}

function Wave() {
  return Array.from({ length: 7 }, (_, k) => {
    const pts: [number, number][] = []
    for (let x = 0; x <= 300; x += 4) pts.push([x, 60 + k * 26 + Math.sin(x / 34 + k * 0.7) * (8 + k * 2.5)])
    return <path key={k} d={line(pts)} className={k === 3 ? sig : k % 2 ? mid : fg} />
  })
}

function Orbit() {
  return (
    <>
      {[40, 72, 104, 136].map((rx, k) => (
        <ellipse key={rx} cx="150" cy="128" rx={rx} ry={rx * 0.42} transform={`rotate(-18 150 128)`} className={k % 2 ? mid : fg} />
      ))}
      <circle cx="150" cy="128" r="9" className={fg} />
      <circle cx={150 + 104 * Math.cos(0.6)} cy={128 - 104 * 0.42 * Math.sin(0.6) - 26} r="5" className="fill-primary" />
    </>
  )
}

function Lattice() {
  let d = ''
  for (let i = 0; i <= 12; i++) {
    const v: [number, number][] = []
    const h: [number, number][] = []
    for (let j = 0; j <= 24; j++) {
      const u = j / 24
      const bend = (x: number, y: number): [number, number] => {
        const dx = x - 150
        const dy = y - 128
        const k = 1 + 0.5 * Math.exp(-(dx * dx + dy * dy) / 9000)
        return [150 + dx * k, 128 + dy * k]
      }
      v.push(bend(i * 25, u * 300))
      h.push(bend(u * 300, i * 25))
    }
    d += line(v) + line(h)
  }
  return (
    <>
      <path d={d} className={mid} />
      <circle cx="150" cy="128" r="22" className={sig} />
    </>
  )
}

function Ridges() {
  const r = rng(11)
  const seeds = Array.from({ length: 6 }, () => r() * 300)
  return Array.from({ length: 11 }, (_, k) => {
    const pts: [number, number][] = []
    for (let x = 0; x <= 300; x += 5) {
      const peak = seeds.reduce((s, c, i) => s + Math.exp(-((x - c) ** 2) / (300 + i * 140)) * (10 + ((k * 7 + i * 5) % 13)), 0)
      pts.push([x, 40 + k * 20 - peak])
    }
    return <path key={k} d={line(pts)} className={k === 6 ? sig : fg} />
  })
}

function Rays() {
  return (
    <>
      {Array.from({ length: 36 }, (_, k) => {
        const a = (k / 36) * Math.PI * 2
        const r1 = k % 3 ? 30 : 22
        return <line key={k} x1={150 + Math.cos(a) * r1} y1={128 + Math.sin(a) * r1} x2={150 + Math.cos(a) * 190} y2={128 + Math.sin(a) * 190} className={k === 7 ? sig : mid} />
      })}
      <circle cx="150" cy="128" r="16" className={fg} />
    </>
  )
}

function Spiral() {
  const pts: [number, number][] = []
  for (let t = 0; t < 34; t += 0.08) pts.push([150 + Math.cos(t) * t * 4.2, 128 + Math.sin(t) * t * 4.2])
  const tail = pts.slice(-60)
  return (
    <>
      <path d={line(pts)} className={fg} />
      <path d={line(tail)} className={sig} />
    </>
  )
}

function Halftone() {
  const dots: ReactNode[] = []
  for (let y = 12; y < 300; y += 14)
    for (let x = 12 + ((y / 14) % 2) * 7; x < 300; x += 14) {
      const v = Math.exp(-((x - 190) ** 2 + (y - 100) ** 2) / 12000)
      if (v > 0.05) dots.push(<circle key={`${x}-${y}`} cx={x} cy={y} r={f(0.6 + v * 5.4)} className={v > 0.85 ? 'fill-primary' : 'fill-foreground'} />)
    }
  return dots
}

function Knot() {
  const pts: [number, number][] = []
  for (let t = 0; t <= Math.PI * 2 + 0.01; t += 0.02) pts.push([150 + Math.sin(3 * t + 0.5) * 108, 128 + Math.sin(4 * t) * 92])
  return (
    <>
      <path d={line(pts)} className={fg} />
      <circle cx={150 + Math.sin(0.5) * 108} cy="128" r="5" className="fill-primary" />
    </>
  )
}

function Moire() {
  return (
    <>
      {Array.from({ length: 14 }, (_, k) => (
        <circle key={`a${k}`} cx="124" cy="128" r={10 + k * 11} className={mid} />
      ))}
      {Array.from({ length: 14 }, (_, k) => (
        <circle key={`b${k}`} cx="182" cy="118" r={10 + k * 11} className={k === 5 ? sig : fg} />
      ))}
    </>
  )
}

function Flow() {
  const r = rng(29)
  return Array.from({ length: 26 }, (_, k) => {
    let x = r() * 300
    let y = r() * 260
    const pts: [number, number][] = [[x, y]]
    for (let s = 0; s < 34; s++) {
      const a = Math.sin(x / 60) + Math.cos(y / 48) * 1.3
      x += Math.cos(a) * 6
      y += Math.sin(a) * 6
      pts.push([x, y])
    }
    return <path key={k} d={line(pts)} className={k === 9 ? sig : k % 3 ? mid : fg} />
  })
}

function Steps() {
  return (
    <>
      {Array.from({ length: 9 }, (_, k) => (
        <rect key={k} x={36 + k * 26} y={220 - k * 20} width="26" height={k * 20 + 20} className={k === 6 ? sig : fg} />
      ))}
      <path d="M20 240H280" className={mid} />
    </>
  )
}

const PLATES: [string, () => ReactNode][] = [
  ['Contour', Contour],
  ['Wave', Wave],
  ['Orbit', Orbit],
  ['Lattice', Lattice],
  ['Ridges', Ridges],
  ['Rays', Rays],
  ['Spiral', Spiral],
  ['Halftone', Halftone],
  ['Knot', Knot],
  ['Moiré', Moire],
  ['Flow', Flow],
  ['Steps', Steps],
]

const slides = PLATES.map(([title, Art], i) => (
  <Plate key={title} no={i + 1} title={title}>
    <Art />
  </Plate>
))

const NAMES = Object.keys(curveLayouts) as CurveLayoutName[]

export default function CurveCarouselDemo() {
  const [layout, setLayout] = useState<CurveLayoutName>('coverflow')
  return (
    <div className="flex w-full max-w-2xl flex-col items-center gap-5">
      <div role="group" aria-label="Layout" className="flex max-w-full flex-wrap justify-center gap-1 rounded-[calc(var(--radius)*2+2px)] bg-muted p-1">
        {NAMES.map((name) => (
          <button
            key={name}
            type="button"
            aria-pressed={name === layout}
            onClick={() => setLayout(name)}
            className={cn(
              'h-11 cursor-pointer rounded-full px-3.5 text-[13px] font-medium text-muted-foreground [-webkit-tap-highlight-color:transparent] hover:text-foreground',
              'aria-pressed:bg-foreground aria-pressed:text-card',
              'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
            )}
          >
            {name}
          </button>
        ))}
      </div>
      {/* A new key per layout, so each one starts from its first card. */}
      <CurveCarousel key={layout} layout={layout} slides={slides} label="Plates" defaultIndex={curveLayouts[layout].loop ? 0 : 5} />
    </div>
  )
}

Source

curve-carousel.tsx
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'
import { useCarouselEngine } from '@/registry/manniche/hooks/use-carousel-engine'
import { cn } from '@/lib/utils'
import { curveLayouts, type CurveContext, type CurveLayout, type CurveLayoutName, type CurveSize } from './layouts'

export type CurveCarouselProps = Omit<HTMLAttributes<HTMLElement>, 'onChange'> & {
  /** One card per slide. An img or an SVG fills its card edge to edge. In the shingle layout each card sets
   *  `--curve-shift` (px); content that translates by it pans inside its card, e.g. `[translate:var(--curve-shift,0px)_0]`
   *  on an img a little wider than the card. */
  slides: ReactNode[]
  /** Read by screen readers, e.g. “Recent work”. */
  label: string
  /** The curve the cards sit on: one of the built-in layouts, or your own. */
  layout?: CurveLayoutName | CurveLayout
  /** Wrap round from the last card to the first. Ring layouts wrap by default. */
  loop?: boolean
  /** Controlled: the card in focus. */
  index?: number
  /** The card in focus at first, when uncontrolled. */
  defaultIndex?: number
  /** Called when the carousel heads for a new card: on release, a key, a button or a click. */
  onIndexChange?: (index: number) => void
  /** Card width over height. Each layout has its own default. */
  aspect?: number
  /** Show the previous and next buttons and the position rule. */
  controls?: boolean
  labels?: Partial<Record<'previous' | 'next' | 'slide' | 'of' | 'slides', string>>
}

const EN = { previous: 'Previous', next: 'Next', slide: 'Slide', of: 'of', slides: 'Slides' }
const TYPING = 'input, textarea, select, [contenteditable=""], [contenteditable="true"]'

/**
 * Cards on a curve: a fan, an arc, a coverflow, a drum, a wall that wraps round you, a helix, a double helix,
 * a rolodex, overlapping tiles, or a row that bulges towards you when it moves fast. Drag or swipe with momentum, use a trackpad, the arrow keys, the buttons, or
 * click a card to bring it to the front. Every frame is written as transform, opacity and filter only, and
 * cards are painted in depth order. Under reduced motion each move jumps straight to the new card.
 */
export function CurveCarousel({
  slides,
  label,
  layout = 'coverflow',
  loop,
  index,
  defaultIndex,
  onIndexChange,
  aspect,
  controls = true,
  labels = {},
  className,
  onKeyDown,
  ...rest
}: CurveCarouselProps) {
  const t = { ...EN, ...labels }
  const L = typeof layout === 'string' ? curveLayouts[layout] : layout
  const n = slides.length
  const wraps = (loop ?? L.loop) && n > 2
  const id = useId()

  const stage = useRef<HTMLDivElement | null>(null)
  const cards = useRef<(HTMLDivElement | null)[]>([])
  const ticks = useRef<(HTMLSpanElement | null)[]>([])
  const [width, setWidth] = useState(0)
  const ratio = aspect ?? L.aspect
  const size: CurveSize | null = width ? L.size(width, ratio, n) : null
  const ctx = useRef<CurveContext | null>(null)

  const paint = useCallback(
    (p: number, velocity: number) => {
      const c = ctx.current
      if (!c) return
      c.velocity = velocity
      const range = L.range(c)
      for (let i = 0; i < n; i++) {
        const el = cards.current[i]
        if (!el) continue
        let d = i - p
        if (wraps) d = ((((d + n / 2) % n) + n) % n) - n / 2
        const pose = Math.abs(d) <= range ? L.pose(d, c) : null
        // Near the seam of a short loop a card is about to jump to the other end, so it fades out first.
        const seam = wraps ? Math.min(1, Math.max(0, (n / 2 - Math.abs(d)) / 0.75)) : 1
        const opacity = pose ? (pose.opacity ?? 1) * seam : 0
        if (!pose || opacity < 0.004) {
          el.style.visibility = 'hidden'
          continue
        }
        const { x = 0, y = 0, z = 0, rotateX = 0, rotateY = 0, rotateZ = 0, scale = 1, blur = 0, fog = 0, shift = 0 } = pose
        el.style.visibility = 'visible'
        el.style.transform =
          `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, ${z.toFixed(2)}px)` +
          (rotateX ? ` rotateX(${rotateX.toFixed(3)}deg)` : '') +
          (rotateY ? ` rotateY(${rotateY.toFixed(3)}deg)` : '') +
          (rotateZ ? ` rotateZ(${rotateZ.toFixed(3)}deg)` : '') +
          (scale !== 1 ? ` scale(${scale.toFixed(4)})` : '')
        el.style.opacity = opacity.toFixed(3)
        el.style.filter = blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : ''
        el.style.zIndex = String(Math.round(pose.order ?? z) + 10000)
        const fogEl = el.lastElementChild as HTMLElement
        fogEl.style.opacity = fog > 0.002 ? Math.min(fog, 1).toFixed(3) : '0'
        if (L.shift) el.style.setProperty('--curve-shift', `${(shift * L.shift * c.cardWidth).toFixed(2)}px`)
      }
      // The rule under the stage follows the position, also mid-drag.
      const at = wraps ? (((p % n) + n) % n) / n : (Math.min(Math.max(p, 0), n - 1) / Math.max(n - 1, 1)) * (1 - 1 / n)
      ticks.current.forEach((el, k) => {
        if (el) el.style.transform = `translateX(${((at - k) * 100).toFixed(3)}cqw)`
      })
    },
    [L, n, wraps],
  )

  const engine = useCarouselEngine({
    count: n,
    loop: wraps,
    index,
    defaultIndex,
    onIndexChange,
    axis: L.axis,
    step: size ? L.step({ ...size, width, count: n, velocity: 0 }) : 300,
    onFrame: paint,
  })
  const current = engine.index

  // The stage only exists while there are cards, so watch it again when the first ones arrive.
  const empty = !n
  useLayoutEffect(() => {
    const el = stage.current
    if (!el) return
    const ro = new ResizeObserver(([e]) => setWidth(Math.round(e.contentRect.width)))
    ro.observe(el)
    return () => ro.disconnect()
  }, [empty])

  // A new size, layout or set of cards: draw the current frame again.
  const { draw } = engine
  useLayoutEffect(() => {
    ctx.current = width ? { ...L.size(width, ratio, n), width, count: n, velocity: 0 } : null
    draw()
  }, [draw, paint, L, width, ratio, n])

  // If focus sat in a card that just left the front, keep it in the carousel.
  useEffect(() => {
    const el = stage.current
    const active = document.activeElement
    if (!el || !active || active === el || !el.contains(active)) return
    if (!cards.current[current]?.contains(active)) el.focus({ preventScroll: true })
  }, [current])

  const keys = (e: KeyboardEvent<HTMLElement>) => {
    onKeyDown?.(e)
    if (e.defaultPrevented || (e.target as HTMLElement).closest(TYPING)) return
    const back = L.axis === 'x' ? 'ArrowLeft' : 'ArrowUp'
    const fwd = L.axis === 'x' ? 'ArrowRight' : 'ArrowDown'
    if (e.key === back || e.key === fwd) engine.move(e.key === fwd ? 1 : -1)
    else if (e.key === 'Home') engine.go(0)
    else if (e.key === 'End') engine.go(n - 1)
    else return
    e.preventDefault()
  }

  if (!n) return null

  const atStart = !wraps && current === 0
  const atEnd = !wraps && current === n - 1
  const button =
    'grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-muted text-foreground [-webkit-tap-highlight-color:transparent] hover:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--muted))] ' +
    'transition-[opacity,transform] duration-150 ease-out-quint active:scale-[0.96] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:active:scale-100 motion-reduce:transition-none ' +
    'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'

  return (
    <section aria-roledescription="carousel" aria-label={label} onKeyDown={keys} className={cn('w-full', className)} {...rest}>
      <div
        {...engine.bind}
        ref={(el) => {
          stage.current = el
          engine.bind.ref(el)
        }}
        id={id}
        role="group"
        aria-label={t.slides}
        tabIndex={0}
        style={{ height: size?.height ?? 320, perspective: size?.perspective, perspectiveOrigin: size?.origin }}
        className={cn(
          'relative w-full overflow-x-clip rounded-[calc(var(--radius)*2+2px)] select-none [-webkit-tap-highlight-color:transparent]',
          L.axis === 'x' ? 'touch-pan-y' : 'touch-pan-x',
          'cursor-grab data-dragging:cursor-grabbing',
          'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring',
        )}
      >
        {size &&
          slides.map((slide, i) => {
            const on = i === current
            return (
              <div
                key={i}
                ref={(el) => {
                  cards.current[i] = el
                }}
                role="group"
                aria-roledescription="slide"
                aria-label={`${t.slide} ${i + 1} ${t.of} ${n}`}
                aria-hidden={!on}
                onClick={on ? undefined : () => engine.go(i)}
                style={{ width: size.cardWidth, height: size.cardHeight, marginLeft: -size.cardWidth / 2, marginTop: -size.cardHeight / 2 }}
                className={cn(
                  'absolute top-1/2 left-1/2 overflow-hidden rounded-[calc(var(--radius)+2px)] bg-card will-change-transform',
                  'shadow-[0_1px_2px_rgb(0_0_0/0.06),0_18px_36px_-18px_rgb(0_0_0/0.45)]',
                  !on && 'cursor-pointer',
                  // Hidden until the first frame places it, except the card in focus.
                  !on && 'invisible',
                )}
              >
                <div
                  inert={!on}
                  className="absolute inset-0 [&>img]:size-full [&>img]:object-cover [&>img]:[-webkit-user-drag:none] [&>svg]:size-full"
                >
                  {slide}
                </div>
                <span aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)]" />
                <span aria-hidden className="pointer-events-none absolute inset-0 bg-background opacity-0" />
              </div>
            )
          })}
      </div>

      {controls && n > 1 ? (
        <div className="mx-auto mt-4 flex max-w-sm items-center gap-3">
          {/* aria-disabled, not disabled: a button that has focus keeps it when it reaches the end. */}
          <button type="button" aria-label={t.previous} aria-controls={id} aria-disabled={atStart} onClick={() => atStart || engine.move(-1)} className={button}>
            <ChevronLeft className="size-[18px]" strokeWidth={2} aria-hidden />
          </button>
          <div className="flex min-w-0 flex-1 flex-col gap-2">
            {/* A hairline rule with a primary tick, one card's share of it long, that follows the position. In a
                loop a second tick trails one rule-length behind, so the tick slides off one end and on at the other. */}
            <div aria-hidden className="@container relative h-1 overflow-hidden rounded-full bg-muted">
              {(wraps ? [0, 1] : [0]).map((k) => (
                <span
                  key={k}
                  ref={(el) => {
                    ticks.current[k] = el
                  }}
                  style={{ width: `${100 / n}%` }}
                  className="absolute inset-y-0 left-0 block rounded-full bg-primary"
                />
              ))}
            </div>
            <p className="flex justify-between font-mono text-[11px] leading-none font-medium tracking-[0.04em] text-muted-foreground tabular-nums">
              <span aria-hidden>{String(current + 1).padStart(2, '0')}</span>
              <span aria-hidden>{String(n).padStart(2, '0')}</span>
            </p>
          </div>
          <button type="button" aria-label={t.next} aria-controls={id} aria-disabled={atEnd} onClick={() => atEnd || engine.move(1)} className={button}>
            <ChevronRight className="size-[18px]" strokeWidth={2} aria-hidden />
          </button>
        </div>
      ) : null}
      <p aria-live="polite" className="sr-only">
        {`${t.slide} ${current + 1} ${t.of} ${n}`}
      </p>
    </section>
  )
}
layouts.ts
// The curves the cards of a CurveCarousel sit on. Each layout is a pure function from a card's distance to the
// focus (in slides, fractional while moving) to a pose, so a new curve is a few lines of maths.

/** Where one card sits. Lengths in px from the stage's centre, angles in degrees. */
export type CurvePose = {
  x?: number
  y?: number
  /** Towards the viewer. */
  z?: number
  rotateX?: number
  rotateY?: number
  rotateZ?: number
  scale?: number
  opacity?: number
  /** Blur in px. */
  blur?: number
  /** 0–1: how far the card fades into the page's background. */
  fog?: number
  /** Paint order. Defaults to depth (`z`). */
  order?: number
  /** -1 to 1: how far the card's picture slides inside its frame. The card sets `--curve-shift` to this
   *  times the layout's `shift` times its width; content opts in by translating by it. */
  shift?: number
}

export type CurveSize = {
  cardWidth: number
  cardHeight: number
  /** The stage's height. */
  height: number
  /** The CSS perspective of the stage, in px. */
  perspective: number
  /** A radius or other length the layout wants to keep for its poses. */
  radius?: number
  /** Where the viewer looks from, as CSS `perspective-origin`. */
  origin?: string
}

export type CurveContext = CurveSize & {
  /** The stage's width. */
  width: number
  /** How many slides there are. */
  count: number
  /** The carousel's speed, in slides per second. */
  velocity: number
}

export type CurveLayout = {
  /** The drag direction. */
  axis: 'x' | 'y'
  /** Wrap around by default. */
  loop: boolean
  /** Card width over height, unless the carousel sets one. */
  aspect: number
  /** Card size and stage size for a stage this wide. */
  size: (width: number, aspect: number, count: number) => CurveSize
  /** Pixels of drag per slide. Negative turns the drag round. */
  step: (c: CurveContext) => number
  /** Cards further than this from the focus are not drawn. */
  range: (c: CurveContext) => number
  /** The furthest a picture slides inside its card, as a share of the card's width (see `CurvePose.shift`). */
  shift?: number
  pose: (d: number, c: CurveContext) => CurvePose
}

const RAD = Math.PI / 180
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
// 0 at `a`, 1 at `b`, smooth in between.
const ramp = (v: number, a: number, b: number) => {
  const t = clamp((v - a) / (b - a), 0, 1)
  return t * t * (3 - 2 * t)
}
// A ring with at least `min` places, so few cards still leave the back of it empty.
const places = (count: number, min: number) => Math.max(count, min)

/** A hand of cards held at a hinge just below them, the focused card lifted out of the hand. */
const fan: CurveLayout = {
  axis: 'x',
  loop: false,
  aspect: 0.7,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.3, 120, 196)
    const cardHeight = cardWidth / a
    return { cardWidth, cardHeight, height: cardHeight * 1.52, perspective: 1600, radius: cardHeight * 1.1 }
  },
  step: (c) => c.cardWidth * 0.42,
  range: () => 4.6,
  pose: (d, c) => {
    const r = c.radius!
    // 7° a card, and 6° more either side of the focus, so the lifted card stands clear of the hand.
    const near = clamp(d, -1, 1)
    const deg = d * 7 + near * 6
    const a = deg * RAD
    const lift = (1 - Math.abs(near)) * c.cardHeight * 0.1
    return {
      x: r * Math.sin(a),
      y: r * (1 - Math.cos(a)) - lift - c.cardHeight * 0.1,
      rotateZ: deg,
      fog: Math.min(Math.abs(d), 4) * 0.05,
      opacity: ramp(4.6 - Math.abs(d), 0, 1),
      order: Math.round(-Math.abs(d) * 100),
    }
  },
}

/** Cards spaced along a wide circle, the focus at its top. */
const arc: CurveLayout = {
  axis: 'x',
  loop: true,
  aspect: 0.75,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.3, 120, 196)
    const cardHeight = cardWidth / a
    const radius = Math.max(w * 1.15, cardWidth * 3.6)
    return { cardWidth, cardHeight, height: cardHeight * 1.5, perspective: 1600, radius }
  },
  step: (c) => c.cardWidth * 1.12,
  range: (c) => Math.ceil(c.width / 2 / (c.cardWidth * 1.12)) + 1.5,
  pose: (d, c) => {
    const r = c.radius!
    const a = (d * c.cardWidth * 1.12) / r
    const x = r * Math.sin(a)
    return {
      x,
      y: r * (1 - Math.cos(a)) - c.cardHeight * 0.12,
      rotateZ: a / RAD,
      opacity: ramp(c.width / 2 + c.cardWidth * 0.35 - Math.abs(x), 0, c.cardWidth * 0.7),
      order: Math.round(-Math.abs(d) * 100),
    }
  },
}

/** The focused card faces you; the rest turn away and stack to either side. */
const coverflow: CurveLayout = {
  axis: 'x',
  loop: false,
  aspect: 0.8,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.4, 150, 248)
    const cardHeight = cardWidth / a
    return { cardWidth, cardHeight, height: cardHeight * 1.24, perspective: 1100 }
  },
  step: (c) => c.cardWidth * 0.55,
  range: (c) => Math.min(6, Math.ceil((c.width / 2 - c.cardWidth * 0.6) / (c.cardWidth * 0.22)) + 2),
  pose: (d, c) => {
    const a = clamp(d, -1, 1)
    const rest = d - a
    const w = c.cardWidth
    const out = Math.abs(rest)
    return {
      x: a * w * 0.62 + rest * w * 0.22,
      z: -Math.abs(a) * w * 0.55 - out * w * 0.08,
      rotateY: -a * 58,
      fog: Math.abs(a) * 0.12 + Math.min(out, 3) * 0.08,
      opacity: ramp(c.width / 2 + w * 0.1 - Math.abs(a * w * 0.62 + rest * w * 0.22), 0, w * 0.4),
      order: Math.round(-Math.abs(d) * 100),
    }
  },
}

/** A drum seen from outside: the cards ride round it and pass behind. */
const cylinder: CurveLayout = {
  axis: 'x',
  loop: true,
  aspect: 0.72,
  size: (w, a, count) => {
    const cardWidth = clamp(w * 0.26, 108, 172)
    const cardHeight = cardWidth / a
    const step = (2 * Math.PI) / places(count, 9)
    const radius = (cardWidth * 0.56) / Math.tan(step / 2)
    return { cardWidth, cardHeight, height: cardHeight * 1.3, perspective: radius * 2.2, radius }
  },
  step: (c) => (c.radius! * 2 * Math.PI) / places(c.count, 9),
  range: (c) => places(c.count, 9) / 2,
  pose: (d, c) => {
    const a = (d * 2 * Math.PI) / places(c.count, 9)
    const cos = Math.cos(a)
    return {
      x: c.radius! * Math.sin(a),
      z: c.radius! * (cos - 1),
      rotateY: a / RAD,
      opacity: ramp(cos, -0.1, 0.4),
      fog: (1 - cos) * 0.4,
    }
  },
}

/** A curved wall that wraps round the viewer, like standing inside a panorama: the nearer cards loom larger. */
const curl: CurveLayout = {
  axis: 'x',
  loop: true,
  aspect: 0.78,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.24, 100, 168)
    const cardHeight = cardWidth / a
    const radius = Math.max(w * 0.5, cardWidth * 2.2)
    return { cardWidth, cardHeight, height: cardHeight * 1.7, perspective: radius * 1.12, radius }
  },
  step: (c) => c.cardWidth * 1.06,
  range: (c) => Math.ceil((64 * RAD * c.radius!) / (c.cardWidth * 1.06)) + 0.5,
  pose: (d, c) => {
    const r = c.radius!
    const a = (d * c.cardWidth * 1.06) / r
    return {
      x: r * Math.sin(a),
      z: r * (1 - Math.cos(a)),
      rotateY: -a / RAD,
      opacity: ramp(66 - Math.abs(a / RAD), 0, 14),
      order: Math.round(-Math.abs(d) * 100),
    }
  },
}

/** A spiral staircase of cards: turning it walks them round the back and up or down past the front. */
const helix: CurveLayout = {
  axis: 'x',
  loop: false,
  aspect: 1.3,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.22, 100, 150)
    const cardHeight = cardWidth / a
    return { cardWidth, cardHeight, height: cardHeight * (1 + 2 * 5 * 0.24), perspective: 1100, radius: cardWidth * 1.5, origin: '50% 30%' }
  },
  step: (c) => c.cardWidth * 0.6,
  range: () => 5.6,
  pose: (d, c) => {
    const a = d * 30 * RAD
    const cos = Math.cos(a)
    return {
      x: c.radius! * Math.sin(a),
      y: d * c.cardHeight * 0.24,
      z: c.radius! * (cos - 1),
      rotateY: a / RAD,
      fog: (1 - cos) * 0.3,
      blur: (1 - cos) * 0.7,
      opacity: ramp(5.6 - Math.abs(d), 0, 1),
    }
  },
}

/**
 * Two strands twisting round a horizontal axis. Each step turns the twist by a little less than half a round, so
 * neighbouring cards sit on opposite strands and each strand winds slowly along. The cards face you and the back
 * strand blurs with depth.
 */
const doubleHelix: CurveLayout = {
  axis: 'x',
  loop: false,
  aspect: 1.3,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.19, 84, 124)
    const cardHeight = cardWidth / a
    const radius = cardHeight * 0.72
    return { cardWidth, cardHeight, height: cardHeight * 1.25 + radius * 2, perspective: 800, radius }
  },
  step: (c) => c.cardWidth * 0.62,
  range: (c) => Math.ceil(c.width / 2 / (c.cardWidth * 0.62)) + 1,
  pose: (d, c) => {
    const a = d * 165 * RAD
    const cos = Math.cos(a)
    const x = d * c.cardWidth * 0.62
    return {
      x,
      y: -c.radius! * Math.sin(a),
      z: c.radius! * (cos - 1),
      fog: (1 - cos) * 0.24,
      blur: (1 - cos) * 1.6,
      opacity: ramp(c.width / 2 + c.cardWidth * 0.2 - Math.abs(x), 0, c.cardWidth * 0.5),
    }
  },
}

/**
 * A card file: the cards still to come stand behind the front one, each leaning back a little more and showing
 * its top; the ones already read tip forward and drop away. Pull down for the next card.
 */
const rolodex: CurveLayout = {
  axis: 'y',
  loop: false,
  aspect: 1.62,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.56, 220, 320)
    const cardHeight = cardWidth / a
    return { cardWidth, cardHeight, height: cardHeight * 1.9, perspective: 1100, origin: '50% 10%' }
  },
  step: (c) => -c.cardHeight * 0.7,
  range: () => 5.5,
  pose: (d, c) => {
    const h = c.cardHeight
    if (d < 0) {
      // Tips forward on its bottom edge, towards you and down, and fades.
      const t = Math.min(-d, 1)
      return {
        y: h * 0.3 + t * h * 0.42,
        z: t * h * 0.5,
        rotateX: -t * 70,
        opacity: ramp(1 - t, 0, 0.7),
        order: Math.round(-d * 100) + 1000,
      }
    }
    const back = d
    return {
      y: h * 0.3 - back * h * 0.15,
      z: -back * h * 0.2,
      rotateX: Math.min(back, 1) * 10 + back * 3,
      fog: Math.min(back, 5) * 0.11,
      opacity: ramp(5.5 - back, 0, 1),
      order: Math.round(-back * 100),
    }
  },
}

/**
 * Overlapping cards like roof tiles: the card in focus lies on top, whole; the others tuck under it, each showing
 * a slice. Pictures that use `--curve-shift` slide inside their cards as they move, so each slice is a crop.
 */
const shingle: CurveLayout = {
  axis: 'x',
  loop: false,
  aspect: 0.78,
  shift: 0.14,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.44, 160, 260)
    const cardHeight = cardWidth / a
    return { cardWidth, cardHeight, height: cardHeight * 1.08, perspective: 1600 }
  },
  step: (c) => c.cardWidth * 0.5,
  range: (c) => Math.min(6, Math.ceil((c.width / 2 - c.cardWidth * 0.55) / (c.cardWidth * 0.2)) + 2),
  pose: (d, c) => {
    const a = clamp(d, -1, 1)
    const rest = d - a
    const out = Math.abs(rest)
    const x = a * c.cardWidth * 0.56 + rest * c.cardWidth * 0.2
    return {
      x,
      scale: 1 - Math.abs(a) * 0.14 - Math.min(out, 4) * 0.05,
      fog: Math.abs(a) * 0.22 + Math.min(out, 3) * 0.1,
      shift: -a,
      opacity: ramp(c.width / 2 + c.cardWidth * 0.2 - Math.abs(x), 0, c.cardWidth * 0.4),
      order: Math.round(-Math.abs(d) * 100),
    }
  },
}

/**
 * A flat row that bulges towards you while it moves fast: the middle swells forward and the ends bend away, more
 * the faster it goes, and the row flattens again as it slows down.
 */
const bulge: CurveLayout = {
  axis: 'x',
  loop: false,
  aspect: 0.75,
  size: (w, a) => {
    const cardWidth = clamp(w * 0.3, 124, 220)
    const cardHeight = cardWidth / a
    return { cardWidth, cardHeight, height: cardHeight * 1.3, perspective: 1100 }
  },
  step: (c) => c.cardWidth * 1.08,
  range: (c) => Math.ceil(c.width / 2 / (c.cardWidth * 1.08)) + 1,
  pose: (d, c) => {
    const x = d * c.cardWidth * 1.08
    const r = c.width / 2 + c.cardWidth * 0.5
    // 0 at rest, 1 from about nine slides a second.
    const b = ramp(Math.abs(c.velocity), 1.2, 9) * c.cardWidth * 0.7
    const u = x / r
    return {
      x,
      z: b * (1 - u * u),
      rotateY: Math.atan((2 * b * x) / (r * r)) / RAD,
      opacity: ramp(c.width / 2 + c.cardWidth * 0.55 - Math.abs(x), 0, c.cardWidth * 0.3),
    }
  },
}

export const curveLayouts = { fan, arc, coverflow, cylinder, curl, helix, 'double-helix': doubleHelix, rolodex, shingle, bulge } satisfies Record<
  string,
  CurveLayout
>

export type CurveLayoutName = keyof typeof curveLayouts
use-carousel-engine.ts
import { useEffect, useLayoutEffect, useState, type MouseEvent, type PointerEvent } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'

export type CarouselEngineOptions = {
  /** How many slides there are. */
  count: number
  /** Wrap around from the last slide to the first. */
  loop?: boolean
  /** Controlled: the current slide. The carousel shows it, and goes back to it if `onIndexChange` does not update it. */
  index?: number
  /** The slide shown first, when uncontrolled. */
  defaultIndex?: number
  /** Called when the carousel heads for a new slide: on release, a key or a button, not when the glide ends. */
  onIndexChange?: (index: number) => void
  /** The drag direction. `x` also takes sideways wheel and trackpad swipes. */
  axis?: 'x' | 'y'
  /** Pixels of drag that move the carousel one slide. Negative turns the drag round, e.g. pull down for the next slide. */
  step: number
  /** Come to rest on a whole slide. Off, a fling glides to a stop anywhere. */
  snap?: boolean
  /** Draws a frame: the position in slides (fractional while moving) and the speed in slides per second. */
  onFrame: (position: number, velocity: number) => void
}

export type CarouselEngine = {
  /** The slide the carousel is on or heading to. */
  index: number
  /** Go to a slide. In a loop it takes the short way round. */
  go: (index: number) => void
  /** Move by a number of slides from where the carousel is heading. */
  move: (by: number) => void
  /** Draw the current frame again, e.g. after a resize. */
  draw: () => void
  /** Spread on the element that takes the drag. */
  bind: {
    ref: (el: HTMLElement | null) => void
    onPointerDown: (e: PointerEvent<HTMLElement>) => void
    onPointerMove: (e: PointerEvent<HTMLElement>) => void
    onPointerUp: (e: PointerEvent<HTMLElement>) => void
    onPointerCancel: (e: PointerEvent<HTMLElement>) => void
    onClickCapture: (e: MouseEvent<HTMLElement>) => void
  }
}

type Settings = Required<Pick<CarouselEngineOptions, 'loop' | 'axis' | 'snap' | 'step' | 'onFrame'>> &
  Pick<CarouselEngineOptions, 'onIndexChange'> & { n: number; reduce: boolean }

const SLOP = 6 // px before a press becomes a drag
const THROW = 0.32 // s: how far a fling carries, as speed × this
const SAMPLE = 90 // ms of pointer history used for the release speed
const mod = (v: number, n: number) => ((v % n) + n) % n
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
// Pixels per slide, kept away from zero but with its sign.
const per = (step: number) => (step < 0 ? -1 : 1) * Math.max(Math.abs(step), 1)
// Past an end the carousel gives less and less, and never more than one slide.
const rubber = (over: number) => 1 - 1 / (over * 0.55 + 1)

/**
 * The motion behind the carousels: one position, in slides, that drag, fling, wheel, keys and buttons all move.
 * It runs a frame loop only while something moves and hands each frame to `onFrame`, which writes transforms
 * straight to the DOM, so React renders only when the slide changes. Glides use ease-out-quint: 300 ms for a
 * step, and after a fling a duration that starts at the finger's speed. Under reduced motion every move jumps.
 */
export function useCarouselEngine(options: CarouselEngineOptions): CarouselEngine {
  const { count, loop = false, index, defaultIndex = 0, axis = 'x', snap = true, step, onFrame, onIndexChange } = options
  const n = Math.max(count, 1)
  const reduce = useReducedMotion()
  const [current, setCurrent] = useState(() => clamp(index ?? defaultIndex, 0, n - 1))
  const [m] = useState(() => machine(current, setCurrent))
  const [surface, setSurface] = useState<HTMLElement | null>(null)

  // The latest options, for the handlers and the frame loop, which outlive a render.
  useLayoutEffect(() => {
    m.configure({ n, loop, axis, snap, step, onFrame, onIndexChange, reduce })
  })

  // A new count, or a loop turned on or off, puts the carousel back on a slide it has.
  useEffect(() => m.reseat(), [n, loop, m])
  // Controlled: follow the index from outside, and go back to it when a move was not taken up. Neither calls
  // onIndexChange, as the parent already knows.
  const controlled = index !== undefined
  const target = clamp(index ?? 0, 0, n - 1)
  useEffect(() => {
    if (controlled && target !== m.committed) m.go(target, false)
  })

  useEffect(() => (surface && axis === 'x' ? m.listenWheel(surface) : undefined), [surface, axis, m])
  useEffect(() => m.stop, [m])

  return { index: controlled ? target : current, go: m.go, move: m.move, draw: m.draw, bind: { ref: setSurface, ...m.handlers } }
}

type Tween = { from: number; to: number; start: number; dur: number }
type Drag = { id: number; x: number; y: number; p0: number; active: boolean; samples: [number, number][]; resume: number | null }

// The state and behaviour, made once per carousel and kept for its lifetime.
function machine(start: number, setCurrent: (i: number) => void) {
  let want = start // where a drag or the wheel puts the carousel
  let drawn = start // the position on screen
  let velocity = 0
  let tween: Tween | null = null
  let drag: Drag | null = null
  let raf = 0
  let last = 0
  let swallowClick = false
  let wheelTimer = 0

  const m = {
    set: null as Settings | null,
    committed: start,
    configure: (settings: Settings) => {
      m.set = settings
    },
    go,
    move,
    reseat,
    draw,
    stop,
    listenWheel,
    handlers: {
      onPointerDown,
      onPointerMove,
      onPointerUp: (e: PointerEvent<HTMLElement>) => release(e, false),
      onPointerCancel: (e: PointerEvent<HTMLElement>) => release(e, true),
      onClickCapture,
    },
  }

  function draw() {
    m.set?.onFrame(drawn, velocity)
  }

  function tick(now: number) {
    raf = 0
    const o = m.set
    if (!o) return
    let p = want
    if (tween) {
      const u = clamp((now - tween.start) / tween.dur, 0, 1)
      p = tween.from + (tween.to - tween.from) * (1 - (1 - u) ** 5)
      if (u >= 1) tween = null
    }
    const dt = Math.max(now - last, 1) / 1000
    last = now
    // Smoothed over about 60 ms, so a layout can lean into the speed without jitter.
    velocity = o.reduce ? 0 : velocity + ((p - drawn) / dt - velocity) * (1 - Math.exp(-dt / 0.06))
    drawn = p
    const moving = tween || drag?.active || Math.abs(velocity) > 0.01
    if (!moving) velocity = 0
    o.onFrame(p, velocity)
    if (moving) raf = requestAnimationFrame(tick)
  }

  function run() {
    if (raf) return
    last = performance.now()
    raf = requestAnimationFrame(tick)
  }

  function commit(to: number, notify = true) {
    const o = m.set
    if (!o) return
    const i = mod(Math.round(to), o.n)
    if (i === m.committed) return
    m.committed = i
    setCurrent(i)
    if (notify) o.onIndexChange?.(i)
  }

  // Glide to a position. With a release speed (slides/s) in the same direction, the glide starts at that speed.
  function glide(to: number, speed = 0) {
    want = to
    const dist = to - drawn
    if (m.set?.reduce || Math.abs(dist) < 1e-4) tween = null
    else {
      const dur = speed && Math.sign(speed) === Math.sign(dist) ? clamp(((5 * Math.abs(dist)) / Math.abs(speed)) * 1000, 220, 900) : 300
      tween = { from: drawn, to, start: performance.now(), dur }
    }
    run()
  }

  function go(i: number, notify = true) {
    const o = m.set
    if (!o || mod(i, o.n) === m.committed) return
    let to: number
    if (o.loop) {
      const at = Math.round(tween?.to ?? want)
      to = at + mod(i - at + o.n / 2, o.n) - o.n / 2
      // An even count puts the far slide exactly half way round: go forwards.
      if (to < at && mod(i - at, o.n) === o.n / 2) to = at + o.n / 2
    } else to = clamp(i, 0, o.n - 1)
    glide(to)
    commit(to, notify)
  }

  // After the count or the loop changes: jump to the current slide, or the last one if it is gone. A position
  // wound past the ends of a loop means nothing in the new count, so it is unwound too.
  function reseat() {
    const o = m.set
    if (!o) return
    const i = clamp(m.committed, 0, o.n - 1)
    if (want === i && drawn === i && !tween) return
    tween = null
    velocity = 0
    want = drawn = i
    commit(i)
    draw()
  }

  function move(by: number) {
    const o = m.set
    if (!o) return
    let to = Math.round(tween?.to ?? want) + by
    if (!o.loop) to = clamp(to, 0, o.n - 1)
    glide(to)
    commit(to)
  }

  function stop() {
    cancelAnimationFrame(raf)
    clearTimeout(wheelTimer)
    raf = 0
    tween = null
    drag = null
    velocity = 0
    // Land where it was heading, so a remount starts settled.
    drawn = want
  }

  // Where a position lands once the ends of a carousel that does not loop push back.
  function give(raw: number) {
    const o = m.set!
    if (o.loop) return raw
    if (raw < 0) return -rubber(-raw)
    if (raw > o.n - 1) return o.n - 1 + rubber(raw - (o.n - 1))
    return raw
  }

  function settle(speed: number, from: number) {
    const o = m.set!
    let to = drawn + speed * THROW
    if (o.snap) {
      to = Math.round(to)
      // A quick flick always moves at least one slide.
      if (Math.abs(speed) > 0.8 && to === Math.round(from)) to += Math.sign(speed)
    }
    if (!o.loop) to = clamp(to, 0, o.n - 1)
    glide(to, speed)
    commit(to)
  }

  function onPointerDown(e: PointerEvent<HTMLElement>) {
    swallowClick = false
    // A press that left the carousel before it became a drag never got its pointerup.
    if (drag && !drag.active) drop(drag)
    if (!e.isPrimary || e.button !== 0 || drag || !m.set || m.set.n < 2) return
    const resume = tween ? tween.to : null
    // Catch a moving carousel where it is.
    if (tween) {
      tween = null
      want = drawn
    }
    drag = { id: e.pointerId, x: e.clientX, y: e.clientY, p0: drawn, active: false, samples: [[e.timeStamp, drawn]], resume }
  }

  function onPointerMove(e: PointerEvent<HTMLElement>) {
    const d = drag
    const o = m.set
    if (!d || !o || e.pointerId !== d.id) return
    // The mouse came back with its button up, so it was let go outside the carousel.
    if (!d.active && !(e.buttons & 1)) return drop(d)
    const dx = e.clientX - d.x
    const dy = e.clientY - d.y
    const along = o.axis === 'x' ? dx : dy
    const across = o.axis === 'x' ? dy : dx
    if (!d.active) {
      if (Math.abs(along) < SLOP && Math.abs(across) < SLOP) return
      // Moving across the carousel: leave it to the page.
      if (Math.abs(across) >= Math.abs(along)) return drop(d)
      d.active = true
      // Count from here, so the slop does not make the slides jump.
      if (o.axis === 'x') d.x += Math.sign(dx) * SLOP
      else d.y += Math.sign(dy) * SLOP
      e.currentTarget.setPointerCapture(e.pointerId)
      e.currentTarget.dataset.dragging = ''
    }
    const p = give(d.p0 - (o.axis === 'x' ? e.clientX - d.x : e.clientY - d.y) / per(o.step))
    want = p
    d.samples.push([e.timeStamp, p])
    while (d.samples.length > 2 && e.timeStamp - d.samples[0][0] > SAMPLE) d.samples.shift()
    run()
  }

  function release(e: PointerEvent<HTMLElement>, cancelled: boolean) {
    const d = drag
    if (!d || e.pointerId !== d.id) return
    delete e.currentTarget.dataset.dragging
    if (!d.active) return drop(d)
    drag = null
    swallowClick = true
    let speed = 0
    const [t0, p0] = d.samples[0]
    const [t1, p1] = d.samples[d.samples.length - 1]
    // A finger that stopped before letting go throws nothing.
    if (!cancelled && t1 > t0 && e.timeStamp - t1 < 60) speed = ((p1 - p0) / (t1 - t0)) * 1000
    settle(speed, d.p0)
  }

  // Let go of a press that never became a drag, and finish any glide it caught.
  function drop(d: Drag) {
    drag = null
    if (d.resume !== null) glide(d.resume)
  }

  // A drag ends with a click on whatever was under the pointer; that click is not meant.
  function onClickCapture(e: MouseEvent<HTMLElement>) {
    if (!swallowClick) return
    swallowClick = false
    e.preventDefault()
    e.stopPropagation()
  }

  // Sideways wheel and trackpad swipes. A vertical wheel is left to the page.
  function listenWheel(el: HTMLElement) {
    let base: number | null = null
    const onWheel = (e: WheelEvent) => {
      const o = m.set
      const dx = e.deltaX || (e.shiftKey ? e.deltaY : 0)
      if (!o || o.n < 2 || Math.abs(dx) <= Math.abs(e.shiftKey ? 0 : e.deltaY)) return
      e.preventDefault()
      if (drag?.active) return
      tween = null
      base = (base ?? drawn) + (dx * (e.deltaMode === 1 ? 16 : 1)) / per(o.step)
      if (!o.loop) base = clamp(base, -1.5, o.n + 0.5)
      want = give(base)
      run()
      clearTimeout(wheelTimer)
      wheelTimer = window.setTimeout(() => {
        base = null
        settle(0, drawn)
      }, 140)
    }
    el.addEventListener('wheel', onWheel, { passive: false })
    return () => {
      el.removeEventListener('wheel', onWheel)
      clearTimeout(wheelTimer)
    }
  }

  return m
}
use-reduced-motion.ts
import { useSyncExternalStore } from 'react'

const QUERY = '(prefers-reduced-motion: reduce)'

function subscribe(onChange: () => void) {
  const mq = window.matchMedia(QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

/** True when the visitor has asked the system for less motion. */
export function useReducedMotion() {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  )
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/curve-carousel.json

With the registry added (components.json)

npx shadcn@latest add @manniche/curve-carousel

Files

  • curve-carousel.tsx
  • layouts.ts
  • use-carousel-engine.ts
  • use-reduced-motion.ts

Manniche Lab · open source

Free tools for AI agents and web development.