v1.0

Carousel

Preview

Per page, alignment, orientation

Loading…

Preview

Variable size, dynamic

Loading…

Preview

Code

ts
import Carousel from '@/components/media/Carousel';

src/components/media/Carousel.tsx

AI prompt

text
Build a paged carousel/slider component, generic over its items, in React + TypeScript + Tailwind CSS.

## Look
- `<section class="flex flex-col gap-2">`.
- Main row: `flex items-center gap-1.5` (vertical: `flex-col`), holding the prev button, the viewport, and the next button.
- Viewport: `min-w-0 flex-1 self-stretch overflow-hidden`. Vertical: `w-full`, with its height set by `verticalViewportHeight`.
- Nav buttons: `h-7 w-7 rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200`, with 16px ChevronLeft / ChevronRight (ChevronUp / ChevronDown when vertical). At a non-wrapping end they get `aria-disabled`, `opacity-30` and no hover. Don't use `disabled`: disabling the focused button drops focus to <body>.
- Track: `relative flex` (vertical: `h-full flex-col`), `transition-transform duration-500 ease-out motion-reduce:transition-none`, moved with translateX / translateY.
- Slides: `shrink-0 min-w-0 min-h-0 px-1` (vertical: `py-1`, plus `overflow-hidden` so tall content cannot paint over the next slide). `flex-basis: (100 / visible)%`, unless `autoSize`.
- Under the row, when there is more than one page: `flex justify-center gap-2`, holding an optional pause/play button (a 20px nav button with a 12px Pause / Play icon, present while autoplay is on) and the dots (`gap-1.5`). Each dot is `h-1.5 rounded-full transition-all`: the active dot is `w-5 bg-indigo-600 dark:bg-indigo-400`, the others `w-1.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-600 dark:hover:bg-slate-500`.

## Paging maths
- Metrics are slide offsets, slide sizes and the viewport size, in one unit.
  - Fixed slides: slide i starts at i with size 1, and the viewport is `visible` wide.
  - `autoSize`: pixels read from the rendered slides (`offsetLeft` / `offsetWidth`, or top / height when vertical). A ResizeObserver on each slide catches images loading.
- `visible = clamp(numVisible, 1, items.length)`, and may be fractional: 1.5 shows a slide and half of the next. `scroll` is rounded, and at least 1.
- Snap points: `max = end of last slide − viewport`. For each group starting at s = 0, scroll, 2·scroll…, take `extent = end of its last slide − offsets[s]` and `shift = align === 'center' ? (viewport − extent) / 2 : 0`, and add a stop at `clamp(offsets[s] − shift, 0, max)`. Finally add `max` itself. Drop stops within `viewport × 0.001` of the previous one.
  - So the last page is flush with the end: 7 items, 3 visible, scroll 3 gives stops at 0, 3 and 4, never a half-empty strip.
  - Both ends sit flush even when centred, and no dot goes nowhere.
- Clamp the page to `pageCount − 1` AT RENDER, so a resize or a removed item never shows an empty frame.
- Translate by `−pos × (100 / visible)%` for fixed slides (it survives a resize with no re-measure), or by `−pos px` with `autoSize`.
- `responsiveOptions` match the carousel's OWN width (from a ResizeObserver), not the window. The smallest breakpoint that is ≥ the width wins, like `max-width` media queries. Before the first measurement, the base props apply.

## Behaviour
- Controlled (`page` + `onPageChange`) or internal.
- `goTo` wraps when `circular` or autoplay is on, and clamps otherwise. Wrap by rewinding, not by cloning slides: clones duplicate ids and focusables, and a screen reader would announce slides twice.
- Autoplay (`autoplayInterval` ms > 0): off under `prefers-reduced-motion` and when there is a single page. It pauses while hovered, while focus is inside, or after its pause button is pressed. Have the interval call the latest step through a ref, so it is not re-armed (clock restarted) on every page change.
- Swipe: for touch or pen (not mouse), a pointer-down to pointer-up delta over 40px along the axis goes to the next or previous page.
- Slides not wholly inside the viewport, like a peek, are `inert` and `aria-hidden`, so Tab only lands on what is visible. A slide larger than the viewport counts as shown while any of it overlaps.
- Dots are one tab stop (roving tabindex on the active dot). Left / Right (Up / Down when vertical) and Home / End change the page and move focus along the dots.

