Reviews Carousel

beta

Testimonials stacked in depth — the active review in front, the next ones receding behind — stepped through with buttons, indicators or arrow keys.

It has completely changed how I build interfaces. The motion is calm, the components are well designed, and the documentation is excellent.

Sarah JohnsonFrontend Developer

Installation

Terminal
pnpm dlx @dowel-ui/cli add reviews-carousel

Installs button as well, because this component imports it.

npm packages installed: class-variance-authority.

Accessibility

Follows the APG carousel pattern: a section with aria-roledescription="carousel", each review a labelled group with aria-roledescription="slide", and only the active one exposed (the rest are aria-hidden and inert). Previous/next buttons and indicator buttons are named, the current indicator carries aria-current, and the end buttons use aria-disabled so focus is never dropped. Arrow keys (mirrored in RTL), Home and End work while focus is inside. `autoPlay` adds a stop/start control, pauses on hover and focus, turns the live region off while rotating, and does not start by itself under reduced motion.

ReviewsCarousel

PropTypeDefault
reviews (required)Review[]
autoPlay

Advances on a timer, with a stop/start control; never starts by itself under reduced motion.

booleanfalse
autoPlayInterval

Milliseconds per review while playing.

number5000
defaultIndex

Initial active review when uncontrolled.

number
index

Controlled active review.

number
labels

Visible and accessible text, for localisation.

Partial<ReviewsCarouselLabels>
loop

Wrap from the last review to the first. The source stops at the ends.

booleanfalse
onIndexChange(index: number) => void
showIndicatorsbooleantrue
showNavigationbooleantrue

Plus every attribute of <section> except children.

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add reviews-carousel writes into your project, with imports rewritten to your own path alias.

ui/reviews-carousel.tsx
"use client";

// Ported from SmoothUI Reviews Carousel (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { cva } from "class-variance-authority";
import type { ComponentPropsWithRef, CSSProperties, KeyboardEvent, ReactNode } from "react";

import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import {
  CarouselButton,
  RotationButton,
  chain,
  handleCarouselKey,
  useAutoRotate,
  useCarouselIndex,
} from "./carousel-controls";

/*
 * A stack of cards: the active review in front, the next three receding
 * upward behind it, the ones already read fading and blurring out below.
 *
 * The source animates each card with a motion spring. Nothing here carries a
 * gesture's velocity — every change comes from a button, a key or a timer — so
 * each card is a CSS transition between computed positions, and the global
 * reduced-motion rule makes the change instant.
 */

/** Vertical step between stacked cards, and how many stay visible behind. */
const STEP_REM = 1.875;
const VISIBLE_BEHIND = 3;

const reviewsCarouselVariants = cva(
  cn(
    "col-start-1 row-start-1 w-[calc(100%-2rem)] max-w-xl rounded-2xl border border-border",
    "bg-card/80 p-4 text-card-foreground shadow-lg backdrop-blur-md sm:p-6",
    "transition-[translate,scale,opacity,filter] duration-[var(--duration-slow)] ease-[var(--ease-out-quint)]",
    "pointer-events-none data-[state=active]:pointer-events-auto",
  ),
);

export interface Review {
  id: string | number;
  /** The quotation. */
  body: ReactNode;
  author: ReactNode;
  /** A role or affiliation under the author's name. */
  title?: ReactNode;
  /** An avatar node, rendered before the author. */
  avatar?: ReactNode;
}

export interface ReviewsCarouselLabels {
  previous: string;
  next: string;
  stop: string;
  start: string;
  /** Names each slide for its position. */
  slide: (index: number, count: number) => string;
  /** Names each indicator button. */
  indicator: (index: number, count: number) => string;
}

const DEFAULT_LABELS: ReviewsCarouselLabels = {
  previous: "Previous review",
  next: "Next review",
  stop: "Stop automatic slide show",
  start: "Start automatic slide show",
  slide: (index, count) => `${String(index + 1)} of ${String(count)}`,
  indicator: (index) => `Review ${String(index + 1)}`,
};

export interface ReviewsCarouselProps extends Omit<
  ComponentPropsWithRef<"section">,
  "children"
> {
  reviews: Review[];
  /** Controlled active review. */
  index?: number;
  /** Initial active review when uncontrolled. */
  defaultIndex?: number;
  onIndexChange?: (index: number) => void;
  /** Wrap from the last review to the first. The source stops at the ends. */
  loop?: boolean;
  showNavigation?: boolean;
  showIndicators?: boolean;
  /** Advances on a timer, with a stop/start control; never starts by itself under reduced motion. */
  autoPlay?: boolean;
  /** Milliseconds per review while playing. */
  autoPlayInterval?: number;
  /** Visible and accessible text, for localisation. */
  labels?: Partial<ReviewsCarouselLabels>;
}

