Skip to content
UI Kit
Accent color

React carousel

Picture slides after the APG pattern: a hero with autoplay and pause, cards with the next one peeking in, a gallery with thumbnails and a centered row. Swipe, drag, keyboard, and a plain scroll row without JavaScript.

4 variantsOne of 7 components in Media

Slides that respect the keyboard

A carousel shows several pictures or cards in the space of one. This one follows the APG carousel pattern on a native snap scroll row, so swipe, mouse drag, arrow keys and dots all work, and the row stays readable and scrollable without JavaScript.

Hero with autoplay shows full width pictures with a caption on a soft scrim, autoplay rests while the pointer is on it, stops for good once the keyboard enters, and the first button pauses and starts it. Cards with a peek lines up listings with the next card always peeking in, arrows and a count in the title row and a thin line that shows how much of the row is in view. Gallery with thumbnails pairs one large picture with a strip of thumbnails, the current one carries the accent ring. Centered row keeps the current picture in the middle while its neighbours shrink and fade, and after the last one the row starts again.

Let people control the pace, autoplay always needs a visible pause. Put the most important slide first, many visitors never see the second. On phones the card row with a peek tells people there is more without a word.

Variants

Details

What Carousel brings along, read from the registry and the source.

Category
Media
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/carousel.tsx
Brings along
utilskiticon
npm packages
None
Languages
English and German, 9 texts of its own
Studio
Adjust in code

Props

The CarouselProps interface from components/ui/carousel.tsx, with the comments from the code.

  • slidesrequired
    CarouselSlide[]
  • variantoptional
    CarouselVariant

    herocardsthumbnailscentered

  • labelrequired
    string

    Accessible name, e.g. "New in the shop"

  • headingoptional
    React.ReactNode

    Title row above the cards (cards variant)

  • autoplayoptional
    number

    Advance on its own every n ms. Off with reduced motion, paused on hover, stopped by keyboard focus.

  • loopoptional
    boolean

    After the last slide comes the first again

  • aspectoptional
    string

    Ratio of the pictures, e.g. "16/9"

  • startIndexoptional
    number
  • onIndexChangeoptional
    (index: number) => void

Built-in texts

Carousel writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 9 texts
carouselEnglish carouselGerman Karussell
slideEnglish slideGerman Folie
ofEnglish ofGerman von
previousEnglish Previous slideGerman Vorherige Folie
nextEnglish Next slideGerman Nächste Folie
goToEnglish Show slideGerman Folie zeigen
stopEnglish Stop the slide showGerman Diashow anhalten
startEnglish Start the slide showGerman Diashow starten
picturesEnglish PicturesGerman Bilder

Accessibility

Read from the source of the component.

  • Carousel handles the keys Arrow left, Arrow right and Space itself.
  • Sets aria-current, aria-label, aria-live and aria-roledescription, so screen readers announce purpose and state.
  • Uses the role group.
  • Announces changes through a live region without moving focus.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 633 lines
components/ui/carousel.tsx
'use client';

import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';

/*
 * Carousel after the APG pattern: a region with the role description
 * "carousel", slides as labelled groups ("2 of 5"), previous and next
 * buttons, a picker (dots or thumbnails) and, with autoplay, a rotation
 * button that comes first. Autoplay pauses while the pointer rests on it,
 * stops for good once keyboard focus enters, waits while it is off screen or
 * the tab is hidden and never starts with reduced motion.
 *
 * The slides sit in a native scroll row with snap points. Fingers swipe it
 * natively, a mouse drags it (pointer events), and without JavaScript it is
 * still a row of pictures that scrolls sideways.
 */

export type CarouselVariant = 'hero' | 'cards' | 'thumbnails' | 'centered';

export interface CarouselSlide {
  /** Picture of the slide, a drawn scene, a photo or a screen */
  media: React.ReactNode;
  /** Smaller picture for the thumbnail strip, defaults to media */
  thumb?: React.ReactNode;
  eyebrow?: string;
  title?: string;
  description?: string;
  /** Quiet line under the card text, e.g. a price or a date */
  meta?: string;
  href?: string;
  /** Text of the link on hero slides */
  cta?: string;
}