## API
- `items: T[]`, `itemTemplate(item, index)`, `itemKey?(item, index)`
- `numVisible?` = 1, `numScroll?` = 1, `align?: 'start' | 'center'` = `'start'`, `autoSize?` = false, `circular?` = false, `autoplayInterval?` = 0
- `orientation?: 'horizontal' | 'vertical'`, `verticalViewportHeight?` = '320px'
- `responsiveOptions?: { breakpoint: number; numVisible: number; numScroll: number }[]`
- `showNavigators?` = true, `showIndicators?` = true, `page?`, `onPageChange?`, `'aria-label'?` = "Carousel", `className?`

## Accessibility
- Section: `aria-roledescription="carousel"` and `aria-label`. Slides: `role="group" aria-roledescription="slide" aria-label="3 of 8"`.
- The track is `aria-live="polite"`, but `"off"` while autoplay is running, or a screen reader narrates the slideshow.
- Nav buttons: "Previous page" / "Next page". Dots: "Go to page n" with `aria-current`. Pause button: "Stop automatic slide show" / "Start automatic slide show".

## Demo
Eight photo cards (picsum.photos seeds: lake, forest, desert, harbor, meadow, canyon, glacier, valley). Each card is an opaque rounded card with a 16:10 cover image, then "Photo n" and "Random sample image" below it. Show 3 per page, `circular`, `autoplayInterval={4000}`, with responsive options of 2 per page at ≤640px and 1 at ≤420px. Add a Horizontal / Vertical toggle; vertical shows 2 per page in a 360px viewport inside `max-w-xs`.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from 'react';
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Pause, Play } from 'lucide-react';
import { cn } from '@/lib/cn';

export interface CarouselResponsiveOption {
  /** Applies while the carousel's OWN width is at most this many px. */
  breakpoint: number;
  numVisible: number;
  numScroll: number;
}

export interface CarouselProps<T> {
  items: T[];
  itemTemplate: (item: T, index: number) => ReactNode;
  /** Defaults to the index; pass one when items can be reordered. */
  itemKey?: (item: T, index: number) => string | number;
  /**
   * Slides in view at once. May be fractional: `1.5` shows one slide and half
   * of the next, the peek that says there is more. Ignored with `autoSize`.
   */
  numVisible?: number;
  /** Slides to move per step. */
  numScroll?: number;
  /**
   * Where a step's slides sit in the viewport. `center` puts them in the
   * middle with the neighbours peeking either side; both ends still sit flush,
   * so the strip never opens a gap before the first slide or after the last.
   */
  align?: 'start' | 'center';
  /**
   * Each slide keeps the size its template gives it, instead of a share of
   * the viewport — cards of different widths. Steps are measured from the
   * rendered slides, so they follow content and images as they load.
   */
  autoSize?: boolean;
  /** Next on the last page returns to the first, and prev on the first goes to the last. */
  circular?: boolean;
  /** Milliseconds between pages. 0 turns autoplay off; it is also off under `prefers-reduced-motion`. */
  autoplayInterval?: number;
  orientation?: 'horizontal' | 'vertical';
  /** The viewport's height in `vertical` orientation, where it cannot be taken from the content. */
  verticalViewportHeight?: string;
  /** Overrides by width, matched against the carousel's own box — not the window — so it adapts inside a sidebar or a split pane too. */
  responsiveOptions?: CarouselResponsiveOption[];
  showNavigators?: boolean;
  showIndicators?: boolean;
  /** Controlled page (0-based); pair with `onPageChange`. */
  page?: number;
  onPageChange?: (page: number) => void;
  'aria-label'?: string;
  className?: string;
}

