Skip to content
Dethink Components

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 files

Import the component into your page or component file.

Usage
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
examples/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>
  );
}

Flat staging lets the image lead. Captions preserve enough context to browse a place, project, portfolio, or product collection without a separate detail view.

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
examples/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
examples/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-label oraria-labelledby that 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.

Carousel props
PropWhat it doesDefault
childrenCarouselContent + optional controlsPlace 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"
indexnumberControlled active slide index. Pair with onIndexChange to synchronize application state.Not set
defaultIndexnumberInitial active index for uncontrolled use.0
onIndexChange(index: number) => voidFires after controls, dots, keyboard navigation, or a settled drag choose a different slide.Not set
dragbooleanEnables mouse and touch drag when more than one slide is present.true
aria-label / aria-labelledbystringRequired accessible name for the carousel region. Name the content, not the visual effect.Not set
CarouselContent props
PropWhat it doesDefault
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 / CSSPropertiesUse the viewport to adjust layout. The carousel exposes CSS variables for its card size and spacing tokens.Not set
CarouselDots props
PropWhat it doesDefault
label(index: number) => stringReturns an accessible destination label for each pagination button."Go to slide {n}"