Skip to content
Dethink Components

ComponentsCardScroller

CardScroller

Let users scroll through and select cards in a row.

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 {
  Card,
  CardScroller,
  CardScrollerItem,
} from "@dethink/components";

Live review surfaces rendered by the exact code shown below each one. Hover or focus a card to inspect spotlight behavior, select a new card, then resize the page to review snapping and controls.

SaaS pricing selector

A controlled selection keeps the chosen plan in sync with an action outside the scroller. The selected plan overlaps its neighbors while hover and focus temporarily spotlight another card; drag the row or use the arrow controls to browse.

Choose your plan

Start small. Upgrade when you need to.

Billed monthly · cancel anytime

Launch

For solo builders shipping their first product.

$19/ month

  • 3 projects
  • 10 GB storage
  • Community support
Select anywhere on this card
Most popular

Grow

For teams building a repeatable growth engine.

$49/ month

  • Unlimited projects
  • 100 GB storage
  • Priority support
Select anywhere on this card

Scale

For companies standardizing how work gets shipped.

$99/ month

  • Advanced roles
  • 500 GB storage
  • SAML SSO
Select anywhere on this card

Enterprise

For regulated organizations with custom needs.

Custom

  • Custom controls
  • Dedicated region
  • 24/7 support
Select anywhere on this card

Grow is selected

Show sourceexamples/card-scroller/pricing.tsx
examples/card-scroller/pricing.tsx
"use client";

import { useState } from "react";
import { Check } from "lucide-react";
import {
  Button,
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardScroller,
  CardScrollerItem,
  CardTitle,
} from "@dethink/components";

const plans = [
  {
    value: "launch",
    name: "Launch",
    description: "For solo builders shipping their first product.",
    price: "$19",
    eyebrow: null,
    features: ["3 projects", "10 GB storage", "Community support"],
  },
  {
    value: "grow",
    name: "Grow",
    description: "For teams building a repeatable growth engine.",
    price: "$49",
    eyebrow: "Most popular",
    features: ["Unlimited projects", "100 GB storage", "Priority support"],
  },
  {
    value: "scale",
    name: "Scale",
    description: "For companies standardizing how work gets shipped.",
    price: "$99",
    eyebrow: null,
    features: ["Advanced roles", "500 GB storage", "SAML SSO"],
  },
  {
    value: "enterprise",
    name: "Enterprise",
    description: "For regulated organizations with custom needs.",
    price: "Custom",
    eyebrow: null,
    features: ["Custom controls", "Dedicated region", "24/7 support"],
  },
] as const;

export function CardScrollerPricing() {
  const [selectedPlan, setSelectedPlan] = useState("grow");
  const plan = plans.find((entry) => entry.value === selectedPlan) ?? plans[0];

  return (
    <div className="space-y-6">
      <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
        <div>
          <p className="text-primary text-xs font-semibold tracking-[0.14em] uppercase">
            Choose your plan
          </p>
          <h3 className="font-heading mt-1 text-2xl font-semibold tracking-tight">
            Start small. Upgrade when you need to.
          </h3>
        </div>
        <p className="text-muted-foreground text-sm">
          Billed monthly · cancel anytime
        </p>
      </div>

      <CardScroller
        aria-label="Choose a subscription plan"
        maxVisibleCards={3}
        overlap
        value={selectedPlan}
        onValueChange={setSelectedPlan}
      >
        {plans.map((entry) => (
          <CardScrollerItem
            key={entry.value}
            value={entry.value}
            label={`Select the ${entry.name} plan`}
          >
            <Card as="article" className="min-h-[22rem]" shadow="none">
              <CardHeader>
                <div className="min-h-5">
                  {entry.eyebrow ? (
                    <span className="bg-primary/10 text-primary rounded-full px-2 py-1 text-[0.6875rem] font-semibold tracking-wide uppercase">
                      {entry.eyebrow}
                    </span>
                  ) : null}
                </div>
                <CardTitle className="mt-2 text-xl">{entry.name}</CardTitle>
                <CardDescription>{entry.description}</CardDescription>
              </CardHeader>
              <CardContent className="flex flex-1 flex-col gap-5">
                <p className="font-heading text-3xl font-bold tracking-tight">
                  {entry.price}
                  {entry.price.startsWith("$") ? (
                    <span className="text-muted-foreground ml-1 text-sm font-normal">
                      / month
                    </span>
                  ) : null}
                </p>
                <ul
                  className="space-y-3 text-sm"
                  aria-label={`${entry.name} features`}
                >
                  {entry.features.map((feature) => (
                    <li key={feature} className="flex items-start gap-2">
                      <Check
                        aria-hidden="true"
                        className="text-primary mt-0.5 size-4 shrink-0"
                      />
                      <span>{feature}</span>
                    </li>
                  ))}
                </ul>
              </CardContent>
              <CardFooter>
                <span className="text-muted-foreground text-xs">
                  Select anywhere on this card
                </span>
              </CardFooter>
            </Card>
          </CardScrollerItem>
        ))}
      </CardScroller>

      <div className="border-border bg-muted/40 flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between">
        <p className="text-sm" aria-live="polite">
          <span className="font-medium">{plan.name}</span>
          <span className="text-muted-foreground"> is selected</span>
        </p>
        <Button size="sm">Continue with {plan.name}</Button>
      </div>
    </div>
  );
}

