← All components

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Carousel — usage7 lines
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

PropNotes
slidesReactNode[]Each is a group named “2 of 6” for a screen reader.
labelstringWhat it holds. The region's accessible name.
slideClassNamestringEach slide's width. `basis-[85%]` lets the next one show at the edge.
dotsbooleanThe position marker under the track.
onIndexChange(index) => voidAs 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.

packages/ui/src/components/media/Carousel.tsx228 lines
"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>
  );
}
packages/ui/src/components/actions/Button.tsx91 lines
"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>
  );
});