Mikkel Manniche
Dansk English Deutsch

Widgets

Post carousel

Et opslag med billeder, man swiper igennem: det synlige svinger ud og lægger sig bagved, mens det næste kommer frem.

Forhåndsvisning

Brug

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

Kilde

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

Installér

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

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/post-carousel

Filer

  • post-carousel.tsx
  • use-carousel-engine.ts
  • use-reduced-motion.ts

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.