Inputs
Choice Cards
A card-shaped radio or checkbox group — icon, title, description and badge per option, with roving-tabindex keyboard selection.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/choice-cards.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ChoiceCards" component using
class-variance-authority (cva) and lucide-react.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>
(minus onChange/defaultValue) plus VariantProps of the card cva.
- options: { value, title, description?, icon?, badge?, disabled? }[].
- Controlled only: value: string | string[] and
onValueChange: (value: string | string[]) => void — a string in single mode,
a string[] when multiple is true. The component owns no selection state.
- multiple?: boolean (default false) picks the semantics: radio or checkbox.
- columns?: 1 | 2 | 3 (default 2), size?: "sm" | "md" | "lg" (default "md"),
label?: string (the group's aria-label, default "Choices"),
className merged via cn(), remaining props spread on the grid root.
Behavior
- Single mode: root is role="radiogroup"; each card is a <button type="button">
with role="radio" + aria-checked. Roving tabindex — exactly one card is
tabbable: the checked one, else the first enabled one. Arrow keys
(Left/Right/Up/Down) move to the next enabled card, wrapping, and select it
in the same step (radio convention); Home/End jump to the first/last enabled
card; Space/Enter select via the native button activation.
- Multiple mode: root is role="group"; each card is role="checkbox" +
aria-checked and is its own tab stop (no roving tabindex, no arrow keys —
Tab walks the group, Space toggles). Toggling appends or removes one value
and preserves the order of the rest.
- disabled options render the native disabled button: not clickable, not
focusable, and skipped by the arrow-key search (which wraps past them).
- Every card is one button, so its title + badge + description form the
accessible name; consumers wire nothing per card.
Rendering & styling
- Root: grid w-full items-stretch gap-3, column classes per `columns`
(1 / 1→2 at sm / 1→2 at sm→3 at lg). Cards are h-full so a row's cards
match the tallest one; descriptions are never clamped or truncated —
the card grows.
- Card: rounded-lg border border-border bg-card, cva size axis controls only
padding + gap; a small parallel lookup scales title/description/icon.
Selected: border-primary + bg-primary/5 + ring-2 ring-primary.
Hover: border-primary/50 + bg-accent/40. Focus: focus-visible ring-2
ring-ring with ring-offset-background.
- Selection mark sits top-right as a third flex child that is always rendered
(empty circle when unselected) so selecting never reflows the card; it is a
circle for radio semantics and a rounded square for checkbox semantics, and
fills with bg-primary + a lucide Check when selected. It is aria-hidden —
aria-checked already carries the state.
- Semantic tokens only: bg-card, border-border, bg-primary/5, ring-primary,
bg-accent/40, text-muted-foreground, text-primary-foreground. No hex.
- Motion is decoration: the only animations are a color/border/shadow
transition and active:scale-[0.99], both cancelled by motion-reduce; the
ring, tint and check still switch instantly with animations off.
Customization levers
- Density: the cva size axis (padding + gap) and its paired type lookup are
the two places to touch; adding an "xl" step is one entry in each.
- Sub-blocks: icon, badge and description are all optional per option — drop
them for a bare title grid, or add a right-aligned price span next to the
title row without touching the selection logic.
- Grid rhythm: swap the `columns` classes for a container-query or auto-fit
track (repeat(auto-fit,minmax(220px,1fr))) if the group lives in a narrow
panel where viewport breakpoints lie.
- Selection accent: the selected state reads border-primary / bg-primary/5 /
ring-primary — repoint all three at a chart token for a per-category accent,
or drop the ring and keep only the tint for a quieter look.
- Mark shape: circle vs rounded square is derived from `multiple`; force one
shape if your design system always uses a check mark.
- Semantics: keep `multiple` as the single switch — every ARIA role, tab
behaviour and payload shape is derived from it, so nothing else changes when
a "pick one" group becomes "pick many".Concepts
- Card as radio — the whole card is one
<button>carryingrole="radio"(orrole="checkbox"), so the icon, title, badge and description all become its accessible name and the entire surface is the hit target, not a tiny dot. - Roving tabindex — a radio group is one tab stop: only the checked card (or the first enabled one) is tabbable, and arrow keys move and select in a single step, matching how native radios behave.
- Semantics derived from one flag —
multipledecides the root role, the item role, whether tabindex roves, and whether the payload is a string or an array; call sites never assemble ARIA by hand. - Reserved mark slot — the corner indicator is rendered even when unselected (as an empty outline), so choosing an option changes colors, never layout.
- Equal-height rows —
items-stretchplush-fullcards means a long description grows its whole row instead of overflowing or being clamped; comparison stays honest. - Reduced-motion honesty — the press-scale and color transition are the only motion, both dropped under
prefers-reduced-motion; selection is still fully readable through the ring, tint and check.
Inline Edit
Click-to-edit text that swaps a button for an input in place — pending state on async saves, rollback with the failure reason, and no width jump.
Filter Chips
A wrapping row of toggle chips for narrowing a list — per-facet counts, single or multi select, and a Clear all that only appears when something is on.