/** Where a card sits relative to the active one. */
function placement(offset: number): CSSProperties {
  if (offset < 0) {
    return {
      translate: `0 ${String(-offset * STEP_REM)}rem`,
      scale: "1.08",
      opacity: 0,
      filter: "blur(2px)",
    };
  }
  const depth = Math.min(offset, VISIBLE_BEHIND);
  return {
    translate: `0 ${String(-depth * STEP_REM)}rem`,
    scale: String(Math.max(0.08, 1 - offset * 0.08)),
    opacity: offset > VISIBLE_BEHIND ? 0 : 1,
  };
}

/** Testimonials stacked in depth, stepped through with buttons, indicators or arrow keys. */
export function ReviewsCarousel({
  className,
  reviews,
  index: indexProp,
  defaultIndex,
  onIndexChange,
  loop = false,
  showNavigation = true,
  showIndicators = true,
  autoPlay = false,
  autoPlayInterval = 5000,
  labels: labelsProp,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ReviewsCarouselProps) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp };
  const count = reviews.length;
  const carousel = useCarouselIndex({
    count,
    index: indexProp,
    defaultIndex,
    onIndexChange,
    loop,
  });
  const { index, goTo } = carousel;
  const rotation = useAutoRotate({
    autoPlay,
    interval: autoPlayInterval,
    count,
    resetKey: index,
    // A timer always wraps, even when the buttons stop at the ends.
    advance: () => {
      goTo(index >= count - 1 ? 0 : index + 1);
    },
  });

  if (count === 0) return null;

  const hasControls = showNavigation || showIndicators || autoPlay;

  function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
    handleCarouselKey(event, {
      next: carousel.next,
      previous: carousel.previous,
      first: () => {
        goTo(0);
      },
      last: () => {
        goTo(count - 1);
      },
    });
  }

  return (
    <section
      data-slot="reviews-carousel"
      aria-roledescription="carousel"
      aria-label={props["aria-labelledby"] ? undefined : "Reviews"}
      className={cn("relative mx-auto flex h-80 w-full max-w-4xl flex-col", className)}
      onPointerEnter={chain(rotation.rootProps.onPointerEnter, onPointerEnter)}
      onPointerLeave={chain(rotation.rootProps.onPointerLeave, onPointerLeave)}
      onFocus={chain(rotation.rootProps.onFocus, onFocus)}
      onBlur={chain(rotation.rootProps.onBlur, onBlur)}
      {...props}
    >
      <div
        data-slot="reviews-carousel-viewport"
        aria-live={rotation.rotating ? "off" : "polite"}
        className="relative grid min-h-0 flex-1 place-items-center py-8"
      >
        {reviews.map((review, position) => {
          const offset = position - index;
          const active = offset === 0;
          return (
            <div
              key={review.id}
              role="group"
              aria-roledescription="slide"
              aria-label={labels.slide(position, count)}
              aria-hidden={active ? undefined : true}
              inert={active ? undefined : true}
              data-slot="reviews-carousel-slide"
              data-state={active ? "active" : offset < 0 ? "past" : "upcoming"}
              className={reviewsCarouselVariants()}
              style={{ ...placement(offset), zIndex: count - position }}
            >
              <figure>
                <blockquote className="relative">
                  <span
                    aria-hidden="true"
                    className="absolute -start-2 -top-1 text-4xl leading-none text-foreground/10"
                  >
                    &ldquo;
                  </span>
                  <p className="relative text-sm leading-relaxed text-foreground/80">
                    {review.body}
                  </p>
                </blockquote>
                <figcaption className="mt-4 flex items-center gap-2 border-t border-border pt-4">
                  {review.avatar}
                  <span className="flex flex-col">
                    <span className="text-xs font-semibold text-foreground">
                      {review.author}
                    </span>
                    {review.title != null ? (
                      <span className="text-xs text-muted-foreground">{review.title}</span>
                    ) : null}
                  </span>
                </figcaption>
              </figure>
            </div>
          );
        })}
      </div>

      {hasControls ? (
        <div
          data-slot="reviews-carousel-controls"
          className="relative z-10 flex items-center justify-center gap-2 pb-4"
        >
          {autoPlay ? (
            <RotationButton
              enabled={rotation.enabled}
              onToggle={rotation.toggle}
              onKeyDown={handleKeyDown}
              stopLabel={labels.stop}
              startLabel={labels.start}
            />
          ) : null}
          {showNavigation ? (
            <CarouselButton
              direction="previous"
              aria-label={labels.previous}
              // aria-disabled rather than disabled: a button that disables itself
              // under the pointer or the keyboard would drop focus to the page.
              aria-disabled={!carousel.canPrevious || undefined}
              onClick={carousel.previous}
              onKeyDown={handleKeyDown}
            />
          ) : null}
          {showIndicators ? (
            <div data-slot="reviews-carousel-indicators" className="flex items-center">
              {reviews.map((review, position) => {
                const current = position === index;
                return (
                  <button
                    key={review.id}
                    type="button"
                    aria-label={labels.indicator(position, count)}
                    aria-current={current ? "true" : undefined}
                    className={cn("group flex h-6 items-center rounded-full px-1", focusRing)}
                    onClick={() => {
                      goTo(position);
                    }}
                    onKeyDown={handleKeyDown}
                  >
                    <span
                      className={cn(
                        "block h-2 rounded-full transition-[width,background-color] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]",
                        current
                          ? "w-8 bg-primary"
                          : "w-2 bg-primary/30 group-hover:bg-primary/50",
                      )}
                    />
                  </button>
                );
              })}
            </div>
          ) : null}
          {showNavigation ? (
            <CarouselButton
              direction="next"
              aria-label={labels.next}
              aria-disabled={!carousel.canNext || undefined}
              onClick={carousel.next}
              onKeyDown={handleKeyDown}
            />
          ) : null}
        </div>
      ) : null}
    </section>
  );
}

