Mikkel Manniche
Dansk English Deutsch

Widgets

Curve carousel

Karten auf einer Kurve in zehn Layouts, vom Fächer und Coverflow bis zu Helix, Rolodex und einer Reihe, die sich beim Werfen wölbt; Ziehen mit Schwung, Tasten und Trackpad.

Vorschau

Verwendung

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>
  )
}

Quellcode

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,
  )
}

Installieren

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

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/curve-carousel

Abhängigkeiten

Dateien

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

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.