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 curveLayoutsuse-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.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/curve-carousel