export { reviewsCarouselVariants };
ui/carousel-controls.tsx
"use client";

// Original design (pattern inspired by the WAI-ARIA APG Carousel pattern).
import {
  useCallback,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type FocusEvent,
  type KeyboardEvent,
} from "react";

import { Button, type ButtonProps } from "@/components/button";
import { mirrorForDirection } from "@/lib/styles";
import { cn } from "@/lib/utils";

/*
 * The state, keyboard and rotation rules every Dowel carousel shares, kept in
 * one file that each carousel carries so it installs on its own.
 *
 * - The index is controllable (`index` / `defaultIndex` / `onIndexChange`).
 * - Arrow keys follow the reading direction: in a right-to-left document the
 *   right arrow goes back, because "next" lies toward the inline end.
 * - Automatic rotation follows the APG: it has a stop/start control, pauses
 *   while the pointer is over the carousel or focus is inside it, and never
 *   starts by itself under reduced motion.
 */

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";

export function prefersReducedMotion(): boolean {
  return (
    typeof window !== "undefined" &&
    typeof window.matchMedia === "function" &&
    window.matchMedia(REDUCED_MOTION).matches
  );
}

function subscribeReducedMotion(onChange: () => void): () => void {
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
    return () => {};
  }
  const query = window.matchMedia(REDUCED_MOTION);
  query.addEventListener("change", onChange);
  return () => {
    query.removeEventListener("change", onChange);
  };
}

/** Whether the reader has asked for less motion, kept live. */
export function usePrefersReducedMotion(): boolean {
  return useSyncExternalStore(subscribeReducedMotion, prefersReducedMotion, () => false);
}

/** `index` wrapped into `[0, count)`. */
export function wrapIndex(index: number, count: number): number {
  if (count <= 0) return 0;
  return ((index % count) + count) % count;
}

export interface CarouselIndexOptions {
  count: number;
  index?: number;
  defaultIndex?: number;
  onIndexChange?: (index: number) => void;
  /** Wrap from the last slide to the first. Otherwise the ends are hard stops. */
  loop: boolean;
}

/** A controllable slide index that wraps or clamps. */
export function useCarouselIndex({
  count,
  index: indexProp,
  defaultIndex = 0,
  onIndexChange,
  loop,
}: CarouselIndexOptions) {
  const [uncontrolled, setUncontrolled] = useState(defaultIndex);
  const controlled = indexProp !== undefined;
  const raw = controlled ? indexProp : uncontrolled;
  const last = Math.max(0, count - 1);
  const index = loop ? wrapIndex(raw, count) : Math.min(Math.max(raw, 0), last);

  const goTo = useCallback(
    (target: number) => {
      const next = loop ? wrapIndex(target, count) : Math.min(Math.max(target, 0), last);
      if (next === index) return;
      if (!controlled) setUncontrolled(next);
      onIndexChange?.(next);
    },
    [loop, count, last, index, controlled, onIndexChange],
  );

  return {
    index,
    goTo,
    next: () => {
      goTo(index + 1);
    },
    previous: () => {
      goTo(index - 1);
    },
    canNext: count > 1 && (loop || index < last),
    canPrevious: count > 1 && (loop || index > 0),
  };
}

function isRightToLeft(element: Element): boolean {
  return element.closest("[dir]")?.getAttribute("dir") === "rtl";
}

export interface CarouselKeys {
  next: () => void;
  previous: () => void;
  first: () => void;
  last: () => void;
}

