Widgets
Post carousel
Ein Beitrag mit Bildern zum Durchwischen: das sichtbare schwingt hinaus und legt sich dahinter, während das nächste nach vorn kommt.
Vorschau
Verwendung
post-carousel-demo.tsx
import type { ReactNode } from 'react'
import { PostCarousel } from '@/components/post-carousel'
const f = (v: number) => v.toFixed(1)
// 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]'
function Study({ children }: { children: ReactNode }) {
return (
<svg viewBox="0 0 240 300" preserveAspectRatio="xMidYMid slice" aria-hidden>
<rect width="240" height="300" className="fill-muted" />
{children}
</svg>
)
}
const studies = [
// Horizon: stacked lines that settle towards a flat sea.
<Study key="horizon">
{Array.from({ length: 12 }, (_, k) => {
let d = ''
for (let x = 0; x <= 240; x += 4) d += `${x ? 'L' : 'M'}${x} ${f(70 + k * 16 + Math.sin(x / 26 + k) * (12 - k))}`
return <path key={k} d={d} className={k === 5 ? sig : k % 2 ? mid : fg} />
})}
</Study>,
// Bloom: petals from one centre.
<Study key="bloom">
{Array.from({ length: 10 }, (_, k) => (
<ellipse key={k} cx="120" cy="150" rx="22" ry="92" transform={`rotate(${k * 18} 120 150)`} className={k === 3 ? sig : k % 2 ? mid : fg} />
))}
</Study>,
// Columns: a row of pillars of rising height.
<Study key="columns">
{Array.from({ length: 8 }, (_, k) => (
<rect key={k} x={28 + k * 24} y={250 - 30 - k * 22} width="14" height={30 + k * 22} rx="2" className={k === 6 ? sig : fg} />
))}
<path d="M16 250H224" className={mid} />
</Study>,
// Rings: circles drifting off centre.
<Study key="rings">
{Array.from({ length: 11 }, (_, k) => (
<circle key={k} cx={120 + k * 3} cy={150 - k * 2} r={12 + k * 11} className={k === 7 ? sig : k % 2 ? mid : fg} />
))}
</Study>,
// Grid: a field of crosses, one picked out.
<Study key="grid">
{Array.from({ length: 9 * 11 }, (_, k) => {
const x = 24 + (k % 9) * 24
const y = 30 + Math.floor(k / 9) * 24
return <path key={k} d={`M${x - 4} ${y}H${x + 4}M${x} ${y - 4}V${y + 4}`} className={k === 49 ? sig : mid} />
})}
</Study>,
]
export default function PostCarouselDemo() {
return (
<PostCarousel
label="Post by Ada Lovelace"
slides={studies}
header={
<div className="flex items-center gap-2.5">
<span aria-hidden className="grid size-9 place-items-center rounded-full bg-muted font-mono text-[11px] font-medium text-muted-foreground">
AL
</span>
<div className="min-w-0 leading-tight">
<p className="truncate text-[13px] font-semibold">Ada Lovelace</p>
<p className="font-mono text-[11px] text-muted-foreground">Analyst</p>
</div>
</div>
}
>
<p className="text-[13px] leading-snug text-pretty">
<span className="font-medium">Ada Lovelace</span> <span className="text-muted-foreground">Five studies in line, drawn for the new notebook.</span>
</p>
</PostCarousel>
)
}Quellcode
post-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'
export type PostCarouselProps = Omit<HTMLAttributes<HTMLElement>, 'onChange'> & {
/** One picture per slide. An img or an SVG fills the frame edge to edge. */
slides: ReactNode[]
/** Read by screen readers, e.g. “Post by Ada Lovelace”. */
label: string
/** The row above the pictures, e.g. an avatar and a name. */
header?: ReactNode
/** The text under the pictures. */
children?: ReactNode
/** Picture width over height. */
aspect?: number
/** Controlled: the picture in view. */
index?: number
/** The picture in view at first, when uncontrolled. */
defaultIndex?: number
/** Called when the post heads for a new picture: on release, a key or a button. */
onIndexChange?: (index: number) => void
labels?: Partial<Record<'previous' | 'next' | 'picture' | 'of' | 'pictures', string>>
}
const EN = { previous: 'Previous picture', next: 'Next picture', picture: 'Picture', of: 'of', pictures: 'Pictures' }
const DOT = 6 // px, and the gap between dots
const TYPING = 'input, textarea, select, [contenteditable=""], [contenteditable="true"]'
/**
* A post with a row of pictures to swipe through. The pictures lie in a stack: as you swipe, the one in view swings
* out to one side and tucks in behind, while its neighbour swings out from behind the other side and comes to the
* front. Drag, swipe or use a trackpad with momentum, the arrow keys, or the buttons that show on hover. A dot
* follows the position under the pictures. Every frame is written as transform and opacity only. Under reduced
* motion each move jumps straight to the next picture.
*/
export function PostCarousel({
slides,
label,
header,
children,
aspect = 4 / 5,
index,
defaultIndex,
onIndexChange,
labels = {},
className,
...rest
}: PostCarouselProps) {
const t = { ...EN, ...labels }
const n = slides.length
const id = useId()
const stage = useRef<HTMLDivElement | null>(null)
const cards = useRef<(HTMLDivElement | null)[]>([])
const dot = useRef<HTMLSpanElement | null>(null)
const [width, setWidth] = useState(0)
const frame = useRef(0)
const paint = useCallback(
(p: number) => {
const w = frame.current
if (!w) return
for (let i = 0; i < n; i++) {
const el = cards.current[i]
if (!el) continue
const d = i - p
if (Math.abs(d) >= 1.5) {
el.style.visibility = 'hidden'
continue
}
// Out to the side and back in behind: the swing peaks half way, where the two pictures pass each other.
const a = Math.max(-1, Math.min(1, d))
const swing = Math.sin(Math.PI * a)
const far = Math.abs(a)
el.style.visibility = 'visible'
el.style.transform =
`translate3d(${(swing * w * 0.5).toFixed(2)}px, 0, 0) rotate(${(swing * 3).toFixed(3)}deg)` + ` scale(${(1 - far * 0.12).toFixed(4)})`
el.style.opacity = Math.abs(d) > 1 ? ((1.5 - Math.abs(d)) * 2).toFixed(3) : '1'
el.style.zIndex = String(100 - Math.round(Math.abs(d) * 50))
const fog = el.lastElementChild as HTMLElement
fog.style.opacity = (far * 0.45).toFixed(3)
}
if (dot.current) dot.current.style.transform = `translateX(${(Math.min(Math.max(p, 0), n - 1) * DOT * 2).toFixed(2)}px)`
},
[n],
)
const engine = useCarouselEngine({ count: n, index, defaultIndex, onIndexChange, step: width || 300, onFrame: paint })
const current = engine.index
// The stage only exists while there are pictures, 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 or set of pictures: draw the current frame again.
const { draw } = engine
useLayoutEffect(() => {
frame.current = width
draw()
}, [draw, paint, width])
// If focus sat in a picture 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>) => {
if (e.defaultPrevented || (e.target as HTMLElement).closest(TYPING)) return
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') engine.move(e.key === 'ArrowRight' ? 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 = current === 0
const atEnd = current === n - 1
const button =
'absolute top-1/2 z-[200] grid size-11 -translate-y-1/2 cursor-pointer place-items-center [-webkit-tap-highlight-color:transparent] ' +
'transition-[opacity,transform] duration-150 ease-out-quint active:scale-[0.96] motion-reduce:transition-none ' +
// Shown on hover or focus, always on touch screens; at an end the button stays focusable but fades out unless it has focus.
'opacity-0 group-hover/post:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 ' +
'aria-disabled:pointer-events-none aria-disabled:not-focus-visible:opacity-0! aria-disabled:focus-visible:opacity-50 ' +
'focus-visible:outline-none [&:focus-visible>span]:outline-2 [&:focus-visible>span]:outline-offset-2 [&:focus-visible>span]:outline-ring'
const face = 'grid size-8 place-items-center rounded-full bg-card text-foreground shadow-[0_1px_2px_rgb(0_0_0/0.14),0_4px_12px_-4px_rgb(0_0_0/0.35)]'
return (
<article aria-label={label} className={cn('relative isolate w-full max-w-sm overflow-hidden rounded-[calc(var(--radius)*2+2px)] bg-card text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]', className)} {...rest}>
{header ? <div className="px-4 py-3">{header}</div> : null}
<div onKeyDown={keys} className={cn('group/post relative mx-2 overflow-hidden rounded-[calc(var(--radius)+2px)]', !header && 'mt-2', n < 2 && !children && 'mb-2')}>
<div
{...engine.bind}
ref={(el) => {
stage.current = el
engine.bind.ref(el)
}}
id={id}
role="group"
aria-roledescription="carousel"
aria-label={t.pictures}
tabIndex={0}
style={{ aspectRatio: aspect }}
className={cn(
'relative w-full touch-pan-y overflow-hidden bg-muted select-none [-webkit-tap-highlight-color:transparent]',
'cursor-grab data-dragging:cursor-grabbing',
'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
)}
>
{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.picture} ${i + 1} ${t.of} ${n}`}
aria-hidden={!on}
className={cn(
'absolute inset-0 overflow-hidden bg-muted will-change-transform',
'shadow-[0_1px_2px_rgb(0_0_0/0.08),0_16px_32px_-16px_rgb(0_0_0/0.5)]',
// Hidden until the first frame places it, except the picture in view.
!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 bg-background opacity-0" />
</div>
)
})}
</div>
{n > 1 ? (
<>
<p
aria-hidden
className="pointer-events-none absolute top-2.5 right-2.5 z-[200] rounded-full bg-card px-2 py-1 font-mono text-[11px] leading-none font-medium text-foreground tabular-nums shadow-[0_1px_2px_rgb(0_0_0/0.14)]"
>
{current + 1}
<span className="text-muted-foreground">/{n}</span>
</p>
{/* 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={cn(button, 'left-1')}>
<span className={face}>
<ChevronLeft className="size-4" strokeWidth={2} aria-hidden />
</span>
</button>
<button type="button" aria-label={t.next} aria-controls={id} aria-disabled={atEnd} onClick={() => atEnd || engine.move(1)} className={cn(button, 'right-1')}>
<span className={face}>
<ChevronRight className="size-4" strokeWidth={2} aria-hidden />
</span>
</button>
</>
) : null}
</div>
{n > 1 ? (
<div aria-hidden className="flex justify-center py-3">
<div className="relative flex" style={{ gap: DOT }}>
{slides.map((_, i) => (
<span key={i} className="block rounded-full bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]" style={{ width: DOT, height: DOT }} />
))}
<span ref={dot} className="absolute top-0 left-0 block rounded-full bg-primary" style={{ width: DOT, height: DOT }} />
</div>
</div>
) : null}
{children ? <div className={cn('px-4 pb-4', n < 2 && 'pt-3')}>{children}</div> : null}
<p aria-live="polite" className="sr-only">
{`${t.picture} ${current + 1} ${t.of} ${n}`}
</p>
</article>
)
}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/post-carousel.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/post-carousel