Feature groups

The same interaction works for product capabilities, onboarding paths, templates, or any other mutually exclusive visual choice.

Automation

Turn repeatable work into dependable flows.

Rules, approvals, scheduled runs

Performance

See bottlenecks before customers feel them.

Live traces, budgets, anomaly alerts

Security

Keep access and policy under one roof.

SSO, audit logs, scoped roles

Platform

Compose the product around your workflow.

API, webhooks, reusable modules

Global scale

Stay close to every customer and team.

Regions, localization, edge delivery

Show sourceexamples/card-scroller/features.tsx
examples/card-scroller/features.tsx
"use client";

import { Boxes, Gauge, Globe2, ShieldCheck, Sparkles } from "lucide-react";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardScroller,
  CardScrollerItem,
  CardTitle,
} from "@dethink/components";

const featureGroups = [
  {
    value: "automation",
    title: "Automation",
    description: "Turn repeatable work into dependable flows.",
    detail: "Rules, approvals, scheduled runs",
    icon: Sparkles,
  },
  {
    value: "performance",
    title: "Performance",
    description: "See bottlenecks before customers feel them.",
    detail: "Live traces, budgets, anomaly alerts",
    icon: Gauge,
  },
  {
    value: "security",
    title: "Security",
    description: "Keep access and policy under one roof.",
    detail: "SSO, audit logs, scoped roles",
    icon: ShieldCheck,
  },
  {
    value: "platform",
    title: "Platform",
    description: "Compose the product around your workflow.",
    detail: "API, webhooks, reusable modules",
    icon: Boxes,
  },
  {
    value: "global",
    title: "Global scale",
    description: "Stay close to every customer and team.",
    detail: "Regions, localization, edge delivery",
    icon: Globe2,
  },
] as const;

export function CardScrollerFeatures() {
  return (
    <CardScroller
      aria-label="Explore product capabilities"
      defaultValue="security"
      maxVisibleCards={4}
      nextLabel="Show later capabilities"
      previousLabel="Show earlier capabilities"
    >
      {featureGroups.map((feature) => {
        const Icon = feature.icon;

        return (
          <CardScrollerItem
            key={feature.value}
            value={feature.value}
            label={`Explore ${feature.title}`}
          >
            <Card as="article" className="min-h-64" shadow="none">
              <CardHeader>
                <span className="border-border bg-muted text-primary mb-3 flex size-10 items-center justify-center rounded-lg border">
                  <Icon aria-hidden="true" className="size-5" />
                </span>
                <CardTitle>{feature.title}</CardTitle>
                <CardDescription>{feature.description}</CardDescription>
              </CardHeader>
              <CardContent className="mt-auto">
                <p className="text-muted-foreground border-border border-t pt-4 text-sm leading-6">
                  {feature.detail}
                </p>
              </CardContent>
            </Card>
          </CardScrollerItem>
        );
      })}
    </CardScroller>
  );
}

Container response

These side-by-side fixtures make container-query behavior reviewable: the narrow host uses one-card mandatory snapping while the wider host caps its layout at two cards.

