ComponentsCarousel
Carousel
Let users browse a series of cards or images.
On this page
Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry files- Motion for React
(motion)Animations, transitions, and reduced-motion handling.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@dethink/components";These are compositional patterns, not separate components. Navigate with the controls, dots, or Arrow/Home/End keys after focusing a carousel. Every example uses the same Carousel primitives and its tokenized staging system.
Feature highlights
Ribbon staging alternates card angles and heights. Change the presentation to compare all six modes without losing your place.
Product storytelling
Good ideas never stand still.
Give every capability a focused moment, then let the surrounding cards suggest the bigger system behind it.
Show sourceexamples/carousel/feature-highlights.tsx
"use client";
import { useId, useState } from "react";
import {
ChartNoAxesCombined,
Layers3,
ShieldCheck,
Sparkles,
} from "lucide-react";
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
useCarousel,
type CarouselStaging,
} from "@dethink/components";
const features = [
{
eyebrow: "Plan with context",
icon: Layers3,
metric: "8 active initiatives",
summary:
"Bring strategy, customer evidence, and the next decision into the same view.",
title: "A roadmap that keeps its signal",
},
{
eyebrow: "Ship with focus",
icon: Sparkles,
metric: "42% less status churn",
summary:
"Turn updates into shared momentum with a clear owner, timeline, and outcome.",
title: "Progress everyone can trust",
},
{
eyebrow: "Learn in the flow",
icon: ChartNoAxesCombined,
metric: "Weekly decision review",
summary:
"Connect the moments that changed the metric with the work that made them happen.",
title: "From signal to a sharper next move",
},
{
eyebrow: "Scale with care",
icon: ShieldCheck,
metric: "Built for complex teams",
summary:
"Give every handoff lasting context while keeping roles, policies, and ownership explicit.",
title: "A calmer operating system",
},
] as const;
export function CarouselFeatureHighlights() {
const [staging, setStaging] = useState<CarouselStaging>("ribbon");
return (
<div className="space-y-7">
<div className="mx-auto max-w-2xl space-y-2 text-center">
<p className="text-primary text-xs font-semibold tracking-[0.16em] uppercase">
Product storytelling
</p>
<h3 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Good ideas never stand still.
</h3>
<p className="text-muted-foreground text-sm leading-6">
Give every capability a focused moment, then let the surrounding cards
suggest the bigger system behind it.
</p>
</div>
<CarouselModeControls value={staging} onChange={setStaging} />
<Carousel
aria-label="Product capabilities"
defaultIndex={1}
staging={staging}
className="[&_[data-active=true]_article]:border-primary/60 mx-auto max-w-5xl"
>
<CarouselContent>
{features.map((feature) => {
const Icon = feature.icon;
return (
<CarouselItem key={feature.title}>
<article className="border-border bg-background shadow-foreground/5 flex min-h-[23rem] w-full flex-col rounded-2xl border p-5 shadow-xl sm:p-6">
<div>
<div className="bg-primary/10 text-primary flex size-12 items-center justify-center rounded-xl">
<Icon aria-hidden="true" className="size-5" />
</div>
<p className="text-primary mt-6 text-xs font-semibold tracking-[0.12em] uppercase">
{feature.eyebrow}
</p>
</div>
<div className="flex flex-1 flex-col pt-4">
<h4 className="font-heading text-xl font-semibold tracking-tight">
{feature.title}
</h4>
<p className="text-muted-foreground mt-3 mb-7 text-sm leading-6">
{feature.summary}
</p>
<p className="text-primary mt-auto border-t pt-4 text-xs font-semibold">
{feature.metric}
</p>
</div>
</article>
</CarouselItem>
);
})}
</CarouselContent>
<div className="mt-7 flex items-center justify-center gap-3">
<CarouselPrevious size="lg" />
<CarouselDots label={(index) => `Show ${features[index].title}`} />
<CarouselNext size="lg" />
</div>
<CarouselPosition />
</Carousel>
</div>
);
}
function CarouselModeControls({
value,
onChange,
}: {
value: CarouselStaging;
onChange: (value: CarouselStaging) => void;
}) {
const id = useId();
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<label htmlFor={id} className="text-muted-foreground text-sm">
Presentation
</label>
<select
id={id}
value={value}
onChange={(event) => onChange(event.target.value as CarouselStaging)}
className="border-border bg-background text-foreground focus-visible:outline-ring min-h-11 rounded-lg border px-3 text-sm focus-visible:outline-2"
>
<option value="fan">Fanned deck</option>
<option value="arc">Curved gallery</option>
<option value="ribbon">Kinetic ribbon</option>
<option value="flat">Flat</option>
<option value="tilt">Tilt</option>
<option value="floor">Floor</option>
</select>
</div>
);
}
function CarouselPosition() {
const { index, count } = useCarousel();
return (
<p
className="text-muted-foreground mt-3 text-center text-xs tabular-nums"
aria-hidden="true"
>
{String(index + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
</p>
);
}Editorial image gallery
Arc staging curves the images toward the center while the selected caption and navigation remain level and readable.
Horizon collection
A new angle on every space.
Rain room
Cloud forest · Botanical
Show sourceexamples/carousel/image-gallery.tsx
"use client";
import { useId, useState } from "react";
import Image from "next/image";
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
useCarousel,
type CarouselStaging,
} from "@dethink/components";
const collection = [
{
alt: "Pale stone coastal retreat with an olive tree facing a deep blue sea.",
category: "Architecture",
image: "/components/carousel/coastal-retreat.png",
location: "Alentejo coast",
title: "Limestone edge",
},
{
alt: "Sculptural glasshouse surrounded by lush plants and misty greenery.",
category: "Botanical",
image: "/components/carousel/glasshouse.png",
location: "Cloud forest",
title: "Rain room",
},
{
alt: "Misty alpine lake, wooden dock, wild grass, and dark mountain peaks at dawn.",
category: "Landscape",
image: "/components/carousel/alpine-lake.png",
location: "Julian Alps",
title: "Quiet water",
},
{
alt: "Warm reading room with a cream lounge chair and walnut bookcase in afternoon light.",
category: "Interiors",
image: "/components/carousel/reading-room.png",
location: "Copenhagen",
title: "A place to pause",
},
] as const;
export function CarouselImageGallery() {
const [index, setIndex] = useState(1);
const [staging, setStaging] = useState<CarouselStaging>("arc");
const selected = collection[index];
return (
<div className="space-y-7">
<div className="text-center">
<p className="text-primary text-xs font-semibold tracking-[0.16em] uppercase">
Horizon collection
</p>
<h3 className="font-heading mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
A new angle on every space.
</h3>
</div>
<CarouselModeControls value={staging} onChange={setStaging} />
<Carousel
aria-label="Horizon collection"
staging={staging}
index={index}
onIndexChange={setIndex}
className="mx-auto max-w-5xl"
>
<CarouselContent>
{collection.map((entry, itemIndex) => (
<CarouselItem key={entry.title}>
<figure className="border-border bg-background shadow-foreground/10 relative aspect-[3/4] w-full overflow-hidden rounded-2xl border shadow-xl">
<Image
alt={entry.alt}
className="size-full object-cover"
decoding="async"
fill
priority={itemIndex === 1}
sizes="(max-width: 640px) 84vw, 384px"
src={entry.image}
/>
</figure>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-3 text-center" aria-live="polite" aria-atomic="true">
<h4 className="font-heading text-2xl font-semibold tracking-tight">
{selected.title}
</h4>
<p className="text-muted-foreground mt-2 text-sm">
{selected.location} · {selected.category}
</p>
</div>
<div className="mt-5 flex items-center justify-center gap-3">
<CarouselPrevious size="lg" />
<CarouselDots
label={(itemIndex) => `Show ${collection[itemIndex].title}`}
/>
<CarouselNext size="lg" />
</div>
<CarouselPosition />
</Carousel>
</div>
);
}
function CarouselModeControls({
value,
onChange,
}: {
value: CarouselStaging;
onChange: (value: CarouselStaging) => void;
}) {
const id = useId();
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<label htmlFor={id} className="text-muted-foreground text-sm">
Presentation
</label>
<select
id={id}
value={value}
onChange={(event) => onChange(event.target.value as CarouselStaging)}
className="border-border bg-background text-foreground focus-visible:outline-ring min-h-11 rounded-lg border px-3 text-sm focus-visible:outline-2"
>
<option value="fan">Fanned deck</option>
<option value="arc">Curved gallery</option>
<option value="ribbon">Kinetic ribbon</option>
<option value="flat">Flat</option>
<option value="tilt">Tilt</option>
<option value="floor">Floor</option>
</select>
</div>
);
}
function CarouselPosition() {
const { index, count } = useCarousel();
return (
<p
className="text-muted-foreground mt-3 text-center text-xs tabular-nums"
aria-hidden="true"
>
{String(index + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
</p>
);
}Plan explorer
A controlled carousel keeps an external purchase action synchronized with a compact, touch-friendly plan comparison.
Plan explorer
A price conversation with room to breathe.
Billed monthly · cancel anytime
Growth is in focus
Show sourceexamples/carousel/pricing.tsx
"use client";
import { useState } from "react";
import { Check } from "lucide-react";
import {
Button,
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@dethink/components";
const plans = [
{
description: "For a small crew finding its rhythm.",
features: [
"Up to 5 collaborators",
"Unlimited projects",
"Weekly snapshots",
],
name: "Starter",
price: "$0",
},
{
description: "For the team connecting strategy to delivery.",
features: [
"Unlimited collaborators",
"Goals and roadmaps",
"Priority support",
],
name: "Growth",
price: "$24",
},
{
description: "For an organization ready to standardize work.",
features: [
"Advanced permissions",
"SAML single sign-on",
"Success partner",
],
name: "Scale",
price: "Custom",
},
] as const;
export function CarouselPricing() {
const [index, setIndex] = useState(1);
const selectedPlan = plans[index];
return (
<div className="space-y-7">
<div className="flex flex-col gap-3 border-b pb-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-primary text-xs font-semibold tracking-[0.16em] uppercase">
Plan explorer
</p>
<h3 className="font-heading mt-1 text-2xl font-semibold tracking-tight">
A price conversation with room to breathe.
</h3>
</div>
<p className="text-muted-foreground text-sm">
Billed monthly · cancel anytime
</p>
</div>
<Carousel
aria-label="Subscription plans"
index={index}
intensity="standard"
onIndexChange={setIndex}
staging="tilt"
className="mx-auto max-w-5xl"
>
<CarouselContent>
{plans.map((plan) => (
<CarouselItem key={plan.name}>
<section className="border-border bg-background shadow-foreground/5 flex min-h-[27rem] w-full flex-col rounded-2xl border p-5 shadow-xl sm:p-6">
<div className="flex min-h-7 items-center justify-between gap-3">
<p className="font-heading text-lg font-semibold">
{plan.name}
</p>
{plan.name === "Growth" ? (
<span className="bg-primary text-primary-foreground rounded-full px-2.5 py-1 text-[0.65rem] font-semibold tracking-wide uppercase">
Most popular
</span>
) : null}
</div>
<p className="text-muted-foreground mt-3 min-h-12 text-sm leading-6">
{plan.description}
</p>
<p className="font-heading mt-6 text-4xl font-semibold tracking-tight">
{plan.price}
{plan.price.startsWith("$") ? (
<span className="text-muted-foreground ml-1 text-sm font-normal">
/ month
</span>
) : null}
</p>
<ul className="my-7 space-y-3 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<Check
aria-hidden="true"
className="text-success mt-0.5 size-4 shrink-0"
/>
<span>{feature}</span>
</li>
))}
</ul>
<p className="text-muted-foreground mt-auto border-t pt-4 text-xs">
Use the controls, dots, or a swipe to compare.
</p>
</section>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-7 flex items-center justify-center gap-3">
<CarouselPrevious />
<CarouselDots
label={(planIndex) => `Show ${plans[planIndex].name} plan`}
/>
<CarouselNext />
</div>
</Carousel>
<div className="border-border bg-muted/40 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm" aria-live="polite">
<span className="font-medium">{selectedPlan.name}</span>
<span className="text-muted-foreground"> is in focus</span>
</p>
<Button size="sm">Continue with {selectedPlan.name}</Button>
</div>
</div>
);
}Customer testimonials
Fan staging gives each quote an angled, opaque surface. Neighboring cards soften while the active story stays crisp, with gentler angles in narrow containers.
Customer voice
Let the proof take the stage.
Show sourceexamples/carousel/testimonials.tsx
"use client";
import { Quote } from "lucide-react";
import { useId, useState } from "react";
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
useCarousel,
type CarouselStaging,
} from "@dethink/components";
const testimonials = [
{
initials: "AL",
name: "Avery Lin",
quote:
"The carousel gave our case studies a real sense of pace. Every customer story feels focused, but never isolated from the rest.",
role: "Design Director, Northstar",
tone: "bg-primary text-primary-foreground",
},
{
initials: "JM",
name: "Jordan Mendez",
quote:
"We now show the evidence behind a release without burying someone in tabs, accordions, and a dozen tiny cards.",
role: "Product Lead, Meridian",
tone: "bg-info text-info-foreground",
},
{
initials: "SP",
name: "Samira Patel",
quote:
"It has the energy of a presentation, with the accessibility and control model our application teams expect from a real component.",
role: "Staff Engineer, Fieldwork",
tone: "bg-success text-success-foreground",
},
] as const;
export function CarouselTestimonials() {
const [staging, setStaging] = useState<CarouselStaging>("fan");
return (
<div className="space-y-7">
<div className="mx-auto max-w-xl space-y-2 text-center">
<p className="text-primary text-xs font-semibold tracking-[0.16em] uppercase">
Customer voice
</p>
<h3 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Let the proof take the stage.
</h3>
</div>
<CarouselModeControls value={staging} onChange={setStaging} />
<Carousel
aria-label="Customer testimonials"
defaultIndex={1}
staging={staging}
className="[&_[data-active=true]_figure]:border-primary/60 mx-auto max-w-5xl"
>
<CarouselContent>
{testimonials.map((testimonial) => (
<CarouselItem key={testimonial.name}>
<figure className="border-border bg-background shadow-foreground/5 flex min-h-[23rem] w-full flex-col rounded-2xl border p-5 shadow-xl sm:p-7">
<div className="flex items-center justify-between">
<Quote aria-hidden="true" className="text-primary size-6" />
<span className="text-muted-foreground text-xs font-medium">
Customer story
</span>
</div>
<blockquote className="font-heading my-7 text-lg leading-relaxed font-medium tracking-tight sm:text-xl">
“{testimonial.quote}”
</blockquote>
<figcaption className="mt-auto flex items-center gap-3 border-t pt-5">
<span
aria-hidden="true"
className={`${testimonial.tone} flex size-10 items-center justify-center rounded-full text-xs font-bold`}
>
{testimonial.initials}
</span>
<span>
<span className="block text-sm font-semibold">
{testimonial.name}
</span>
<span className="text-muted-foreground block text-xs">
{testimonial.role}
</span>
</span>
</figcaption>
</figure>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-8 flex items-center justify-center gap-3">
<CarouselPrevious size="lg" />
<CarouselDots
label={(index) =>
`Show testimonial from ${testimonials[index].name}`
}
/>
<CarouselNext size="lg" />
</div>
<CarouselPosition />
</Carousel>
</div>
);
}
function CarouselModeControls({
value,
onChange,
}: {
value: CarouselStaging;
onChange: (value: CarouselStaging) => void;
}) {
const id = useId();
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<label htmlFor={id} className="text-muted-foreground text-sm">
Presentation
</label>
<select
id={id}
value={value}
onChange={(event) => onChange(event.target.value as CarouselStaging)}
className="border-border bg-background text-foreground focus-visible:outline-ring min-h-11 rounded-lg border px-3 text-sm focus-visible:outline-2"
>
<option value="fan">Fanned deck</option>
<option value="arc">Curved gallery</option>
<option value="ribbon">Kinetic ribbon</option>
<option value="flat">Flat</option>
<option value="tilt">Tilt</option>
<option value="floor">Floor</option>
</select>
</div>
);
}
function CarouselPosition() {
const { index, count } = useCarousel();
return (
<p
className="text-muted-foreground mt-3 text-center text-xs tabular-nums"
aria-hidden="true"
>
{String(index + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
</p>
);
}Carousel provides the landmark, slide semantics, focus safety, and motion fallback. The content you place inside each slide still needs a clear structure and an accurate accessible name.
- Give every carousel a specific
aria-labeloraria-labelledbythat identifies the content set. - Focus the viewport, then use Left/Right Arrow, Home, or End to navigate. Previous and next controls disable at the boundaries.
- Label dots with their destination, such as a plan or testimonial, rather than an opaque number whenever the destination has a name.
- Fan, arc and ribbon make inactive slide content inert. Legacy modes retain their visible-neighbor behavior. Reduced motion removes angles and blur, showing one centered card in the new modes.
Existing flat, tilt and floor values remain supported. Choose fan for stories, arc for imagery, or ribbon for feature highlights; the controlled index and navigation API stay the same.
Use an opaque bg-background surface on overlapping card content. CarouselItem remains unstyled so images and custom surfaces compose naturally. Use content-aware heights for long text, rather than clipping quotes or attribution.
Set inactiveBlur={0} for sharp previews. Tune --carousel-card-size and --carousel-step on CarouselContent; keep enough room around rotated corners. The new modes adapt to their container width, not only the window.
Keep controls and captions outside rotated items. Only the active slide’s content is interactive in the new modes. Click a visible side card to center it, or use the arrows and named dots. Arrow, Home and End keys inside nested form controls retain their native behavior.
Manual acceptance: navigate with Tab and Arrow/Home/End, drag in both directions, change reduced motion while mounted, check long text at 200% zoom, and verify focus never enters an obscured card. Screen-reader acceptance should confirm the region name, slide position and updated gallery caption.
The root, content, and dots accept their standard native div attributes in addition to the documented API. Items and previous/next controls accept their native div and IconButton attributes respectively.
| Prop | What it does | Default |
|---|---|---|
childrenCarouselContent + optional controls | Place one CarouselContent directly inside the root. Navigation controls and dots can sit alongside it. | Not set |
staging"flat" | "tilt" | "floor" | "fan" | "arc" | "ribbon" | Flat slides, legacy tilt/floor staging, a fanned deck, a curved image gallery, or an alternating tilted ribbon. New modes make inactive slides inert. | "flat" |
intensity"subtle" | "standard" | "dramatic" | Scales rotation in angled modes without changing the control model. Narrow containers reduce angles automatically. | "standard" |
inactiveBlurnumber (0–4 pixels) | Controls blur of inactive cards in fan, arc and ribbon independently of intensity. Set 0 for crisp previews. Active cards are always sharp; legacy modes retain their existing treatment. | fan/ribbon: 1.5; arc: 0.5 |
indexnumber | Controlled active slide index. Pair with onIndexChange to synchronize application state. | Not set |
defaultIndexnumber | Initial active index for uncontrolled use. | 0 |
onIndexChange(index: number) => void | Fires after controls, dots, keyboard navigation, or a settled drag choose a different slide. | Not set |
dragboolean | Enables mouse and touch drag when more than one slide is present. | true |
aria-label / aria-labelledbystring | Required accessible name for the carousel region. Name the content, not the visual effect. | Not set |
| Prop | What it does | Default |
|---|---|---|
childrenCarouselItem | CarouselItem[] | Direct slide children. Each item gains slide semantics, an ordinal label, and inert behavior when it leaves the visible stage. | Not set |
className / stylestring / CSSProperties | Use the viewport to adjust layout. The carousel exposes CSS variables for its card size and spacing tokens. | Not set |
| Prop | What it does | Default |
|---|---|---|
label(index: number) => string | Returns an accessible destination label for each pagination button. | "Go to slide {n}" |



