ComponentsCardScroller
CardScroller
Let users scroll through and select cards in a row.
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 filesImplemented with React and browser APIs, without an additional behavior or rendering library.
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.
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 |