Skip to content
Dethink Components

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

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.

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

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

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

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-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
examples/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-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.
  • 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.

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" | "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
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}"