const TEXTS = {
  en: {
    carousel: 'carousel',
    slide: 'slide',
    of: 'of',
    previous: 'Previous slide',
    next: 'Next slide',
    goTo: 'Show slide',
    stop: 'Stop the slide show',
    start: 'Start the slide show',
    pictures: 'Pictures',
  },
  de: {
    carousel: 'Karussell',
    slide: 'Folie',
    of: 'von',
    previous: 'Vorherige Folie',
    next: 'Nächste Folie',
    goTo: 'Folie zeigen',
    stop: 'Diashow anhalten',
    start: 'Diashow starten',
    pictures: 'Bilder',
  },
};
type CarouselTexts = (typeof TEXTS)['en'];

export interface CarouselProps {
  slides: CarouselSlide[];
  variant?: CarouselVariant;
  /** Accessible name, e.g. "New in the shop" */
  label: string;
  /** Title row above the cards (cards variant) */
  heading?: React.ReactNode;
  /** Advance on its own every n ms. Off with reduced motion, paused on hover, stopped by keyboard focus. */
  autoplay?: number;
  /** After the last slide comes the first again */
  loop?: boolean;
  /** Ratio of the pictures, e.g. "16/9" */
  aspect?: string;
  startIndex?: number;
  onIndexChange?: (index: number) => void;
  labels?: Partial<CarouselTexts>;
  className?: string;
}

const STYLES =
  '.kitfx-car-bar{transform-origin:left;animation:kitfx-car-bar var(--car-ms) linear both}@keyframes kitfx-car-bar{from{transform:scaleX(0)}to{transform:scaleX(1)}}' +
  '.kitfx-car-paused .kitfx-car-bar{animation-play-state:paused}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-car-bar{animation:none;transform:scaleX(1)}}';

const ROUND_BTN =
  'relative grid size-10 shrink-0 cursor-pointer place-items-center rounded-full border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] outline-none transition-[background-color,color,scale,opacity] duration-200 ease-[var(--ease-kit)] after:absolute after:-inset-0.5 after:rounded-full after:content-[""] hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring active:scale-95 disabled:cursor-default disabled:opacity-40 disabled:hover:bg-background motion-reduce:active:scale-100 max-sm:size-11';