const REDUCED = '(prefers-reduced-motion: reduce)';
const subscribeReduced = (cb: () => void) => {
  const mq = window.matchMedia(REDUCED);
  mq.addEventListener('change', cb);
  return () => mq.removeEventListener('change', cb);
};

/**
 * Positions along the strip, in one unit throughout: slide widths for a fixed
 * strip (slide `i` starts at `i`), pixels for `autoSize`.
 */
type Metrics = { offsets: number[]; sizes: number[]; viewport: number };

/**
 * Where each step stops. One stop per `scroll` slides, shifted for `center`,
 * then clamped so the strip never scrolls past either end. Clamping makes
 * neighbouring stops collide at the ends — three slides centred in a
 * three-slide viewport all clamp to 0 — and a collided stop would be a dot
 * that goes nowhere, so duplicates are dropped.
 */
function snapPoints({ offsets, sizes, viewport }: Metrics, scroll: number, align: 'start' | 'center') {
  const n = offsets.length;
  if (n === 0) return [0];
  const max = Math.max(0, offsets[n - 1] + sizes[n - 1] - viewport);
  const eps = viewport * 1e-3;
  const snaps: number[] = [];
  const add = (pos: number) => {
    if (snaps.length === 0 || pos - snaps[snaps.length - 1] > eps) snaps.push(pos);
  };
  for (let s = 0; s < n; s += scroll) {
    const last = Math.min(s + scroll, n) - 1;
    const extent = offsets[last] + sizes[last] - offsets[s];
    const shift = align === 'center' ? (viewport - extent) / 2 : 0;
    add(Math.min(max, Math.max(0, offsets[s] - shift)));
  }
  // A last slide wider than the viewport ends past every stop; reach its end too.
  add(max);
  return snaps;
}

const sameMetrics = (a: Metrics | null, b: Metrics) =>
  !!a &&
  a.viewport === b.viewport &&
  a.offsets.length === b.offsets.length &&
  a.offsets.every((o, i) => o === b.offsets[i] && a.sizes[i] === b.sizes[i]);

const NAV =
  'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 aria-disabled:cursor-default aria-disabled:opacity-30 aria-disabled:hover:bg-transparent dark:aria-disabled:hover:bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200';

// `aria-disabled` rather than `disabled` at either end: disabling the button
// that has focus drops focus to <body>, so the Enter that reached the last
// page would leave the keyboard user nowhere.
const endProps = (off: boolean, go: () => void) => ({
  'aria-disabled': off || undefined,
  onClick: () => {
    if (!off) go();
  },
});

/**
 * A paged strip of items that slides by `numScroll` at a time.
 *
 * Pages are counted so the LAST page is flush with the end: with 7 items, 3
 * visible and a scroll of 3, the pages start at 0, 3 and 4 — never at 6 with
 * two empty slots. A strip that ends half-empty reads as missing content. The
 * same holds for `align="center"`, fractional `numVisible` and `autoSize`:
 * every mode computes its stops the one way, in `snapPoints`.
 *
 * `circular` wraps by rewinding, not by cloning slides at either end. Clones
 * duplicate every focusable element and id inside `itemTemplate`, and a screen
 * reader would announce the same slide twice; a visible slide back to the start
 * is the honest version of "we are at the beginning again".
 *
 * Off-screen slides are `inert` and `aria-hidden`, so Tab never lands on a
 * button the viewer cannot see. Autoplay pauses while the pointer or focus is
 * inside, and has its own pause button — WCAG 2.2.2 asks for one on anything
 * that moves by itself for more than five seconds.
 */
