Selectable card
Card-sized radios and checkboxes for picking a plan, a mode, or a set of sources.
Each card is a real button carrying its own role and aria-checked, so it is reachable and operable from the keyboard. The indicator picks both the mark and the role: a radio never unchecks itself, a checkbox toggles. SelectableCardGroup renders a single-choice grid as a radiogroup with roving arrow keys, and a multiple-choice grid as a plain group whose checkboxes are each tabbed to.
Install
$
npx shadcn@latest add @vibra/selectable-cardNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { CloudIcon, ServerIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { SelectableCard } from "@/components/ui/selectable-card"
export default function SelectableCardDemo() {
const [managed, setManaged] = React.useState(true)
const [selfHosted, setSelfHosted] = React.useState(false)
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<SelectableCard
icon={<CloudIcon />}
title="Managed hosting"
description="We run the cluster, patch it, and page ourselves at 3am"
badge={<Badge variant="secondary">Recommended</Badge>}
selected={managed}
onSelectedChange={setManaged}
/>
<SelectableCard
icon={<ServerIcon />}
title="Self-hosted"
description="Run the agent in your own VPC and keep every byte in it"
selected={selfHosted}
onSelectedChange={setSelfHosted}
/>
</div>
)
}Single and multiple groups
A plan picker holding one choice, and a source picker holding several.
"use client"
import * as React from "react"
import { DatabaseIcon, GitBranchIcon, TerminalIcon } from "lucide-react"
import { SelectableCardGroup } from "@/components/ui/selectable-card"
const PLANS = [
{ value: "starter", title: "Starter", description: "5 seats, 10k events a month" },
{ value: "team", title: "Team", description: "30 seats, 250k events a month" },
{ value: "scale", title: "Scale", description: "Unlimited seats, custom limits" },
]
const SOURCES = [
{ value: "postgres", title: "Postgres", description: "Logical replication", icon: <DatabaseIcon /> },
{ value: "repository", title: "Git repository", description: "Push and pull request events", icon: <GitBranchIcon /> },
{ value: "cli", title: "CLI", description: "Batch uploads", icon: <TerminalIcon /> },
]
export default function SelectableCardGroupDemo() {
const [plan, setPlan] = React.useState<string | null>("team")
const [sources, setSources] = React.useState<string[]>(["postgres"])
return (
<div className="flex w-full max-w-lg flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Plan</span>
<SelectableCardGroup
type="single"
aria-label="Plan"
value={plan}
onValueChange={setPlan}
columns={3}
options={PLANS}
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Data sources</span>
<SelectableCardGroup
type="multiple"
aria-label="Data sources"
value={sources}
onValueChange={setSources}
columns={1}
options={SOURCES}
/>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| selected | boolean | — | Whether this card is chosen. |
| onSelectedChange | (selected: boolean) => void | — | Called with the state the card moves to when it is activated. |
| title | React.ReactNode | — | The card's heading. |
| description | React.ReactNode | — | A line or two under the title saying what the choice means. |
| icon | React.ReactNode | — | Sits before the title; sized to 4 unless it sets its own size. |
| badge | React.ReactNode | — | Sits beside the title, e.g. a Most popular badge. |
| indicator | "radio" | "checkbox" | "none" | "checkbox" | Picks the mark and the role; none hides the mark and keeps checkbox behaviour. |
| disabled | boolean | false | Dims the card and stops it responding. |
| SelectableCardGroup.type | "single" | "multiple" | — | single holds one value or null; multiple holds an array. |
| SelectableCardGroup.value | string | null | string[] | — | The chosen value for a single group, or the chosen values for a multiple one. |
| SelectableCardGroup.onValueChange | (value: string | null | string[]) => void | — | Called with the whole new selection. |
| SelectableCardGroup.columns | 1 | 2 | 3 | 4 | 2 | Columns at the widest breakpoint; the grid steps down to one on small screens. |
| SelectableCardGroup.options | { value: string; title: React.ReactNode; description?: React.ReactNode; icon?: React.ReactNode; badge?: React.ReactNode; disabled?: boolean }[] | — | The cards, in the order they are shown. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const COLUMN_CLASSES = {
1: "grid-cols-1",
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
} as const
// "title" is omitted as well as "value": the button's own title attribute is a
// tooltip string, and this one is the card's heading.
export type SelectableCardProps = Omit<React.ComponentProps<"button">, "value" | "title"> & {
selected: boolean
onSelectedChange?: (selected: boolean) => void
title: React.ReactNode
description?: React.ReactNode
/** Sits before the title; sized to 4 unless it sets its own size. */
icon?: React.ReactNode
/** Sits beside the title, e.g. a "Most popular" badge. */
badge?: React.ReactNode
/** Picks the mark and the role: radio for one of many, checkbox otherwise. */
indicator?: "radio" | "checkbox" | "none"
disabled?: boolean
}
/** A card-sized radio or checkbox for picking a plan, a mode, or a destination. */
function SelectableCard({
className,
selected,
onSelectedChange,
title,
description,
icon,
badge,
indicator = "checkbox",
disabled = false,
onClick,
...props
}: SelectableCardProps) {
const isRadio = indicator === "radio"
return (
<button
type="button"
// A radio never unchecks itself; a checkbox toggles.
role={isRadio ? "radio" : "checkbox"}
aria-checked={selected}
data-slot="selectable-card"
data-selected={selected || undefined}
data-indicator={indicator}
disabled={disabled}
onClick={(event) => {
onClick?.(event)
onSelectedChange?.(isRadio ? true : !selected)
}}
className={cn(
// A disabled card mutes its text and dashes its border instead of fading the
// whole card: the reason it is disabled (an "Expired" pill, a note) stays
// readable at full contrast rather than compositing at half opacity.
"relative flex w-full items-start gap-3 rounded-lg border border-input bg-card p-3 text-start transition-colors hover:bg-accent/40 focus-ring disabled:pointer-events-none disabled:border-dashed disabled:text-muted-foreground",
// Chosen is a fill, never a stroke: the indicator carries the ink.
selected && "bg-brand-muted",
className
)}
{...props}
>
{icon ? (
<span
data-slot="selectable-card-icon"
aria-hidden="true"
className={cn(
"mt-0.5 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
selected ? "text-foreground" : "text-muted-foreground"
)}
>
{icon}
</span>
) : null}
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center gap-2">
<span data-slot="selectable-card-title" className="truncate text-sm font-medium">
{title}
</span>
{badge}
</span>
{description ? (
<span
data-slot="selectable-card-description"
className="text-xs leading-relaxed text-muted-foreground"
>
{description}
</span>
) : null}
</span>
{indicator === "none" ? null : (
<span
data-slot="selectable-card-indicator"
aria-hidden="true"
className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center border border-input text-primary-foreground transition-colors",
isRadio ? "rounded-full" : "rounded-[4px]",
selected && "border-primary bg-primary"
)}
>
{isRadio ? (
<span
className={cn(
"size-1.5 rounded-full bg-current transition-opacity",
selected ? "opacity-100" : "opacity-0"
)}
/>
) : (
<CheckIcon
className={cn("size-3 transition-opacity", selected ? "opacity-100" : "opacity-0")}
/>
)}
</span>
)}
</button>
)
}
export type SelectableCardOption = {
value: string
title: React.ReactNode
description?: React.ReactNode
icon?: React.ReactNode
badge?: React.ReactNode
disabled?: boolean
}
export type SelectableCardGroupProps<T extends "single" | "multiple"> =
React.ComponentProps<"div"> & {
type: T
value: T extends "single" ? string | null : string[]
onValueChange: (value: T extends "single" ? string | null : string[]) => void
columns?: 1 | 2 | 3 | 4
options: SelectableCardOption[]
}
/** A grid of selectable cards holding one choice or several. */
function SelectableCardGroup<T extends "single" | "multiple">({
className,
type,
value,
onValueChange,
columns = 2,
options,
...props
}: SelectableCardGroupProps<T>) {
const single = type === "single"
const selectedValues = single ? (value ? [value as string] : []) : (value as string[])
const cardRefs = React.useRef(new Map<string, HTMLButtonElement | null>())
// The handler's parameter is conditional on T, which no call site inside a
// generic body can satisfy; the branches below pass the right shape.
const change = onValueChange as (next: string | null | string[]) => void
function select(option: SelectableCardOption, next: boolean) {
if (single) {
change(next ? option.value : null)
return
}
const current = value as string[]
change(next ? [...current, option.value] : current.filter((item) => item !== option.value))
}
// Roving arrow keys, but only for the radiogroup: a set of checkboxes is
// tabbed through one by one.
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
if (!single) return
const forward = event.key === "ArrowRight" || event.key === "ArrowDown"
const back = event.key === "ArrowLeft" || event.key === "ArrowUp"
if (!forward && !back) return
const direction = forward ? 1 : -1
event.preventDefault()
const count = options.length
for (let step = 1; step <= count; step += 1) {
const next = options[(((index + direction * step) % count) + count) % count]
if (next && !next.disabled) {
change(next.value)
cardRefs.current.get(next.value)?.focus()
return
}
}
}
// Only one card in a radiogroup is in the tab order: the chosen one, or the
// first that can take the choice.
const chosen = options.find((option) => option.value === selectedValues[0] && !option.disabled)
const firstEnabled = options.find((option) => !option.disabled)
const tabStop = single ? ((chosen ?? firstEnabled)?.value ?? null) : null
return (
<div
data-slot="selectable-card-group"
data-type={type}
data-columns={columns}
role={single ? "radiogroup" : "group"}
className={cn("grid w-full gap-3", COLUMN_CLASSES[columns], className)}
{...props}
>
{options.map((option, index) => (
<SelectableCard
key={option.value}
ref={(node) => {
cardRefs.current.set(option.value, node)
}}
selected={selectedValues.includes(option.value)}
onSelectedChange={(next) => select(option, next)}
indicator={single ? "radio" : "checkbox"}
title={option.title}
description={option.description}
icon={option.icon}
badge={option.badge}
disabled={option.disabled}
tabIndex={single ? (option.value === tabStop ? 0 : -1) : undefined}
onKeyDown={(event) => handleKeyDown(event, index)}
/>
))}
</div>
)
}
export { SelectableCard, SelectableCardGroup }