/** The heart of every variant: where the row is, how to move it, how a mouse drags it */
function useCarousel(count: number, opts: { loop: boolean; center: boolean; startIndex: number; onIndexChange?: (i: number) => void }) {
  const trackRef = useRef<HTMLDivElement>(null);
  const [index, setIndex] = useState(opts.startIndex);
  const [edges, setEdges] = useState({ start: true, end: count <= 1 });
  const indexRef = useRef(index);
  const onChange = useRef(opts.onIndexChange);
  onChange.current = opts.onIndexChange;

  const slidesOf = (t: HTMLDivElement) => [...t.querySelectorAll<HTMLElement>(':scope > [data-slide]')];
  const target = useCallback(
    (t: HTMLDivElement, el: HTMLElement) => {
      const max = t.scrollWidth - t.clientWidth;
      const pad = parseFloat(getComputedStyle(t).paddingInlineStart) || 0;
      const x = opts.center ? el.offsetLeft - (t.clientWidth - el.offsetWidth) / 2 : el.offsetLeft - pad;
      return Math.max(0, Math.min(max, x));
    },
    [opts.center],
  );

  const sync = useCallback(() => {
    const t = trackRef.current;
    if (!t) return;
    const slides = slidesOf(t);
    let best = 0;
    let dist = Infinity;
    slides.forEach((el, i) => {
      const d = Math.abs(target(t, el) - t.scrollLeft);
      if (d < dist - 1) {
        dist = d;
        best = i;
      }
    });
    const max = t.scrollWidth - t.clientWidth;
    setEdges({ start: t.scrollLeft <= 2, end: t.scrollLeft >= max - 2 });
    if (best !== indexRef.current) {
      indexRef.current = best;
      setIndex(best);
      onChange.current?.(best);
    }
  }, [target]);

  const goTo = useCallback(
    (i: number, smooth = true) => {
      const t = trackRef.current;
      if (!t || !count) return;
      const n = opts.loop ? (i + count) % count : Math.max(0, Math.min(count - 1, i));
      const el = slidesOf(t)[n];
      if (!el) return;
      t.scrollTo({ left: target(t, el), behavior: smooth && !prefersReducedMotion() ? 'smooth' : 'auto' });
      // The picker follows at once, the scroll event confirms it
      indexRef.current = n;
      setIndex(n);
      onChange.current?.(n);
    },
    [count, opts.loop, target],
  );

  // Start position and measuring on scroll and resize
  useEffect(() => {
    const t = trackRef.current;
    if (!t) return;
    if (opts.startIndex) goTo(opts.startIndex, false);
    let raf = 0;
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(sync);
    };
    t.addEventListener('scroll', onScroll, { passive: true });
    const ro = new ResizeObserver(onScroll);
    ro.observe(t);
    sync();
    return () => {
      cancelAnimationFrame(raf);
      t.removeEventListener('scroll', onScroll);
      ro.disconnect();
    };
  }, [sync]); // eslint-disable-line react-hooks/exhaustive-deps

  // Mouse drag. Fingers keep the native swipe, a pen or mouse pulls the row and lets it snap.
  const drag = useRef<{ x: number; left: number; from: number; moved: boolean; id: number } | null>(null);
  const pointer = {
    onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
      if (e.pointerType === 'touch' || e.button !== 0) return;
      const t = trackRef.current;
      if (!t) return;
      drag.current = { x: e.clientX, left: t.scrollLeft, from: indexRef.current, moved: false, id: e.pointerId };
    },
    onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
      const d = drag.current;
      const t = trackRef.current;
      if (!d || !t) return;
      const dx = e.clientX - d.x;
      if (!d.moved && Math.abs(dx) < 6) return;
      if (!d.moved) {
        d.moved = true;
        t.setPointerCapture(d.id);
        t.style.scrollSnapType = 'none';
        t.style.scrollBehavior = 'auto';
        t.style.cursor = 'grabbing';
      }
      t.scrollLeft = d.left - dx;
    },
    onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
      const d = drag.current;
      const t = trackRef.current;
      drag.current = null;
      if (!d || !t || !d.moved) return;
      t.style.cursor = '';
      const dx = e.clientX - d.x;
      t.style.scrollSnapType = '';
      t.style.scrollBehavior = '';
      // A clear pull moves at least one slide, a small one snaps back
      const step = Math.abs(dx) > 48 ? (dx < 0 ? 1 : -1) : 0;
      const goal = opts.loop ? Math.max(0, Math.min(count - 1, d.from + step)) : d.from + step;
      goTo(goal);
      // The click that ends a drag must not open a link inside the slide
      const stop = (ev: Event) => {
        ev.preventDefault();
        ev.stopPropagation();
      };
      t.addEventListener('click', stop, { capture: true, once: true });
      setTimeout(() => t.removeEventListener('click', stop, { capture: true }), 0);
    },
  };
  return { trackRef, index, edges, goTo, pointer };
}

/** Autoplay that follows the APG rules */
function useAutoplay(ms: number | undefined, rootRef: React.RefObject<HTMLElement | null>) {
  const [playing, setPlaying] = useState(false);
  const [hover, setHover] = useState(false);
  const [visible, setVisible] = useState(true);
  useEffect(() => {
    if (ms && !prefersReducedMotion()) setPlaying(true);
  }, [ms]);
  useEffect(() => {
    const el = rootRef.current;
    if (!el || !ms) return;
    const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting && document.visibilityState === 'visible'));
    io.observe(el);
    const onVis = () => setVisible(document.visibilityState === 'visible');
    document.addEventListener('visibilitychange', onVis);
    return () => {
      io.disconnect();
      document.removeEventListener('visibilitychange', onVis);
    };
  }, [ms, rootRef]);
  const running = Boolean(ms) && playing && !hover && visible;
  return { playing, setPlaying, running, setHover };
}

function SlideMedia({ slide, aspect, className, rounded = 'rounded-xl' }: { slide: CarouselSlide; aspect: string; className?: string; rounded?: string }) {
  return (
    <div data-clip-ok className={cn('relative overflow-hidden bg-muted', rounded, className)} style={{ aspectRatio: aspect }}>
      <div className="pointer-events-none absolute inset-0 select-none [&>*]:size-full [&_img]:object-cover">{slide.media}</div>
    </div>
  );
}

