ComponentsCarousel
Carousel
Let users browse a series of cards or images.
On this page
Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.
View registry filesImport 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
Tilt staging gives product capabilities a sense of depth while keeping their titles, context, and supporting metric readable at a glance.
Product storytelling
Make a feature feel like a destination.
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 {
ChartNoAxesCombined,
Layers3,
ShieldCheck,
Sparkles,
} from "lucide-react";
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} 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() {
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">
Make a feature feel like a destination.
</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>
<Carousel
aria-label="Product capabilities"
defaultIndex={1}
staging="tilt"
className="mx-auto max-w-5xl"
>
<CarouselContent>
{features.map((feature) => {
const Icon = feature.icon;
return (
<CarouselItem key={feature.title}>
<article className="border-border bg-card flex h-[23rem] w-full flex-col overflow-hidden rounded-2xl border shadow-xl shadow-black/5">
<div className="from-primary/20 via-primary/5 to-background relative min-h-40 overflow-hidden bg-gradient-to-br p-6">
<div className="bg-primary/15 absolute -top-12 -right-10 size-44 rounded-full blur-3xl" />
<div className="border-primary/15 bg-background/75 text-primary relative flex size-12 items-center justify-center rounded-2xl border shadow-sm backdrop-blur-sm">
<Icon aria-hidden="true" className="size-5" />
</div>
<p className="text-muted-foreground relative mt-8 text-xs font-semibold tracking-[0.16em] uppercase">
{feature.eyebrow}
</p>
</div>
<div className="flex flex-1 flex-col p-6 pt-5">
<h4 className="font-heading text-xl font-semibold tracking-tight">
{feature.title}
</h4>
<p className="text-muted-foreground mt-2 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 />
<CarouselDots label={(index) => `Show ${features[index].title}`} />
<CarouselNext />
</div>
</Carousel>
</div>
);
}Editorial image gallery
Flat staging lets the image lead. Captions preserve enough context to browse a place, project, portfolio, or product collection without a separate detail view.
Show sourceexamples/carousel/image-gallery.tsx
"use client";
import Image from "next/image";
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} 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() {
return (
<Carousel
aria-label="Horizon collection"
staging="flat"
className="mx-auto max-w-5xl"
>
<CarouselContent>
{collection.map((entry, index) => (
<CarouselItem key={entry.title}>
<figure className="border-border bg-card relative h-[clamp(20rem,54vw,32rem)] w-full overflow-hidden rounded-2xl border shadow-xl shadow-black/10">
<Image
alt={entry.alt}
className="size-full object-cover"
decoding="async"
fill
priority={index === 0}
sizes="(max-width: 640px) 92vw, 544px"
src={entry.image}
/>
<figcaption className="from-foreground/85 via-foreground/20 text-primary-foreground absolute inset-x-0 bottom-0 bg-gradient-to-t to-transparent px-6 pt-20 pb-6 sm:px-8 sm:pb-8">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.16em] uppercase opacity-75">
{entry.category}
</p>
<p className="font-heading mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">
{entry.title}
</p>
</div>
<p className="hidden text-right text-xs font-medium opacity-80 sm:block">
{entry.location}
</p>
</div>
</figcaption>
</figure>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-6 flex items-center justify-center gap-3">
<CarouselPrevious />
<CarouselDots label={(index) => `Show ${collection[index].title}`} />
<CarouselNext />
</div>
</Carousel>
);
}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-card flex h-[27rem] w-full flex-col rounded-2xl border p-6 shadow-xl shadow-black/5">
<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="mt-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
Floor staging gives longer quotes a deliberate presentation surface while retaining direct, labelled ways to move between stories.
Customer voice
Let the proof take the stage.
Show sourceexamples/carousel/testimonials.tsx
"use client";
import { Quote } from "lucide-react";
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} 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() {
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>
<Carousel
aria-label="Customer testimonials"
defaultIndex={1}
intensity="subtle"
staging="floor"
className="mx-auto max-w-5xl"
>
<CarouselContent>
{testimonials.map((testimonial) => (
<CarouselItem key={testimonial.name}>
<figure className="border-border bg-card flex h-[23rem] w-full flex-col rounded-2xl border p-7 shadow-xl shadow-black/5">
<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 mt-7 text-xl leading-8 font-medium tracking-tight">
“{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 />
<CarouselDots
label={(index) =>
`Show testimonial from ${testimonials[index].name}`
}
/>
<CarouselNext />
</div>
</Carousel>
</div>
);
}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.
- Off-stage slides become inert and reduced motion flattens the 3D presentation into a calm opacity transition.
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" | Conventional centered slides, a perspective row, or a receding floor stage. | "flat" |
intensity"subtle" | "standard" | "dramatic" | Scales the depth and rotation in tilt and floor staging without changing the control model. | "standard" |
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}" |