Narrow container

One card per snap point, independent of viewport width.

Overview

A quick read on workspace health.

Workspace view

Activity

Recent events across every project.

Workspace view

Insights

Trends and recommendations for the team.

Workspace view

Reports

Shareable summaries for stakeholders.

Workspace view

Roomy container, two-card ceiling

The component fills its host but honors maxVisibleCards.

Overview

A quick read on workspace health.

Workspace view

Activity

Recent events across every project.

Workspace view

Insights

Trends and recommendations for the team.

Workspace view

Reports

Shareable summaries for stakeholders.

Workspace view

Show sourceexamples/card-scroller/responsive.tsx
examples/card-scroller/responsive.tsx
"use client";

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardScroller,
  CardScrollerItem,
  CardTitle,
} from "@dethink/components";

const routes = [
  ["overview", "Overview", "A quick read on workspace health."],
  ["activity", "Activity", "Recent events across every project."],
  ["insights", "Insights", "Trends and recommendations for the team."],
  ["reports", "Reports", "Shareable summaries for stakeholders."],
] as const;

function RouteItems() {
  return routes.map(([value, title, description]) => (
    <CardScrollerItem key={value} value={value} label={`Select ${title}`}>
      <Card as="article" className="min-h-48" shadow="none">
        <CardHeader>
          <CardTitle>{title}</CardTitle>
          <CardDescription>{description}</CardDescription>
        </CardHeader>
        <CardContent className="mt-auto">
          <p className="text-primary text-xs font-semibold tracking-wide uppercase">
            Workspace view
          </p>
        </CardContent>
      </Card>
    </CardScrollerItem>
  ));
}

export function CardScrollerResponsive() {
  return (
    <div className="grid items-start gap-8 xl:grid-cols-[22rem_minmax(0,1fr)]">
      <section className="space-y-3" aria-labelledby="narrow-frame-title">
        <div>
          <h4 id="narrow-frame-title" className="font-heading font-semibold">
            Narrow container
          </h4>
          <p className="text-muted-foreground text-sm">
            One card per snap point, independent of viewport width.
          </p>
        </div>
        <div className="border-border bg-muted/30 rounded-xl border p-3">
          <CardScroller
            aria-label="Workspace views in a narrow container"
            defaultValue="activity"
            maxVisibleCards={4}
          >
            {RouteItems()}
          </CardScroller>
        </div>
      </section>

      <section className="space-y-3" aria-labelledby="roomy-frame-title">
        <div>
          <h4 id="roomy-frame-title" className="font-heading font-semibold">
            Roomy container, two-card ceiling
          </h4>
          <p className="text-muted-foreground text-sm">
            The component fills its host but honors maxVisibleCards.
          </p>
        </div>
        <div className="border-border bg-muted/30 rounded-xl border p-3">
          <CardScroller
            aria-label="Workspace views in a roomy container"
            defaultValue="activity"
            maxVisibleCards={2}
          >
            {RouteItems()}
          </CardScroller>
        </div>
      </section>
    </div>
  );
}

Theme, density, and direction

Review the same composition in comfortable light mode and compact dark RTL mode. Controls follow reading direction and every visual state remains token-driven.

Light · comfortable

Core

Shared workspaces and product analytics.

Product module

Guard

Access policy, SSO, and audit trails.

Product module

Relay

Automations, webhooks, and scheduled jobs.

Product module

داكن · مدمج · من اليمين إلى اليسار

الأساس

مساحات عمل وتحليلات مشتركة.

وحدة المنتج

الحماية

سياسات وصول وسجل تدقيق.

وحدة المنتج

الربط

أتمتة وخطافات ويب مجدولة.

وحدة المنتج
Show sourceexamples/card-scroller/themes.tsx
examples/card-scroller/themes.tsx
"use client";

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardScroller,
  CardScrollerItem,
  CardTitle,
  DethinkProvider,
} from "@dethink/components";

