## Snap Carousel — Scroll Momentum that lands on a slide. Docs: https://www.interior.dev/docs/snap-carousel Reference: https://www.interior.dev/reference/snap-carousel License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/snap-carousel.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx import { useState } from "react"; import { SnapCarousel } from "@/components/interior/snap-carousel"; type Shot = { id: string; name: string; src: string }; export function GalleryStrip({ shots }: { shots: Shot[] }) { const [index, setIndex] = useState(0); return (
{shots.map((shot) => (
{shot.name}
{shot.name}
))}

Showing {shots[index]?.name}

); } ``` ### Props - `children` (`React.ReactNode`) One element per slide. Each is wrapped in a full-width, non-shrinking cell, so slides never disagree about width. - `label` (`string`) Accessible name for the carousel group. Required, because an unnamed region is a dead end for a screen reader. - `index` (`number`) — default: `—` Controlled resting slide. If the parent refuses a change, the track glides back to the index it was given. - `defaultIndex` (`number`) — default: `0` Uncontrolled starting slide, clamped into range. - `onIndexChange` (`(index: number) => void`) — default: `—` Fires once per landing, with the settled index. Never fires mid-gesture. - `gap` (`number`) — default: `12` Pixels between slides. Folded into the snap step, so the gap can never accumulate into drift. - `peek` (`number`) — default: `0` Pixels of the neighbouring slides left visible at each edge. Applied as viewport padding, so the slide width shrinks with it instead of overflowing. - `momentum` (`number`) — default: `0.14` Seconds of release velocity projected forward when choosing the target slide. Higher means a lighter flick carries. - `maxFlick` (`number`) — default: `1` Hard ceiling on how many slides momentum may add past where the finger let go. - `prevLabel` (`string`) — default: `"Previous slide"` Accessible name for the back control. - `nextLabel` (`string`) — default: `"Next slide"` Accessible name for the forward control. - `className` (`string`) — default: `""` Appended last to the outer element, so callers win. ### Behavior notes - A flick resolves to a slide index before the landing spring starts, so the track cannot come to rest halfway between two slides, and it cannot leave a sliver of the wrong slide on screen when the finger lifts mid-gap. - Momentum is projected from release velocity but capped at maxFlick slides past where the finger actually let go, so a hard throw advances one slide instead of flinging the reader four deep into a gallery they never saw. - The landing spring is handed the gesture's exit velocity and can be caught mid-flight: grabbing the track again picks it up from where it currently is, never from where the last animation started. - Every slide is one viewport wide and the track stretches to the tallest of them, so moving between slides resizes nothing and the content below never jumps. - Only the resting slide is interactive; the rest are inert, which stops Tab from landing inside a slide nobody can see, and a scroll guard returns the viewport to zero if anything else tries to scroll the clipped track out of alignment. - The horizontal offset is a motion value written straight to the node, so React renders only when the target index crosses a boundary, and the polite live region announces the settled slide once rather than sixty times a second; under prefers-reduced-motion the same index is reached with the travel removed. ### Source (`components/interior/snap-carousel.tsx`) ```tsx "use client"; import { Children, isValidElement, useCallback, useEffect, useId, useRef, useState, } from "react"; import { animate, motion, useIsomorphicLayoutEffect, useMotionValue, useReducedMotion, } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45, } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8, } as const; const WALL = { type: "spring", stiffness: 700, damping: 30, mass: 0.5, } as const; const WALL_IMPULSE = 900; const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); type DragInfo = { offset: { x: number; y: number }; velocity: { x: number; y: number }; }; export type UseSnapCarouselOptions = { count: number; index?: number; defaultIndex?: number; onIndexChange?: (index: number) => void; gap?: number; momentum?: number; maxFlick?: number; disabled?: boolean; }; export function useSnapCarousel({ count, index: controlled, defaultIndex = 0, onIndexChange, gap = 12, momentum = 0.14, maxFlick = 1, disabled = false, }: UseSnapCarouselOptions) { const total = Math.max(1, Math.floor(count)); const [uncontrolled, setUncontrolled] = useState(() => clamp(defaultIndex, 0, total - 1), ); const [slideWidth, setSlideWidth] = useState(0); const [dragging, setDragging] = useState(false); const index = clamp(controlled ?? uncontrolled, 0, total - 1); const [target, setTarget] = useState(index); const step = slideWidth + gap; const viewportRef = useRef(null); const anim = useRef<{ stop: () => void } | null>(null); const desired = useRef(null); const lastStep = useRef(0); const live = useRef(index); live.current = index; const metrics = useRef({ step, total, momentum, maxFlick }); metrics.current = { step, total, momentum, maxFlick }; const changed = useRef(onIndexChange); changed.current = onIndexChange; const x = useMotionValue(0); const reduced = useReducedMotion(); const glide = useCallback( (to: number, velocity = 0) => { desired.current = to; anim.current?.stop(); anim.current = animate( x, to, reduced ? { duration: 0 } : { ...CROSSFADE, velocity }, ); }, [x, reduced], ); const goTo = useCallback( (next: number, velocity = 0) => { const shelf = metrics.current; const to = clamp(Math.round(next), 0, shelf.total - 1); setTarget(to); if (to !== live.current) { live.current = to; setUncontrolled(to); changed.current?.(to); } glide(-to * shelf.step, velocity); }, [glide], ); const bounce = useCallback( (dir: 1 | -1) => { const shelf = metrics.current; const to = -live.current * shelf.step; desired.current = to; anim.current?.stop(); anim.current = animate( x, to, reduced ? { duration: 0 } : { ...WALL, velocity: -dir * WALL_IMPULSE }, ); }, [x, reduced], ); const move = useCallback( (dir: 1 | -1) => { const to = live.current + dir; if (to < 0 || to > metrics.current.total - 1) bounce(dir); else goTo(to); }, [bounce, goTo], ); const next = useCallback(() => move(1), [move]); const prev = useCallback(() => move(-1), [move]); const pick = useCallback( (velocity: number) => { const shelf = metrics.current; if (shelf.step === 0) return live.current; const at = -x.get() / shelf.step; const anchor = clamp(Math.round(at), 0, shelf.total - 1); const projected = at - (velocity * shelf.momentum) / shelf.step; return clamp( clamp( Math.round(projected), anchor - shelf.maxFlick, anchor + shelf.maxFlick, ), 0, shelf.total - 1, ); }, [x], ); useIsomorphicLayoutEffect(() => { const el = viewportRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { const width = entries[0]?.contentRect.width ?? 0; setSlideWidth((current) => Math.abs(current - width) < 0.5 ? current : width, ); }); observer.observe(el); return () => observer.disconnect(); }, []); useIsomorphicLayoutEffect(() => { if (step === 0) return; const to = -index * step; if (lastStep.current !== step) { lastStep.current = step; desired.current = to; anim.current?.stop(); x.set(to); return; } if (dragging || desired.current === to) return; glide(to); }, [index, step, dragging, glide, x]); useEffect(() => () => anim.current?.stop(), []); const onDragStart = useCallback(() => { anim.current?.stop(); setDragging(true); setTarget(live.current); }, []); const onDrag = useCallback( (_event: MouseEvent | TouchEvent | PointerEvent, info: DragInfo) => { const to = pick(info.velocity.x); setTarget((current) => (current === to ? current : to)); }, [pick], ); const onDragEnd = useCallback( (_event: MouseEvent | TouchEvent | PointerEvent, info: DragInfo) => { setDragging(false); goTo(pick(info.velocity.x), info.velocity.x); }, [goTo, pick], ); const onKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "ArrowRight") { event.preventDefault(); next(); } else if (event.key === "ArrowLeft") { event.preventDefault(); prev(); } else if (event.key === "Home") { event.preventDefault(); goTo(0); } else if (event.key === "End") { event.preventDefault(); goTo(metrics.current.total - 1); } }, [goTo, next, prev], ); const onScroll = useCallback((event: React.UIEvent) => { event.currentTarget.scrollLeft = 0; event.currentTarget.scrollTop = 0; }, []); const viewportProps = { tabIndex: 0, role: "group" as const, "aria-roledescription": "carousel", onKeyDown, onScroll, }; const trackProps = { drag: (disabled || total < 2 ? false : "x") as false | "x", dragDirectionLock: true, dragMomentum: false, dragElastic: 0.14, dragConstraints: { left: -(total - 1) * step, right: 0 }, onDragStart, onDrag, onDragEnd, style: { x, gap: `${gap}px`, touchAction: "pan-y" as const }, }; return { index, count: total, target, shown: dragging ? target : index, dragging, slideWidth, step, x, goTo, next, prev, viewportRef, viewportProps, trackProps, }; } export type UseSnapCarouselResult = ReturnType; const CARET_LEFT = ( ); const CARET_RIGHT = ( ); export type SnapCarouselProps = { children: React.ReactNode; label: string; index?: number; defaultIndex?: number; onIndexChange?: (index: number) => void; gap?: number; peek?: number; momentum?: number; maxFlick?: number; prevLabel?: string; nextLabel?: string; className?: string; }; export function SnapCarousel({ children, label, index, defaultIndex = 0, onIndexChange, gap = 12, peek = 0, momentum = 0.14, maxFlick = 1, prevLabel = "Previous slide", nextLabel = "Next slide", className = "", }: SnapCarouselProps) { const slides = Children.toArray(children); const hintId = useId(); const reduced = useReducedMotion(); const car = useSnapCarousel({ count: slides.length, index, defaultIndex, onIndexChange, gap, momentum, maxFlick, }); const button = "grid size-7 place-items-center rounded-[6px] border border-stone-200 bg-white text-stone-700 shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] outline-none transition-[background-color,border-color,box-shadow,transform] duration-150 hover:bg-stone-50 active:translate-y-px active:shadow-[inset_0_1px_2px_rgba(28,25,23,0.06)] focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#252522] dark:text-stone-200 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#2A2A27] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)]"; return (
0 ? { WebkitMaskImage: `linear-gradient(to right, transparent 0, black ${peek + 14}px, black calc(100% - ${peek + 14}px), transparent 100%)`, maskImage: `linear-gradient(to right, transparent 0, black ${peek + 14}px, black calc(100% - ${peek + 14}px), transparent 100%)`, } : {}), }} className="relative overflow-hidden rounded-[14px] py-1.5 outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]" {...car.viewportProps} > {slides.map((slide, i) => ( {slide} ))}
{slides.map((slide, i) => ( ))}
Left and right arrow keys move between {slides.length} slides. Slide {car.index + 1} of {slides.length}
); } ```