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.
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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/choice-cardIts 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.13import { 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
RadioGroupinstead for plain text choices,SegmentedControlfor two to five short options that switch a view, andSelectfor 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
columnsof 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 islegend:<ChoiceCard legend="Cover photo" />.onChange: Not a prop. Ouro UI saysonValueChange:<ChoiceCard onValueChange={setCover} />.options: Not a prop. The choices areitems:<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
- layout-classes: test:registry/ouro/components/ouro/choice-card/choice-card.test.tsx::ChoiceCard > forwards a ref and lets layout classes win, with no margin of its own
verify:testExists(registry/ouro/components/ouro/choice-card/choice-card.test.tsx::ChoiceCard > forwards a ref and lets layout classes win, with no margin of its own)