Carousel
Slides on the browser's own snapping scroller, so every swipe and flick already works.
The idea
The track is native scrolling with scroll-snap, not a strip moved by a transform — which is why it feels right: a trackpad swipe, a touch flick with its momentum and a Shift-scroll all work at the speed the reader's device has taught them, and the snap settles each onto a slide. The glass buttons and the arrow keys only ask the scroller for the next stop, and go quiet at the ends rather than vanishing. One marker slides along the dots, the same shared highlight every chosen thing here moves with.
Composes Button.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Carousel } from "mihirmodi-ui";
<Carousel
label="Plates from the March shoot"
slideClassName="basis-[86%]" // let the next slide show at the edge
slides={photos.map((p) => <img key={p.id} src={p.src} alt={p.alt} />)}
/>Props
| Prop | Type | Notes |
|---|---|---|
| slidesReactNode[] | ReactNode[] | Each is a group named “2 of 6” for a screen reader. |
| labelstring | string | What it holds. The region's accessible name. |
| slideClassNamestring | string= "basis-full" | Each slide's width. `basis-[85%]` lets the next one show at the edge. |
| dotsboolean | boolean= true | The position marker under the track. |
| onIndexChange(index) => void | (index) => void | As the slide at the start of the track changes. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { Button } from "../actions/Button.js";
import { glyphProps } from "../../lib/glyph.js";
import { FLAT, SLIDE } from "../../lib/motion.js";
export type CarouselProps = {
/** What the carousel holds — "Plates from the March shoot". Its accessible name. */
label: string;
slides: ReactNode[];
/**
* Each slide's width, as utilities. `basis-full` is one at a time; `basis-[85%]`
* lets the next one show at the edge, which tells a reader there is more
* without a word.
*/
slideClassName?: string;
/** Hide the dots — for a row of many small slides, where the buttons say enough. */
dots?: boolean;
/** Called as the slide at the start of the track changes. */
onIndexChange?: (index: number) => void;
className?: string;
};
function Arrow({ back }: { back?: boolean }) {
return (
<svg {...glyphProps} strokeWidth="1.8" className="h-3.5 w-3.5">
<path d={back ? "M14.5 6l-6 6 6 6" : "M9.5 6l6 6-6 6"} />
</svg>
);
}
/**
* A row of slides you move through one at a time.
*
* The track is the browser's own scroller with scroll-snap, not a strip moved
* by a transform. That is the whole reason it feels right: a trackpad's
* two-finger swipe, a touch flick with its momentum, a scroll wheel with Shift,
* all already work, at the speed the reader's device has taught them, and the
* snap settles each one onto a slide. The buttons and the arrow keys only ask
* that scroller to go to the next stop. Its scrollbar is hidden, since the dots
* and the buttons say the same thing more quietly.
*
* The position is drawn by one marker that slides along the dots, the same
* shared highlight every chosen thing here moves with. Previous and Next are
* glass, because they float over the slide, and go quiet at the ends rather
* than vanishing, so they do not jump under a reader's finger. It is the APG
* carousel's anatomy: a region with `aria-roledescription="carousel"`, slides
* that are groups named "2 of 6", and arrow keys on the region.
*/
export function Carousel({ label, slides, slideClassName = "basis-full", dots = true, onIndexChange, className = "" }: CarouselProps) {
const reduce = useReducedMotion();
const track = useRef<HTMLDivElement>(null);
const [stops, setStops] = useState<number[]>([0]);
const [index, setIndex] = useState(0);
const [dot, setDot] = useState(0);
const [edge, setEdge] = useState({ start: true, end: slides.length <= 1 });
const frame = useRef(0);
/** Where a button press last sent the track, until it arrives — so quick presses add up. */
const aim = useRef<number | null>(null);
/* The places the track can rest: each slide's left edge, capped at the far end,
so a slide narrower than the track does not leave dots that cannot be reached. */
const measure = useCallback(() => {
const t = track.current;
if (!t) return;
const maxScroll = t.scrollWidth - t.clientWidth;
const lefts = Array.from(t.children, (c) => Math.min((c as HTMLElement).offsetLeft, maxScroll));
const unique = lefts.filter((x, i) => i === 0 || x - lefts[i - 1] > 1);
setStops((was) => (was.length === unique.length && was.every((x, i) => x === unique[i]) ? was : unique));
}, []);
const read = useCallback(() => {
const t = track.current;
if (!t) return;
const x = t.scrollLeft;
const maxScroll = t.scrollWidth - t.clientWidth;
if (aim.current !== null && Math.abs(aim.current - x) < 2) aim.current = null;
const lefts = Array.from(t.children, (c) => Math.min((c as HTMLElement).offsetLeft, maxScroll));
const nearest = (xs: number[]) => xs.reduce((b, l, i) => (Math.abs(l - x) < Math.abs(xs[b] - x) ? i : b), 0);
setIndex(nearest(lefts));
// The dot is the nearest resting place, which is not always a slide's own edge.
setDot(nearest(lefts.filter((l, i) => i === 0 || l - lefts[i - 1] > 1)));
setEdge({ start: x <= 1, end: x >= maxScroll - 1 });
}, []);
useEffect(() => {
measure();
read();
const t = track.current;
if (!t || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
measure();
read();
});
ro.observe(t);
return () => ro.disconnect();
}, [measure, read, slides.length]);
useEffect(() => () => cancelAnimationFrame(frame.current), []);
useEffect(() => onIndexChange?.(index), [index, onIndexChange]);
// One read per frame, however many scroll events the frame brings.
const onScroll = () => {
cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(read);
};
const go = (i: number) => {
const t = track.current;
const el = t?.children[Math.max(0, Math.min(slides.length - 1, i))] as HTMLElement | undefined;
if (!t || !el) return;
send(Math.min(el.offsetLeft, t.scrollWidth - t.clientWidth));
};
const send = (left: number) => {
aim.current = left;
track.current?.scrollTo({ left, behavior: reduce ? "auto" : "smooth" });
};
const lefts = stops;
const step = (dir: 1 | -1) => {
const t = track.current;
if (!t) return;
// From where the last press is heading, if it has not arrived — so two quick presses go two stops.
const x = aim.current ?? t.scrollLeft;
const target = dir > 0 ? lefts.find((l) => l > x + 1) : [...lefts].reverse().find((l) => l < x - 1);
if (target === undefined) return;
send(target);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowRight") step(1);
else if (e.key === "ArrowLeft") step(-1);
else if (e.key === "Home") go(0);
else if (e.key === "End") go(slides.length - 1);
else return;
e.preventDefault();
};
const DOT = 6;
const GAP = 8;
return (
<LazyMotion features={domAnimation} strict>
<section aria-roledescription="carousel" aria-label={label} onKeyDown={onKey} className={`relative ${className}`}>
<div className="relative">
<div
ref={track}
// Focusable, so the keyboard can reach a scroller that holds nothing focusable itself.
tabIndex={0}
onScroll={onScroll}
// A hand on the track overrides wherever a press was sending it.
onWheel={() => (aim.current = null)}
onPointerDown={() => (aim.current = null)}
className="relative flex snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain rounded-[14px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{slides.map((s, i) => (
<div
key={i}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${slides.length}`}
className={`shrink-0 snap-start overflow-hidden rounded-[14px] ${slideClassName}`}
>
{s}
</div>
))}
</div>
<Button
variant="glass"
size="icon-sm"
aria-label="Previous slide"
disabled={edge.start}
onClick={() => step(-1)}
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full"
>
<Arrow back />
</Button>
<Button
variant="glass"
size="icon-sm"
aria-label="Next slide"
disabled={edge.end}
onClick={() => step(1)}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full"
>
<Arrow />
</Button>
</div>
{dots && lefts.length > 1 && (
<div className="mt-3 flex justify-center">
<div role="group" aria-label="Choose a slide" className="relative flex items-center" style={{ gap: GAP }}>
<m.span
aria-hidden
className="pointer-events-none absolute left-0 top-1/2 z-10 -mt-[3px] rounded-full bg-ink"
style={{ width: DOT, height: DOT }}
initial={false}
animate={{ x: dot * (DOT + GAP) }}
transition={reduce ? FLAT : SLIDE}
/>
{lefts.map((l, i) => (
<button
key={i}
type="button"
aria-label={`Go to slide ${i + 1}`}
aria-current={i === dot ? "true" : undefined}
onClick={() => send(l)}
// A 6px dot inside a 20px target: small to look at, easy to hit.
className="group/dot relative -m-[7px] flex h-5 w-5 items-center justify-center"
>
<span
aria-hidden
className="rounded-full bg-gray-alpha-500 transition-colors duration-150 ease-out group-hover/dot:bg-gray-alpha-600"
style={{ width: DOT, height: DOT }}
/>
</button>
))}
</div>
</div>
)}
</section>
</LazyMotion>
);
}
"use client";
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Spinner } from "./Spinner.js";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "glass";
export type ButtonSize = "sm" | "md" | "lg" | "icon" | "icon-sm";
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
/**
* `primary` is the one ink fill on a screen. `secondary` is a hairline on the
* page. `ghost` has no edge until you point at it. `glass` floats over content.
*/
variant?: ButtonVariant;
size?: ButtonSize;
/** Swap the label's leading mark for a spinner and refuse presses, keeping the width. */
loading?: boolean;
/** A mark before the label. */
icon?: ReactNode;
/** A mark after the label — an arrow, a shortcut. */
trailing?: ReactNode;
};
/** The class strings, exported so a link or a menu row can wear a button without being one. */
export const buttonVariants: Record<ButtonVariant, string> = {
primary: "bg-ink text-background-100 hover:bg-[color-mix(in_srgb,var(--color-ink)_86%,var(--color-background-100))]",
secondary:
"border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 hover:border-gray-alpha-600 hover:bg-gray-100",
ghost: "text-muted hover:bg-gray-alpha-200 hover:text-gray-1000",
glass: "glass-nav dock-shadow border-[0.5px] border-gray-alpha-400 text-gray-1000 hover:bg-[var(--glass-menu-bg)]",
};
export const buttonSizes: Record<ButtonSize, string> = {
sm: "h-7 gap-1.5 rounded-[8px] px-2.5 text-[12px]",
md: "h-9 gap-2 rounded-[10px] px-3.5 text-[13px]",
lg: "h-10 gap-2 rounded-[12px] px-4 text-[14px]",
icon: "h-9 w-9 rounded-[10px]",
"icon-sm": "h-7 w-7 rounded-[8px]",
};
/** Everything a button shares whatever it wears: the press, the transition, the disabled dim. */
export const buttonBase =
"inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium leading-none transition-[background-color,border-color,color,transform,opacity] duration-150 ease-geist active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100";
export function buttonClass(variant: ButtonVariant = "secondary", size: ButtonSize = "md", extra = "") {
return `${buttonBase} ${buttonVariants[variant]} ${buttonSizes[size]} ${extra}`;
}
/**
* A button, in four materials and five sizes.
*
* Only `primary` is filled, and a screen should have one of them: the fill is
* how the eye finds the thing to do next, and two fills make the reader choose
* between them before they have read either. Everything else is a hairline or
* nothing at all. There is deliberately no red "destructive" variant — the
* palette is monochrome, and what makes a destructive button safe is the
* words on it and the confirmation in front of it (see AlertDialog), not a
* colour.
*
* The press is a 3% shrink on the house curve: enough to feel the button
* take the click, too small to read as movement. While `loading`, the leading
* mark becomes a spinner in the same box, so the label does not jump and the
* button does not change width under the cursor.
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "secondary", size = "md", loading = false, icon, trailing, className = "", children, disabled, type = "button", ...rest },
ref
) {
const lead = loading ? <Spinner size={size === "sm" || size === "icon-sm" ? 12 : 14} /> : icon;
const iconOnly = size === "icon" || size === "icon-sm";
return (
<button
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={buttonClass(variant, size, className)}
{...rest}
>
{iconOnly ? (
(lead ?? children)
) : (
<>
{lead && <span className="flex shrink-0 items-center">{lead}</span>}
{children}
{trailing && <span className="-mr-0.5 flex shrink-0 items-center opacity-70">{trailing}</span>}
</>
)}
</button>
);
});