Reviews Carousel
betaTestimonials 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.
Installation
pnpm dlx @dowel-ui/cli add reviews-carouselInstalls 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.
Props
ReviewsCarousel
| Prop | Type | Default |
|---|---|---|
reviews (required) | Review[] | — |
autoPlayAdvances on a timer, with a stop/start control; never starts by itself under reduced motion. | boolean | false |
autoPlayIntervalMilliseconds per review while playing. | number | 5000 |
defaultIndexInitial active review when uncontrolled. | number | — |
indexControlled active review. | number | — |
labelsVisible and accessible text, for localisation. | Partial<ReviewsCarouselLabels> | — |
loopWrap from the last review to the first. The source stops at the ends. | boolean | false |
onIndexChange | (index: number) => void | — |
showIndicators | boolean | true |
showNavigation | boolean | true |
Plus every attribute of <section> except children.
Quality
10/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
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.
"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"
>
“
</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 };
"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>
);
}