export default function Carousel<T>({
  items,
  itemTemplate,
  itemKey,
  numVisible = 1,
  numScroll = 1,
  align = 'start',
  autoSize = false,
  circular = false,
  autoplayInterval = 0,
  orientation = 'horizontal',
  verticalViewportHeight = '320px',
  responsiveOptions,
  showNavigators = true,
  showIndicators = true,
  page: pageProp,
  onPageChange,
  'aria-label': ariaLabel = 'Carousel',
  className,
}: CarouselProps<T>) {
  const rootRef = useRef<HTMLElement>(null);
  const dotsRef = useRef<HTMLDivElement>(null);
  const viewportRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const [measured, setMeasured] = useState<Metrics | null>(null);
  const swipe = useRef<{ x: number; y: number } | null>(null);
  const [width, setWidth] = useState(0);
  const [innerPage, setInnerPage] = useState(0);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [stopped, setStopped] = useState(false);
  const reducedMotion = useSyncExternalStore(subscribeReduced, () => window.matchMedia(REDUCED).matches, () => false);

  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // The narrowest breakpoint that still contains the current width wins,
  // matching `max-width` media-query semantics. Before the first measurement
  // (width 0, and always on the server) the base props apply.
  const match =
    width > 0
      ? [...(responsiveOptions ?? [])].sort((a, b) => a.breakpoint - b.breakpoint).find((o) => width <= o.breakpoint)
      : undefined;
  const visible = Math.max(1, Math.min(match?.numVisible ?? numVisible, items.length || 1));
  const scroll = Math.max(1, Math.round(match?.numScroll ?? numScroll));
  const vertical = orientation === 'vertical';

  // `autoSize` reads its geometry off the rendered slides. Measured after
  // every render but stored only when it changed, so this settles in one pass;
  // the observer catches what a render does not cause — an image arriving.
  const measure = () => {
    const vp = viewportRef.current;
    const track = trackRef.current;
    if (!autoSize || !vp || !track) return;
    const slides = [...track.children] as HTMLElement[];
    const next: Metrics = {
      offsets: slides.map((el) => (vertical ? el.offsetTop : el.offsetLeft)),
      sizes: slides.map((el) => (vertical ? el.offsetHeight : el.offsetWidth)),
      viewport: vertical ? vp.clientHeight : vp.clientWidth,
    };
    setMeasured((prev) => (sameMetrics(prev, next) ? prev : next));
  };
  useLayoutEffect(measure);
  useEffect(() => {
    const track = trackRef.current;
    if (!autoSize || !track) return;
    const ro = new ResizeObserver(() => measure());
    for (const el of track.children) ro.observe(el);
    return () => ro.disconnect();
  }, [autoSize, items, vertical]); // eslint-disable-line react-hooks/exhaustive-deps

  // Until `autoSize` has measured (and always on the server) there is one
  // stop at 0 and every slide counts as shown.
  const metrics: Metrics | null = autoSize
    ? measured
    : { offsets: items.map((_, i) => i), sizes: items.map(() => 1), viewport: visible };
  const snaps = metrics ? snapPoints(metrics, scroll, align) : [0];
  const pageCount = snaps.length;
  // Clamped at render, not in an effect: a resize that lowers the page count
  // must not paint one frame past the end.
  const page = Math.min(Math.max(0, pageProp ?? innerPage), pageCount - 1);
  const pos = snaps[page];

  const isShown = (i: number) => {
    if (!metrics || i >= metrics.offsets.length) return true;
    const eps = metrics.viewport * 1e-3;
    const a = metrics.offsets[i];
    const b = a + metrics.sizes[i];
    const inside = a >= pos - eps && b <= pos + metrics.viewport + eps;
    // A slide bigger than the viewport is never wholly inside it; it is shown while any of it is.
    return inside || (metrics.sizes[i] > metrics.viewport && b > pos && a < pos + metrics.viewport);
  };

  const autoplay = autoplayInterval > 0 && !reducedMotion && pageCount > 1;
  // Autoplay has nowhere to go from the last page but back, so it implies wrapping.
  const wraps = circular || autoplay;

  const goTo = (p: number) => {
    const next = wraps ? (p + pageCount) % pageCount : Math.min(Math.max(0, p), pageCount - 1);
    if (next === page) return;
    if (pageProp === undefined) setInnerPage(next);
    onPageChange?.(next);
  };

  // The interval calls the LATEST step through a ref, so it is armed once per
  // run rather than torn down and re-armed on every page change — which would
  // also restart its clock each time.
  const advance = useRef(() => {});
  useEffect(() => {
    advance.current = () => goTo(page + 1);
  });
  const running = autoplay && !stopped && !hovered && !focused;
  useEffect(() => {
    if (!running) return;
    const id = window.setInterval(() => advance.current(), autoplayInterval);
    return () => window.clearInterval(id);
  }, [running, autoplayInterval]);

  const PrevIcon = vertical ? ChevronUp : ChevronLeft;
  const NextIcon = vertical ? ChevronDown : ChevronRight;
  const share = 100 / visible;
  // Fixed slides move in shares of the track (percent survives a resize with
  // no re-measure); measured ones in pixels.
  const offset = autoSize ? `${-pos}px` : `${-pos * share}%`;

  const onDotsKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const back = vertical ? 'ArrowUp' : 'ArrowLeft';
    const fwd = vertical ? 'ArrowDown' : 'ArrowRight';
    if (e.key !== back && e.key !== fwd && e.key !== 'Home' && e.key !== 'End') return;
    e.preventDefault();
    const target =
      e.key === 'Home' ? 0 : e.key === 'End' ? pageCount - 1 : e.key === back ? page - 1 : page + 1;
    goTo(target);
    const wrapped = wraps ? (target + pageCount) % pageCount : Math.min(Math.max(0, target), pageCount - 1);
    dotsRef.current?.querySelectorAll('button')[wrapped]?.focus();
  };

  return (
    <section
      ref={rootRef}
      aria-roledescription="carousel"
      aria-label={ariaLabel}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      onFocus={() => setFocused(true)}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
      }}
      className={cn('flex flex-col gap-2', className)}
    >
      <div className={cn('flex items-center gap-1.5', vertical && 'flex-col')}>
        {showNavigators && (
          <button type="button" {...endProps(!wraps && page === 0, () => goTo(page - 1))} aria-label="Previous page" className={NAV}>
            <PrevIcon aria-hidden className="h-4 w-4" />
          </button>
        )}

        <div
          ref={viewportRef}
          className={cn('min-w-0 overflow-hidden', vertical ? 'w-full' : 'flex-1 self-stretch')}
          style={vertical ? { height: verticalViewportHeight } : undefined}
          onPointerDown={(e) => {
            if (e.pointerType !== 'mouse') swipe.current = { x: e.clientX, y: e.clientY };
          }}
          onPointerUp={(e) => {
            const s = swipe.current;
            swipe.current = null;
            if (!s) return;
            const d = vertical ? e.clientY - s.y : e.clientX - s.x;
            if (Math.abs(d) > 40) goTo(d < 0 ? page + 1 : page - 1);
          }}
        >
          <div
            ref={trackRef}
            // Announce page changes a person made; stay quiet while autoplay
            // turns them over, or a screen reader narrates a slideshow.
            aria-live={running ? 'off' : 'polite'}
            className={cn(
              // `relative`: slide offsets are measured against the track.
              'relative flex transition-transform duration-500 ease-out motion-reduce:transition-none',
              vertical ? 'h-full flex-col' : 'w-full',
            )}
            style={{ transform: vertical ? `translateY(${offset})` : `translateX(${offset})` }}
          >
            {items.map((item, i) => {
              // Partly visible slides — a peek — stay inert: focusing one would
              // scroll the clipped viewport out from under the transform.
              const shown = isShown(i);
              return (
                <div
                  key={itemKey ? itemKey(item, i) : i}
                  role="group"
                  aria-roledescription="slide"
                  aria-label={`${i + 1} of ${items.length}`}
                  aria-hidden={!shown || undefined}
                  inert={!shown}
                  // `overflow-hidden` in vertical: the slide's height is a
                  // share of the viewport, and content taller than that would
                  // otherwise paint over the next slide.
                  className={cn(
                    'min-h-0 min-w-0 shrink-0',
                    vertical ? 'py-1' : 'px-1',
                    vertical && !autoSize && 'overflow-hidden',
                  )}
                  style={autoSize ? undefined : { flexBasis: `${share}%` }}
                >
                  {itemTemplate(item, i)}
                </div>
              );
            })}
          </div>
        </div>

        {showNavigators && (
          <button
            type="button"
            {...endProps(!wraps && page === pageCount - 1, () => goTo(page + 1))}
            aria-label="Next page"
            className={NAV}
          >
            <NextIcon aria-hidden className="h-4 w-4" />
          </button>
        )}
      </div>

      {(showIndicators || autoplay) && pageCount > 1 && (
        <div className="flex items-center justify-center gap-2">
          {autoplay && (
            <button
              type="button"
              onClick={() => setStopped((s) => !s)}
              aria-label={stopped ? 'Start automatic slide show' : 'Stop automatic slide show'}
              className={cn(NAV, 'h-5 w-5')}
            >
              {stopped ? <Play aria-hidden className="h-3 w-3" /> : <Pause aria-hidden className="h-3 w-3" />}
            </button>
          )}
          {showIndicators && (
            <div ref={dotsRef} className="flex items-center gap-1.5" onKeyDown={onDotsKey}>
              {Array.from({ length: pageCount }, (_, p) => (
                <button
                  key={p}
                  type="button"
                  onClick={() => goTo(p)}
                  aria-label={`Go to page ${p + 1}`}
                  aria-current={p === page || undefined}
                  // One tab stop for the row; arrows move along it.
                  tabIndex={p === page ? 0 : -1}
                  className={cn(
                    'h-1.5 rounded-full transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900',
                    p === page
                      ? 'w-5 bg-indigo-600 dark:bg-indigo-400'
                      : 'w-1.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-600 dark:hover:bg-slate-500',
                  )}
                />
              ))}
            </div>
          )}
        </div>
      )}
    </section>
  );
}

