Selection Card
Card-shaped radio and checkbox options for choosing one or several from a set.
Installation
npx shadcn@latest add https://ui.mattdowney.com/r/selection-card.jsonUsage
import {
SelectionCardGroup, SelectionCard, SelectionCardTitle, SelectionCardDescription,
SelectionCardMedia, SelectionCardContent,
} from "@/components/ui/selection-card"Examples
Default
Radio options with room for a line of description each, for weighty choices like plans. The whole card is the hit target, and each one needs its own value.
Multiple
Set mode="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.
Title only
The description is optional. With just a title, the card collapses to a single line and the control stays centred on it.
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.
Media
variant="media" drops the control for a picture over the words, for when the picture tells the options apart. The accent ring is the only picked state, and the edge darkens on hover.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variantSelectionCard | "default" | "media" | "default" | media removes the control and lays the card out as a picture over its words. Compose it from <SelectionCardMedia /> and <SelectionCardContent />. |
| modeSelectionCardGroup | "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 { IconCheck as CheckIcon } from "@tabler/icons-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 =
| ({
mode?: "single"
/** @deprecated Use `mode`. */
type?: SelectionType
} & React.ComponentProps<typeof RadioGroupPrimitive>)
| ({
mode: "multiple"
/** @deprecated Use `mode`. */
type?: SelectionType
} & React.ComponentProps<typeof CheckboxGroupPrimitive>)
| ({
mode?: undefined
/** @deprecated Use `mode`. */
type: "multiple"
} & React.ComponentProps<typeof CheckboxGroupPrimitive>)
function SelectionCardGroup({
mode: modeProp,
type,
className,
...props
}: SelectionCardGroupProps) {
const mode: SelectionType = modeProp ?? type ?? "single"
const classes = cn("grid gap-3", className)
if (mode === "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.
// No reduced-motion guard: background-color and box-shadow move nothing on
// screen, so neither is vestibular and both stay on at every motion preference.
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-small ease-small 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.
// No reduced-motion guard: both properties are colour, so nothing moves.
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-small ease-small 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.
// Under reduced motion the scale is pinned and only the fade runs, matching the
// standalone Checkbox and RadioGroupItem indicators.
const indicatorBase =
"grid place-content-center text-current scale-50 opacity-0 transition-[transform,opacity] duration-small ease-small group-data-[checked]/card:scale-100 group-data-[checked]/card:opacity-100 motion-reduce:scale-100 motion-reduce:transition-[opacity]"
// The media layout: a picture on top and the words under it, with no control.
// The card runs edge to edge (no padding, clipped to its own corner) so
// SelectionCardMedia can fill the top; SelectionCardContent carries the inset.
// The accent ring is the only selected state, so the picture has to carry the
// meaning the dot used to (the title names the choice either way).
// Hover darkens the edge one step (`border-strong`, never accent, which means
// picked), because with no control there is nothing else saying "clickable".
// Tailwind v4 scopes `hover:` to `(hover: hover)`, so touch never sticks on it.
// Scoped off a picked, disabled or focused card, so it never overrides the
// accent or focus ring, which set the same ring width and colour.
const mediaBase =
"group/card relative flex w-full flex-col overflow-hidden rounded-lg bg-card text-left shadow-ring-md outline-none cursor-pointer transition-[background-color,box-shadow] duration-small ease-small not-data-[checked]:not-data-[disabled]:not-focus-visible:hover:ring-1 not-data-[checked]:not-data-[disabled]:not-focus-visible:hover:ring-border-strong 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"
// @use-when a set of card-sized choices, single or multi select, where the
// whole card is the hit target.
function SelectionCard({
value,
variant = "default",
className,
children,
...props
}: {
/** Identifies this card within its group. */
value: string
/** `media` drops the control and lays the card out as a picture over its
* words: compose it from SelectionCardMedia and SelectionCardContent. */
variant?: "default" | "media"
} & Omit<
React.ComponentProps<typeof RadioPrimitive.Root>,
"value" | "render"
>) {
const type = React.useContext(SelectionCardGroupContext)
if (variant === "media") {
return type === "multiple" ? (
<CheckboxPrimitive.Root
name={value}
data-slot="selection-card"
data-variant="media"
className={cn(mediaBase, className)}
{...props}
>
{children}
</CheckboxPrimitive.Root>
) : (
<RadioPrimitive.Root
value={value}
data-slot="selection-card"
data-variant="media"
className={cn(mediaBase, className)}
{...props}
>
{children}
</RadioPrimitive.Root>
)
}
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>
)
}
/** The picture on a `media` card: a tinted well across the top, split from the
* words by a hairline. It centres whatever it holds, so pass the drawing at
* its own size. */
function SelectionCardMedia({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="selection-card-media"
className={cn(
"flex items-center justify-center border-b border-border bg-muted py-6",
className
)}
{...props}
/>
)
}
/** The words on a `media` card. It owns the inset the card gave up, and binds
* the title to its description on the `pair` rung. */
function SelectionCardContent({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="selection-card-content"
className={cn("flex flex-col gap-(--spacing-rhythm-pair) p-4", className)}
{...props}
/>
)
}
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.
// NO `text-wrap` utility at all, unlike every other *Title in the kit,
// and that is a decision rather than an oversight: the property applies
// to the inline content of a BLOCK, and this is a flex container whose
// text becomes an anonymous box beside a Badge. It would either do
// nothing or fight the `flex-wrap` already deciding these line breaks,
// which is what lets the Badge drop under the label 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,
SelectionCardMedia,
SelectionCardContent,
SelectionCardTitle,
SelectionCardDescription,
}