/** A round dot that grows into a short bar for the current slide, with the autoplay time filling it */
function Dots({ count, index, goTo, t, running, ms, label }: { count: number; index: number; goTo: (i: number) => void; t: CarouselTexts; running: boolean; ms?: number; label: string }) {
  return (
    <div role="group" aria-label={label} className="flex items-center">
      {Array.from({ length: count }, (_, i) => {
        const on = i === index;
        return (
          <button
            key={i}
            type="button"
            aria-label={`${t.goTo} ${i + 1} ${t.of} ${count}`}
            aria-current={on || undefined}
            onClick={() => goTo(i)}
            className="group/dot relative grid h-11 w-6 cursor-pointer place-items-center outline-none max-sm:w-7 [&:focus-visible>span]:outline-2 [&:focus-visible>span]:outline-offset-[3px] [&:focus-visible>span]:outline-solid [&:focus-visible>span]:outline-ring"
          >
            <span
              className={cn(
                'relative block h-1.5 overflow-hidden rounded-full transition-[width,background-color] duration-300 ease-[var(--ease-kit)]',
                on ? 'w-5 bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)]' : 'w-1.5 bg-[color-mix(in_oklab,var(--foreground)_26%,transparent)] group-hover/dot:bg-[color-mix(in_oklab,var(--foreground)_50%,transparent)]',
              )}
            >
              {on && <span key={`${index}-${running}`} className={cn('absolute inset-0 rounded-full bg-foreground', running && ms ? 'kitfx-car-bar' : '')} style={{ '--car-ms': `${ms ?? 0}ms` } as React.CSSProperties} />}
            </span>
          </button>
        );
      })}
    </div>
  );
}

function Arrows({ prev, next, t, atStart, atEnd }: { prev: () => void; next: () => void; t: CarouselTexts; atStart: boolean; atEnd: boolean }) {
  return (
    <div className="flex items-center gap-2">
      <button type="button" aria-label={t.previous} onClick={prev} disabled={atStart} className={ROUND_BTN}>
        <Icon name="chevron-left" size={18} />
      </button>
      <button type="button" aria-label={t.next} onClick={next} disabled={atEnd} className={ROUND_BTN}>
        <Icon name="chevron-right" size={18} />
      </button>
    </div>
  );
}

/**
 * Slides of pictures with previous and next, dots or thumbnails, swipe and drag, and an optional autoplay with a pause button.
 *   <Carousel variant="hero" label="Season highlights" slides={[{ media: <img … />, title: '…' }]} autoplay={6000} />
 */
