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…

"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"

export interface ChoiceCardOption {
  value: string
  title: string
  /** Free-form supporting copy — never truncated, the card grows instead. */
  description?: string
  /** Decorative glyph left of the title; sized by the `size` variant. */
  icon?: React.ReactNode

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/choice-cards.json

Prompt

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> carrying role="radio" (or role="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 flagmultiple decides 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 rowsitems-stretch plus h-full cards 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.

On This Page