Selection Card
Card-shaped radio and checkbox options for choosing one or several from a set.
Installation
$
Usage
import {
SelectionCardGroup, SelectionCard, SelectionCardTitle, SelectionCardDescription,
} from "@/components/ui/selection-card"Examples
Default
StarterFor side projects and quick experiments. One seat included.ProRecommendedFor growing teams that need shared workspaces and priority support.EnterpriseAdvanced controls, audit logs, and a dedicated account manager.
Multiple
Set type="multiple" and the cards become checkboxes: a square control, and any number can be picked at once. The value is an array of the picked values.
Product updatesNew features and improvements, sent as they ship.Weekly digestA Monday summary of everything that changed last week.Security alertsSign-in notifications and account activity you should know about.
Title only
The description is optional. With just a title, the card collapses to a single line and the control stays centred on it.
Billed monthlyBilled yearly
Disabled option
Disable a single card with disabled, or the whole set by putting disabled on the group. A disabled card dims and drops out of keyboard navigation.
Credit cardVisa, Mastercard, and American Express.Bank transferNot available in your region yet.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| typeSelectionCardGroup | "single" | "multiple" | "single" | single renders radio cards (round control, one choice). multiple renders checkbox cards (square control, any number). |
| defaultValueSelectionCardGroup | string | string[] | - | The initially selected value, for uncontrolled usage. A string for single, an array of values for multiple. |
| valueSelectionCardGroup, SelectionCard | string | string[] | - | On the group, the controlled selection (use with onValueChange). On <SelectionCard />, the value this card contributes (required). |
| onValueChangeSelectionCardGroup | (value: string | string[]) => void | - | Called with the new selection. A string for single, an array for multiple. |
| disabledSelectionCardGroup, SelectionCard | boolean | false | Disables every card, or a single card on <SelectionCard />. |
"use client"
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { CheckboxGroup as CheckboxGroupPrimitive } from "@base-ui/react/checkbox-group"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/registry/lib/utils"
type SelectionType = "single" | "multiple"
// The card is a Base UI Radio/Checkbox Root, so the whole card is the hit
// target and roving keyboard focus comes for free. The group tells each card
// which primitive to render; a single-select card keys off `value`, a
// multi-select card off `name` (Base UI's CheckboxGroup identifies items by
// name), so consumers pass one `value` prop and we route it.
const SelectionCardGroupContext = React.createContext<SelectionType>("single")
type SelectionCardGroupProps =
| ({ type?: "single" } & React.ComponentProps<typeof RadioGroupPrimitive>)
| ({ type: "multiple" } & React.ComponentProps<typeof CheckboxGroupPrimitive>)
function SelectionCardGroup({
type = "single",
className,
...props
}: SelectionCardGroupProps) {
const classes = cn("grid gap-3", className)
if (type === "multiple") {
return (
<SelectionCardGroupContext.Provider value="multiple">
<CheckboxGroupPrimitive
data-slot="selection-card-group"
className={classes}
{...(props as React.ComponentProps<typeof CheckboxGroupPrimitive>)}
/>
</SelectionCardGroupContext.Provider>
)
}
return (
<SelectionCardGroupContext.Provider value="single">
<RadioGroupPrimitive
data-slot="selection-card-group"
className={classes}
{...(props as React.ComponentProps<typeof RadioGroupPrimitive>)}
/>
</SelectionCardGroupContext.Provider>
)
}
// Selected = filled control + accent ring; the card surface stays `bg-card` in
// both states. `shadow-ring-md` sets --tw-shadow (the neutral hairline edge), so
// the accent `ring-*` composes on top of it via --tw-ring-shadow rather than
// replacing the edge.
const cardBase =
"group/card relative flex w-full items-start gap-3 rounded-lg bg-card p-4 text-left shadow-ring-md outline-none cursor-pointer transition-[background-color,box-shadow] duration-150 ease-out motion-reduce:transition-none data-[checked]:ring-2 data-[checked]:ring-accent focus-visible:ring-2 focus-visible:ring-ring-accent aria-invalid:ring-2 aria-invalid:ring-ring-destructive data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70"
// The visual control mirrors the card's checked state via `group-data-[checked]`.
// It is NOT the focusable element (the card is), so it is decorative.
const controlBase =
"mt-0.5 grid size-4 shrink-0 place-content-center border border-border dark:bg-neutral-800 shadow-xs transition-[background-color,border-color] duration-150 ease-out motion-reduce:transition-none group-data-[checked]/card:border-accent group-data-[checked]/card:bg-accent group-data-[checked]/card:text-accent-foreground"
// Kept mounted (`keepMounted`) so the dot/check never unmounts; its show/hide is
// a CSS transition on transform+opacity keyed off the card's checked state, so
// enter and exit run the same 150ms path and interrupt cleanly when you jump
// from one card to another. A keyframe enter (animate-in) would pop on and
// leave the outgoing dot to linger while Base UI waited to unmount it.
const indicatorBase =
"grid place-content-center text-current scale-50 opacity-0 transition-[transform,opacity] duration-150 ease-out group-data-[checked]/card:scale-100 group-data-[checked]/card:opacity-100 motion-reduce:transition-none"
function SelectionCard({
value,
className,
children,
...props
}: {
/** Identifies this card within its group. */
value: string
} & Omit<
React.ComponentProps<typeof RadioPrimitive.Root>,
"value" | "render"
>) {
const type = React.useContext(SelectionCardGroupContext)
if (type === "multiple") {
return (
<CheckboxPrimitive.Root
name={value}
data-slot="selection-card"
className={cn(cardBase, className)}
{...props}
>
<span aria-hidden className={cn(controlBase, "rounded-sm")}>
<CheckboxPrimitive.Indicator keepMounted className={indicatorBase}>
<CheckIcon className="size-2.5" />
</CheckboxPrimitive.Indicator>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">{children}</span>
</CheckboxPrimitive.Root>
)
}
return (
<RadioPrimitive.Root
value={value}
data-slot="selection-card"
className={cn(cardBase, className)}
{...props}
>
<span aria-hidden className={cn(controlBase, "rounded-full")}>
<RadioPrimitive.Indicator keepMounted className={indicatorBase}>
<span className="size-1.5 rounded-full bg-current" />
</RadioPrimitive.Indicator>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">{children}</span>
</RadioPrimitive.Root>
)
}
function SelectionCardTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="selection-card-title"
// Flex row so an inline Badge (e.g. "Recommended") sits beside the label
// and drops under it on a narrow card instead of forcing overflow.
className={cn(
"flex flex-wrap items-center gap-x-2 gap-y-1 text-sm leading-snug font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SelectionCardDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="selection-card-description"
className={cn("text-xs text-muted-foreground text-pretty", className)}
{...props}
/>
)
}
export {
SelectionCardGroup,
SelectionCard,
SelectionCardTitle,
SelectionCardDescription,
}