Forms

ChoiceCard

A set of choices shown as cards, each with a picture, a label and a line of description, for picking exactly one: a cover photo, a plan, a layout. The cards are a native radio group under a legend, so the arrow keys move between them, a plain form submits the chosen value, and screen readers hear "Cover photo, radio group" and each card's label and description. The chosen card takes the primary color and a check.

Theme

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

See it live, no sign-in needed: API playground

In its source: app/components/recipe/RecipeCoverHistory.tsx · app/routes/developers.playground.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/choice-card#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/choice-card

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/choice-card#v1.2.13 ourostack/ouro-ui/choice-card-tests#v1.2.13
import { ChoiceCard } from "@/components/ouro/choice-card/choice-card";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<ChoiceCard
  legend="Cover photo"
  name="cover"
  items={photos.map((photo) => ({ value: photo.id, label: photo.title, media: <img src={photo.url} alt="" /> }))}
/>

Use instead when

  • Use RadioGroup instead for plain text choices, SegmentedControl for two to five short options that switch a view, and Select for a long list.

Props

Type: ChoiceCardProps.

className
Type: string
Layout only: margins, width (mt-6). Appearance classes warn in development.
columns
Type: 2 | 3 | 4
Default: 3
How many cards a row holds on a wide screen; on a phone each row holds one, or two with columns of 3 or more.
defaultValue
Type: string
The value chosen at first, for an uncontrolled group.
description
Type: ReactNode
Supporting text under the legend, read with the group.
disabled
Type: boolean
Disables every card.
error
Type: ReadonlyErrorMessages
What is wrong, shown under the cards in the destructive tone and read with each choice.
hideLegend
Type: boolean
Keeps the legend for screen readers only, when a heading above already says it.
items (required)
Type: readonly ChoiceCardItem[]
The choices.
legend (required)
Type: string
What is being chosen, above the cards and read as the group's name.
name
Type: string
The form field name the chosen value submits under.
onValueChange
Type: ((value: string) => void)
Called with the value of the card the person chooses.
ref
Type: Ref<HTMLFieldSetElement>
Reaches the rendered element.
required
Type: boolean
A choice must be made before the form submits; the legend shows the required mark.
value
Type: string
The chosen value, for a controlled group; pair it with onValueChange.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • label: Not a prop. The group's name is legend: <ChoiceCard legend="Cover photo" />.
  • onChange: Not a prop. Ouro UI says onValueChange: <ChoiceCard onValueChange={setCover} />.
  • options: Not a prop. The choices are items: <ChoiceCard items={[{ value, label, media }]} />.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: ChoiceCard merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

ChoiceCardItem

One card in a ChoiceCard's items.

interface ChoiceCardItem {
  /** What the group submits when this card is chosen, and its `value` then. */
  value: string;
  /** The choice's name, under its media. */
  label: string;
  /** Supporting text under the label, read after it. */
  description?: ReactNode | undefined;
  /**
   * A picture of the choice above its label, such as `<img src={cover} alt="" />`: give it
   * `alt=""` when the label says what it shows. It fills the card's width.
   */
  media?: ReactNode | undefined;
  /** Disables this one card; the arrow keys skip it. */
  disabled?: boolean | undefined;
}

ErrorMessage

One error message: text, or a React element for a message that carries a link or other markup.

type ErrorMessage = string | ReactElement;

ReadonlyErrorMessages

A read-only message view accepted by fields and Form; mutable input arrays remain valid.

type ReadonlyErrorMessages = ErrorMessage | readonly ErrorMessage[];

Claim sources