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 filesImport the component into your page or component file.
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
Grow is selected
Show sourceexamples/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.
Show sourceexamples/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.
Roomy container, two-card ceiling
The component fills its host but honors maxVisibleCards.
Show sourceexamples/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
داكن · مدمج · من اليمين إلى اليسار
Show sourceexamples/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.
| Prop | What it does | Default |
|---|---|---|
childrenCardScrollerItem | CardScrollerItem[] | Direct CardScrollerItem children. Each item must contain exactly one direct Card child. | Not set |
valuestring | Controlled selected value. Pair with onValueChange to manage selection externally. | Not set |
defaultValuestring | Initial selected value for uncontrolled usage. | first enabled item |
onValueChange(value: string) => void | Fires when a user selects a different enabled card. | Not set |
maxVisibleCards1 | 2 | 3 | 4 | Maximum cards visible in a wide container. Narrow containers still show one card per snap point. | 3 |
showControlsboolean | Shows previous and next buttons only while the card viewport has overflow. | true |
overlapboolean | Closes the inter-card gap and enlarges the selected card above its immediate neighbors. | false |
previousLabel / nextLabelstring | Accessible labels for the scroll controls. | "Previous card" / "Next card" |
disabledboolean | Disables selection and the built-in scroll controls. | false |
namestring | Name shared by the native radio inputs in the group. | generated |
aria-label / aria-labelledbystring | Accessible name for the radiogroup. | Not set |
| Prop | What it does | Default |
|---|---|---|
childrenCard | Exactly one direct Card. Keep its contents presentational because the whole card is the radio label. | Not set |
valuestring | Unique non-empty value submitted by the item radio. | Not set |
labelstring | Accessible name announced for the item radio. | Not set |
disabledboolean | Prevents this card from being selected. | false |