Props

PropTypeDefaultDescription
items*T[]—
itemTemplate*(item: T, index: number) => ReactNode—
itemKey(item: T, index: number) => string | number—Defaults to the index; pass one when items can be reordered.
numVisiblenumber—Slides in view at once. May be fractional: `1.5` shows one slide and half of the next, the peek that says there is more. Ignored with `autoSize`.
numScrollnumber—Slides to move per step.
align'start' | 'center'—Where a step's slides sit in the viewport. `center` puts them in the middle with the neighbours peeking either side; both ends still sit flush, so the strip never opens a gap before the first slide or after the last.
autoSizeboolean—Each slide keeps the size its template gives it, instead of a share of the viewport — cards of different widths. Steps are measured from the rendered slides, so they follow content and images as they load.
circularboolean—Next on the last page returns to the first, and prev on the first goes to the last.
autoplayIntervalnumber—Milliseconds between pages. 0 turns autoplay off; it is also off under `prefers-reduced-motion`.
orientation'horizontal' | 'vertical'—
verticalViewportHeightstring—The viewport's height in `vertical` orientation, where it cannot be taken from the content.
responsiveOptionsCarouselResponsiveOption[]—Overrides by width, matched against the carousel's own box — not the window — so it adapts inside a sidebar or a split pane too.
showNavigatorsboolean—
showIndicatorsboolean—
pagenumber—Controlled page (0-based); pair with `onPageChange`.
onPageChange(page: number) => void—
classNamestring—