/**
 * Arrow keys (mirrored in RTL), Home and End, for the carousel's own controls.
 * It is bound to the buttons rather than the region, so arrow keys pressed
 * inside a slide's content — a text field, a slider — are never taken over.
 * Returns true when it handled the key.
 */
export function handleCarouselKey(
  event: KeyboardEvent<HTMLElement>,
  keys: CarouselKeys,
): boolean {
  if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return false;
  const rtl = isRightToLeft(event.currentTarget);
  const action = {
    ArrowRight: rtl ? keys.previous : keys.next,
    ArrowLeft: rtl ? keys.next : keys.previous,
    Home: keys.first,
    End: keys.last,
  }[event.key];
  if (!action) return false;
  event.preventDefault();
  action();
  return true;
}

export interface AutoRotateOptions {
  autoPlay: boolean;
  /** Milliseconds per slide. */
  interval: number;
  count: number;
  /** Changes whenever the slide changes, so a manual move restarts the clock. */
  resetKey: number;
  advance: () => void;
}

/** APG automatic rotation: a stop/start control, and pauses for hover and focus. */
export function useAutoRotate({
  autoPlay,
  interval,
  count,
  resetKey,
  advance,
}: AutoRotateOptions) {
  const reduced = usePrefersReducedMotion();
  const [playing, setPlaying] = useState(autoPlay);
  // Under reduced motion rotation only runs once the reader has asked for it.
  const [chosen, setChosen] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const advanceRef = useRef(advance);

  useEffect(() => {
    advanceRef.current = advance;
  });

  const enabled = playing && (chosen || !reduced);
  const rotating = enabled && !hovered && !focused && count > 1;

  useEffect(() => {
    if (!rotating) return;
    const timer = setInterval(() => {
      advanceRef.current();
    }, interval);
    return () => {
      clearInterval(timer);
    };
  }, [rotating, interval, resetKey]);

  return {
    /** Whether rotation is switched on (it may still be paused by hover or focus). */
    enabled,
    /** Whether slides are changing by themselves right now. */
    rotating,
    toggle: () => {
      setChosen(true);
      setPlaying(!enabled);
    },
    rootProps: {
      onPointerEnter: () => {
        setHovered(true);
      },
      onPointerLeave: () => {
        setHovered(false);
      },
      // Focus on the rotation control itself does not pause: that is where a
      // keyboard user sits to start rotation and watch it run.
      onFocus: (event: FocusEvent<HTMLElement>) => {
        const target = event.target as Element;
        setFocused(target.closest("[data-slot=carousel-rotation]") === null);
      },
      onBlur: (event: FocusEvent<HTMLElement>) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      },
    },
  };
}

/** Chains a consumer's handler before ours. */
export function chain<E>(ours: (event: E) => void, theirs?: (event: E) => void) {
  return (event: E) => {
    theirs?.(event);
    ours(event);
  };
}

export interface CarouselButtonProps extends Omit<ButtonProps, "children"> {
  direction: "previous" | "next";
}

/** A round previous/next button whose chevron mirrors in RTL. */
export function CarouselButton({ direction, className, ...props }: CarouselButtonProps) {
  return (
    <Button
      type="button"
      variant="outline"
      size="icon-sm"
      data-slot={`carousel-${direction}`}
      className={cn("size-8 rounded-full bg-background/70 backdrop-blur-sm", className)}
      {...props}
    >
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className={cn("size-4", mirrorForDirection)}
      >
        <path d={direction === "previous" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"} />
      </svg>
    </Button>
  );
}

export interface RotationButtonProps extends Omit<ButtonProps, "children" | "onClick"> {
  enabled: boolean;
  onToggle: () => void;
  stopLabel: string;
  startLabel: string;
}

/** The APG rotation control. Its name says what pressing it will do. */
export function RotationButton({
  enabled,
  onToggle,
  stopLabel,
  startLabel,
  className,
  ...props
}: RotationButtonProps) {
  return (
    <Button
      type="button"
      variant="outline"
      size="icon-sm"
      data-slot="carousel-rotation"
      aria-label={enabled ? stopLabel : startLabel}
      className={cn("size-8 rounded-full bg-background/70 backdrop-blur-sm", className)}
      onClick={onToggle}
      {...props}
    >
      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="size-3.5">
        {enabled ? (
          <path d="M7 5h3v14H7zM14 5h3v14h-3z" />
        ) : (
          <path d="M8 5.5v13a.5.5 0 0 0 .76.43l10.4-6.5a.5.5 0 0 0 0-.86L8.76 5.07A.5.5 0 0 0 8 5.5z" />
        )}
      </svg>
    </Button>
  );
}