const products = [
  {
    value: "core",
    title: "Core",
    description: "Shared workspaces and product analytics.",
    arabicTitle: "الأساس",
    arabicDescription: "مساحات عمل وتحليلات مشتركة.",
  },
  {
    value: "guard",
    title: "Guard",
    description: "Access policy, SSO, and audit trails.",
    arabicTitle: "الحماية",
    arabicDescription: "سياسات وصول وسجل تدقيق.",
  },
  {
    value: "relay",
    title: "Relay",
    description: "Automations, webhooks, and scheduled jobs.",
    arabicTitle: "الربط",
    arabicDescription: "أتمتة وخطافات ويب مجدولة.",
  },
] as const;

function ProductItems({ arabic = false }: { arabic?: boolean }) {
  return products.map((product) => (
    <CardScrollerItem
      key={product.value}
      value={product.value}
      label={
        arabic ? `اختيار ${product.arabicTitle}` : `Select ${product.title}`
      }
    >
      <Card as="article" className="min-h-44" shadow="none">
        <CardHeader>
          <CardTitle>{arabic ? product.arabicTitle : product.title}</CardTitle>
          <CardDescription>
            {arabic ? product.arabicDescription : product.description}
          </CardDescription>
        </CardHeader>
        <CardContent className="mt-auto">
          <span className="text-primary text-xs font-semibold tracking-wide uppercase">
            {arabic ? "وحدة المنتج" : "Product module"}
          </span>
        </CardContent>
      </Card>
    </CardScrollerItem>
  ));
}

export function CardScrollerThemes() {
  return (
    <div className="grid gap-5 xl:grid-cols-2">
      <DethinkProvider
        theme="light"
        density="comfortable"
        className="border-border bg-background text-foreground rounded-xl border p-4"
      >
        <p className="mb-4 text-sm font-medium">Light · comfortable</p>
        <CardScroller
          aria-label="Choose a product module in light theme"
          defaultValue="guard"
          maxVisibleCards={2}
        >
          {ProductItems({})}
        </CardScroller>
      </DethinkProvider>

      <DethinkProvider
        theme="dark"
        density="compact"
        dir="rtl"
        className="border-border bg-background text-foreground rounded-xl border p-4"
      >
        <p className="mb-4 text-sm font-medium">
          داكن · مدمج · من اليمين إلى اليسار
        </p>
        <CardScroller
          aria-label="اختيار وحدة المنتج"
          defaultValue="guard"
          maxVisibleCards={2}
          nextLabel="إظهار الوحدات التالية"
          previousLabel="إظهار الوحدات السابقة"
        >
          {ProductItems({ arabic: true })}
        </CardScroller>
      </DethinkProvider>
    </div>
  );
}

CardScroller exposes a native single-selection model without turning the Card itself into a nested interactive surface.

  • Give the radiogroup a specific accessible name and every item a concise label.
  • Keep buttons, links, inputs, and menus outside CardScrollerItem; the entire card is one radio label.
  • Selection remains visible independently of hover, focus, blur, scale, or motion.
  • Reduced-motion and forced-colors preferences receive non-animated, non-blurred fallbacks.

Both components accept native div attributes in addition to the public props below.

CardScroller props
PropWhat it doesDefault
childrenCardScrollerItem | CardScrollerItem[]Direct CardScrollerItem children. Each item must contain exactly one direct Card child.Not set
valuestringControlled selected value. Pair with onValueChange to manage selection externally.Not set
defaultValuestringInitial selected value for uncontrolled usage.first enabled item
onValueChange(value: string) => voidFires when a user selects a different enabled card.Not set
maxVisibleCards1 | 2 | 3 | 4Maximum cards visible in a wide container. Narrow containers still show one card per snap point.3
showControlsbooleanShows previous and next buttons only while the card viewport has overflow.true
overlapbooleanCloses the inter-card gap and enlarges the selected card above its immediate neighbors.false
previousLabel / nextLabelstringAccessible labels for the scroll controls."Previous card" / "Next card"
disabledbooleanDisables selection and the built-in scroll controls.false
namestringName shared by the native radio inputs in the group.generated
aria-label / aria-labelledbystringAccessible name for the radiogroup.Not set
CardScrollerItem props
PropWhat it doesDefault
childrenCardExactly one direct Card. Keep its contents presentational because the whole card is the radio label.Not set
valuestringUnique non-empty value submitted by the item radio.Not set
labelstringAccessible name announced for the item radio.Not set
disabledbooleanPrevents this card from being selected.false