export function Carousel({ slides, variant = 'hero', label, heading, autoplay, loop, aspect, startIndex = 0, onIndexChange, labels, className }: CarouselProps) {
  const t = useTexts(TEXTS, labels);
  const id = useId();
  const count = slides.length;
  const wraps = loop ?? (variant === 'hero' || variant === 'centered');
  const center = variant === 'centered';
  const { trackRef, index, edges, goTo, pointer } = useCarousel(count, { loop: wraps, center, startIndex, onIndexChange });
  const rootRef = useRef<HTMLElement>(null);
  const auto = useAutoplay(autoplay, rootRef);
  const ratio = aspect ?? (variant === 'hero' ? '16/9' : variant === 'cards' ? '4/3' : variant === 'thumbnails' ? '3/2' : '4/3');

  // Autoplay steps
  useEffect(() => {
    if (!auto.running || !autoplay) return;
    const timer = window.setTimeout(() => goTo(index + 1), autoplay);
    return () => window.clearTimeout(timer);
  }, [auto.running, autoplay, index, goTo]);

  // Centered: neighbours shrink and dim with their distance from the middle, written straight to the elements
  useEffect(() => {
    if (!center) return;
    const tr = trackRef.current;
    if (!tr) return;
    let raf = 0;
    const paint = () => {
      const mid = tr.scrollLeft + tr.clientWidth / 2;
      const calm = prefersReducedMotion();
      tr.querySelectorAll<HTMLElement>(':scope > [data-slide]').forEach((el) => {
        const d = Math.min(1, Math.abs(el.offsetLeft + el.offsetWidth / 2 - mid) / el.offsetWidth);
        const inner = el.firstElementChild as HTMLElement | null;
        if (!inner) return;
        inner.style.transform = calm ? '' : `scale(${round(1 - d * 0.1, 3)})`;
        inner.style.opacity = String(round(1 - d * 0.45, 3));
      });
    };
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(paint);
    };
    paint();
    tr.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(raf);
      tr.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
    };
  }, [center, trackRef]);

  // Thumbnails: the strip keeps the current one in view without moving the page
  const stripRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const s = stripRef.current;
    const el = s?.querySelectorAll<HTMLElement>('button')[index];
    if (!s || !el) return;
    const x = el.offsetLeft - (s.clientWidth - el.offsetWidth) / 2;
    s.scrollTo({ left: Math.max(0, x), behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  }, [index]);

  const prev = () => goTo(index - 1);
  const next = () => {
    if (!wraps && edges.end) return;
    goTo(index + 1);
  };
  const atStart = !wraps && (index === 0 || edges.start);
  const atEnd = !wraps && edges.end;

  const onKeyDown = (e: React.KeyboardEvent) => {
    const tag = (e.target as HTMLElement).tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA') return;
    if (e.key === 'ArrowRight') {
      e.preventDefault();
      next();
      (e.target as HTMLElement).closest('[data-picker]')?.querySelectorAll<HTMLElement>('button')[Math.min(count - 1, wraps ? (index + 1) % count : index + 1)]?.focus();
    } else if (e.key === 'ArrowLeft') {
      e.preventDefault();
      prev();
      (e.target as HTMLElement).closest('[data-picker]')?.querySelectorAll<HTMLElement>('button')[wraps ? (index - 1 + count) % count : Math.max(0, index - 1)]?.focus();
    }
  };

  const rotation = autoplay ? (
    <button
      type="button"
      data-rotation
      aria-label={auto.playing ? t.stop : t.start}
      onClick={() => auto.setPlaying(!auto.playing)}
      className={ROUND_BTN}
    >
      <Icon name={auto.playing ? 'pause' : 'play'} size={16} />
    </button>
  ) : null;

  const counter = (
    <p className="min-w-[3.25rem] text-[13px] tabular-nums text-muted-foreground" aria-hidden>
      <span className="font-medium text-foreground">{index + 1}</span> / {count}
    </p>
  );

  const slideLabel = (i: number) => `${i + 1} ${t.of} ${count}${slides[i].title ? `, ${slides[i].title}` : ''}`;

  const track = (slideClass: string, render: (s: CarouselSlide, i: number) => React.ReactNode, trackClass?: string) => (
    <div
      ref={trackRef}
      id={`${id}-track`}
      aria-live={auto.running ? 'off' : 'polite'}
      {...pointer}
      className={cn(
        'kit-no-scrollbar relative flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch] motion-safe:scroll-smooth',
        'cursor-grab select-none active:cursor-grabbing',
        trackClass,
      )}
    >
      {slides.map((s, i) => (
        <div key={i} data-slide role="group" aria-roledescription={t.slide} aria-label={slideLabel(i)} className={cn('shrink-0', center ? 'snap-center' : 'snap-start', slideClass)}>
          {render(s, i)}
        </div>
      ))}
    </div>
  );

  let body: React.ReactNode;

  if (variant === 'hero') {
    body = (
      <>
        {track(
          'w-full',
          (s) => (
            <div className="relative">
              <SlideMedia slide={s} aspect={ratio} rounded="rounded-none" className="max-sm:aspect-[4/5]!" />
              {(s.title || s.description) && (
                <div className="dark absolute inset-x-0 bottom-0 flex flex-col items-start gap-2 bg-[linear-gradient(to_top,rgb(0_0_0/0.72),rgb(0_0_0/0.38)_55%,transparent)] px-5 pb-6 pt-24 text-foreground sm:px-8 sm:pb-8 lg:px-10 lg:pb-10">
                  {s.eyebrow && <p className="text-[12px] font-medium uppercase tracking-[0.09em] text-[rgb(255_255_255/0.78)]">{s.eyebrow}</p>}
                  {s.title && <h3 className="max-w-xl text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-white sm:text-h3">{s.title}</h3>}
                  {s.description && <p className="max-w-lg text-pretty text-sm leading-relaxed text-[rgb(255_255_255/0.82)] sm:text-[15px]">{s.description}</p>}
                  {s.href && s.cta && (
                    <a
                      href={s.href}
                      draggable={false}
                      className="relative mt-2 inline-flex h-10 items-center gap-1.5 rounded-lg bg-white px-4 text-sm font-medium text-[oklch(0.2_0.01_264)] outline-none transition-[background-color,scale] duration-200 after:absolute after:-inset-y-0.5 after:inset-x-0 after:content-[''] hover:bg-[oklch(0.93_0.005_264)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-white active:scale-[0.97] max-sm:h-11"
                    >
                      {s.cta}
                      <Icon name="arrow-right" size={15} />
                    </a>
                  )}
                </div>
              )}
            </div>
          ),
          'rounded-2xl border border-border',
        )}
        <div className="mt-3 flex items-center gap-3 sm:mt-4">
          {rotation}
          <div data-picker className="flex min-w-0 flex-1 justify-center sm:justify-start">
            <Dots count={count} index={index} goTo={goTo} t={t} running={auto.running} ms={autoplay} label={label} />
          </div>
          <span className="max-sm:hidden">{counter}</span>
          <Arrows prev={prev} next={next} t={t} atStart={atStart} atEnd={atEnd} />
        </div>
      </>
    );
  } else if (variant === 'cards') {
    body = (
      <>
        <div className="mb-5 flex items-end justify-between gap-4 sm:mb-6">
          <div className="min-w-0">{heading}</div>
          <div className="flex shrink-0 items-center gap-3">
            {rotation}
            <span className="max-sm:hidden">{counter}</span>
            <Arrows prev={prev} next={next} t={t} atStart={atStart} atEnd={atEnd} />
          </div>
        </div>
        {track(
          // The next card always peeks in, it tells the row goes on
          'w-[82%] sm:w-[calc((100%-1rem)/2.25)] lg:w-[calc((100%-2.5rem)/3.3)]',
          (s) => (
            <article className="group/card flex h-full flex-col">
              <SlideMedia slide={s} aspect={ratio} className="transition-[box-shadow] duration-300 group-hover/card:shadow-[var(--shadow-md)]" />
              <div className="flex flex-1 flex-col pt-3.5">
                {s.eyebrow && <p className="text-[12px] font-medium text-subtle-foreground">{s.eyebrow}</p>}
                {s.title && (
                  <h3 className="mt-0.5 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-foreground">
                    {s.href ? (
                      <a href={s.href} draggable={false} className="rounded-sm outline-none after:absolute after:inset-0 after:content-[''] hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring">
                        {s.title}
                      </a>
                    ) : (
                      s.title
                    )}
                  </h3>
                )}
                {s.description && <p className="mt-1 line-clamp-2 text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.description}</p>}
                {s.meta && <p className="mt-auto pt-2.5 text-[13px] font-medium tabular-nums text-foreground">{s.meta}</p>}
              </div>
            </article>
          ),
          'gap-4 lg:gap-5 max-sm:-mx-4 max-sm:px-4 max-sm:scroll-px-4 [&>[data-slide]]:relative',
        )}
        <ScrollLine trackRef={trackRef} />
      </>
    );
  } else if (variant === 'thumbnails') {
    body = (
      <>
        {track('w-full', (s) => <SlideMedia slide={s} aspect={ratio} rounded="rounded-none" />, 'rounded-2xl border border-border')}
        <div className="mt-3 flex items-start justify-between gap-4">
          <div className="min-w-0" aria-live="polite">
            {slides[index]?.title && <p className="truncate text-sm font-medium text-foreground">{slides[index].title}</p>}
            {slides[index]?.description && <p className="mt-0.5 line-clamp-2 text-[13px] text-muted-foreground">{slides[index].description}</p>}
          </div>
          <div className="flex shrink-0 items-center gap-3">
            <span className="max-sm:hidden">{counter}</span>
            <Arrows prev={prev} next={next} t={t} atStart={atStart} atEnd={atEnd} />
          </div>
        </div>
        <div
          ref={stripRef}
          data-picker
          role="group"
          aria-label={`${t.pictures}, ${label}`}
          className={cn('kit-no-scrollbar relative -mx-1 mt-4 flex gap-2 overflow-x-auto px-1 py-1.5', count > 5 && 'max-sm:kit-fade-end', count > 8 && 'kit-fade-end')}
        >
          {slides.map((s, i) => {
            const on = i === index;
            return (
              <button
                key={i}
                type="button"
                aria-label={`${t.goTo} ${i + 1} ${t.of} ${count}${s.title ? `, ${s.title}` : ''}`}
                aria-current={on || undefined}
                onClick={() => goTo(i)}
                className={cn(
                  'relative aspect-[4/3] w-[4.5rem] shrink-0 cursor-pointer overflow-hidden rounded-lg outline-none transition-[opacity,box-shadow] duration-200 sm:w-24',
                  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
                  on ? 'opacity-100 shadow-[0_0_0_2px_var(--background),0_0_0_4px_var(--primary)]' : 'opacity-60 shadow-[0_0_0_1px_var(--border)] hover:opacity-100',
                )}
              >
                <span data-clip-ok className="pointer-events-none absolute inset-0 [&>*]:size-full">
                  {s.thumb ?? s.media}
                </span>
              </button>
            );
          })}
        </div>
      </>
    );
  } else {
    // centered
    body = (
      <>
        {track(
          // Container units: the slide and the side room are shares of the same width, so the slide sits exactly in the middle
          'w-[78cqw] px-1.5 sm:w-[62cqw] sm:px-2.5 lg:w-[48cqw]',
          (s, i) => (
            <figure className="origin-center transition-[opacity] duration-300 will-change-transform">
              <SlideMedia slide={s} aspect={ratio} rounded="rounded-2xl" className="shadow-[var(--shadow-md)]" />
              <figcaption className={cn('mt-4 px-1 text-center transition-opacity duration-300', i === index ? 'opacity-100' : 'opacity-0 max-lg:invisible')}>
                {s.title && <span className="block text-[15px] font-semibold text-foreground">{s.title}</span>}
                {s.description && <span className="mx-auto mt-1 block max-w-md text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.description}</span>}
              </figcaption>
            </figure>
          ),
          // Room on both sides so the first and the last slide can sit in the middle
          'px-[11cqw] sm:px-[19cqw] lg:px-[26cqw]',
        )}
        <div className="mt-4 flex items-center justify-center gap-3">
          {rotation}
          <button type="button" aria-label={t.previous} onClick={prev} className={ROUND_BTN}>
            <Icon name="chevron-left" size={18} />
          </button>
          <div data-picker>
            <Dots count={count} index={index} goTo={goTo} t={t} running={auto.running} ms={autoplay} label={label} />
          </div>
          <button type="button" aria-label={t.next} onClick={next} className={ROUND_BTN}>
            <Icon name="chevron-right" size={18} />
          </button>
        </div>
      </>
    );
  }

  return (
    <section
      ref={rootRef}
      aria-roledescription={t.carousel}
      aria-label={label}
      onKeyDown={onKeyDown}
      onPointerEnter={(e) => e.pointerType === 'mouse' && auto.setHover(true)}
      onPointerLeave={() => auto.setHover(false)}
      onFocus={(e) => {
        // APG: keyboard focus inside stops the rotation for good, the rotation button is the way back
        if (autoplay && (e.target as HTMLElement).matches(':focus-visible') && !(e.target as HTMLElement).closest('[data-rotation]')) auto.setPlaying(false);
      }}
      className={cn('@container relative w-full min-w-0', !auto.running && 'kitfx-car-paused', className)}
    >
      <style href="kitfx-car" precedence="default">
        {STYLES}
      </style>
      {body}
    </section>
  );
}

/** A thin line under a card row: how much of the row is in view and where */
function ScrollLine({ trackRef }: { trackRef: React.RefObject<HTMLDivElement | null> }) {
  const [state, setState] = useState({ w: 30, x: 0 });
  useEffect(() => {
    const t = trackRef.current;
    if (!t) return;
    let raf = 0;
    const update = () => {
      const w = t.scrollWidth > 0 ? Math.min(100, (t.clientWidth / t.scrollWidth) * 100) : 100;
      const max = t.scrollWidth - t.clientWidth;
      const x = max > 0 ? (t.scrollLeft / max) * (100 - w) : 0;
      setState({ w: round(w, 1), x: round(x, 1) });
    };
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(update);
    };
    update();
    t.addEventListener('scroll', onScroll, { passive: true });
    const ro = new ResizeObserver(onScroll);
    ro.observe(t);
    return () => {
      cancelAnimationFrame(raf);
      t.removeEventListener('scroll', onScroll);
      ro.disconnect();
    };
  }, [trackRef]);
  return (
    <div aria-hidden className="relative mt-5 h-0.5 overflow-hidden rounded-full bg-track">
      <span className="absolute inset-y-0 rounded-full bg-foreground" style={{ left: `${state.x}%`, width: `${state.w}%` }} />
    </div>
  );
}

Frequent questions

How do I install Carousel?

With aiw add @ai-website/carousel. The command puts components/ui/carousel.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Carousel have?

4 variants, Hero with autoplay, Cards with a peek, Gallery with thumbnails and Centered row. You pick one with the variant prop, for example variant="cards".

Can I translate the texts of Carousel?

Yes. Carousel carries 9 texts of its own in English and German, such as “carousel”, “slide” and “of”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Carousel?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Carousel with aiw_components and install it with aiw_add @ai-website/carousel. Name the variant in your prompt, for example variant="hero", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard