ChoiceCard
A choice drawn as cards, such as a saved payment method or a delivery speed. Each ChoiceCard is a label around a real, visible native radio, with slots for a leading mark (a card brand), a second line and trailing content such as a "Default" Badge. ChoiceCardGroup is the radio group; inside a Field, give the Field group, and its error marks the chosen card invalid.
Import
import { ChoiceCard, ChoiceCardGroup } from '@nizivo/react/choice-card';
// Once per app (Getting started), after
// @nizivo/tokens/tokens.css and @nizivo/react/css/base.css
import '@nizivo/react/css/choice-card.css';Static or interactive
Client. It always ships its client leaf, which wires it to a Field. The server HTML is a native radio group, so arrows and submission work before hydration. How to choose.
In a client app (a Vite or React Router single-page app, or a 'use client' file) it is an ordinary React component: event handlers, refs and state work as usual. In a client app.
Anatomy and names
The radio is a real one, visible at the card's start (Figma drew an ellipse with a thick stroke).
children is the radio's name; description is a second line that describes it
(aria-describedby) instead of joining the name; trailing sits at the card's end and its text
joins the name after children. The "Default" Badge is composition: put it in trailing. leading
is decorative, so a card brand's mark needs the brand's name in the text too, for example a visually
hidden "Visa ending in" before the digits. Brand marks have their own rules, so they are the app's to
supply.
Invalid: an expired or declined card
Put the group in a Field with group and pass the Field an error, such as "This card has expired.
Choose another card or add one." The Field's label names the group, its error describes it, and the
chosen card's ring turns critical (with nothing chosen, every radio's edge does, as in RadioGroup).
No size jump
The edge is an inset ring, not a border: selecting fills the card with bg/brand-subtle and turns
the edge into a 2 px brand ring in the same box, so a card never grows on select (Figma's grew from
73 to 74 px). With 16 px padding and two Body/M lines a card is 76 px high.
Examples
Basic
Saved payment methods: native radios, so arrow keys move the choice and the form submits it with no JavaScript. The "Default" Badge is composition, at the card's end; its text joins the name.
import type { JSX } from 'react';
import { Badge } from '@nizivo/react/badge';
import { Field } from '@nizivo/react/field';
import { ChoiceCard, ChoiceCardGroup } from '@nizivo/react/choice-card';
/**
* Stands in for a card brand's mark (Figma's 44 × 30 Mark). Brand marks have their own rules, so
* they are the app's to supply; the brand's name is in the text too.
*/
function Mark({ brand }: { brand: string }): JSX.Element {
return (
<span
style={{
display: 'inline-grid',
placeItems: 'center',
inlineSize: 44,
blockSize: 30,
borderRadius: 'var(--nz-radius-xs)',
background: 'var(--nz-bg-inverse)',
color: 'var(--nz-text-inverse)',
font: 'var(--nz-font-label-s)',
}}
>
{brand}
</span>
);
}
/** The card's number in Mono, with the brand spelled out for assistive technology. */
function Last4({ brand, last4 }: { brand: string; last4: string }): JSX.Element {
return (
<span style={{ font: 'var(--nz-font-mono-m-strong)' }}>
<span className="nz-visually-hidden">{`${brand} ending in `}</span>
<span aria-hidden="true">•••• </span>
{last4}
</span>
);
}
export function ChoiceCardExample(): JSX.Element {
return (
<Field label="Payment method" name="paymentMethod" group>
<ChoiceCardGroup defaultValue="pm_1" style={{ maxInlineSize: 430 }}>
<ChoiceCard
value="pm_1"
leading={<Mark brand="VISA" />}
description="Expires 08/28"
trailing={<Badge dot={false}>Default</Badge>}
>
<Last4 brand="Visa" last4="4242" />
</ChoiceCard>
<ChoiceCard value="pm_2" leading={<Mark brand="MC" />} description="Expires 11/27">
<Last4 brand="Mastercard" last4="5100" />
</ChoiceCard>
</ChoiceCardGroup>
</Field>
);
}
Delivery
Any choice that needs a second line: delivery speeds, with one not available today.
import type { JSX } from 'react';
import { Field } from '@nizivo/react/field';
import { ChoiceCard, ChoiceCardGroup } from '@nizivo/react/choice-card';
export function ChoiceCardDeliveryExample(): JSX.Element {
return (
<Field label="Delivery" name="delivery" group required>
<ChoiceCardGroup style={{ maxInlineSize: 430 }}>
<ChoiceCard value="standard" description="3–5 working days · Free">
Standard
</ChoiceCard>
<ChoiceCard value="express" description="Next working day · ₹120">
Express
</ChoiceCard>
<ChoiceCard value="same-day" description="Order before 12:00 · ₹240" disabled>
Same day
</ChoiceCard>
</ChoiceCardGroup>
</Field>
);
}
Invalid
Invalid through the Field: a declined or expired card. The Field's error describes the group, and the chosen card's ring turns critical.
import type { JSX } from 'react';
import { Field } from '@nizivo/react/field';
import { ChoiceCard, ChoiceCardGroup } from '@nizivo/react/choice-card';
/**
* Stands in for a card brand's mark (Figma's 44 × 30 Mark). Brand marks have their own rules, so
* they are the app's to supply; the brand's name is in the text too.
*/
function Mark({ brand }: { brand: string }): JSX.Element {
return (
<span
style={{
display: 'inline-grid',
placeItems: 'center',
inlineSize: 44,
blockSize: 30,
borderRadius: 'var(--nz-radius-xs)',
background: 'var(--nz-bg-inverse)',
color: 'var(--nz-text-inverse)',
font: 'var(--nz-font-label-s)',
}}
>
{brand}
</span>
);
}
/** The card's number in Mono, with the brand spelled out for assistive technology. */
function Last4({ brand, last4 }: { brand: string; last4: string }): JSX.Element {
return (
<span style={{ font: 'var(--nz-font-mono-m-strong)' }}>
<span className="nz-visually-hidden">{`${brand} ending in `}</span>
<span aria-hidden="true">•••• </span>
{last4}
</span>
);
}
export function ChoiceCardInvalidExample(): JSX.Element {
return (
<Field
label="Payment method"
name="paymentMethodRetry"
group
error="This card has expired. Choose another card or add one."
>
<ChoiceCardGroup defaultValue="pm_3" style={{ maxInlineSize: 430 }}>
<ChoiceCard value="pm_3" leading={<Mark brand="AMEX" />} description="Expired 09/26">
<Last4 brand="American Express" last4="0005" />
</ChoiceCard>
<ChoiceCard value="pm_1" leading={<Mark brand="VISA" />} description="Expires 08/28">
<Last4 brand="Visa" last4="4242" />
</ChoiceCard>
</ChoiceCardGroup>
</Field>
);
}
Props
ChoiceCard
Client component Renders data-slot="choice-card" with the class nz-choice-card. Props: ChoiceCardProps. Source: src/choice-card/choice-card.client.tsx.
| Name | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | – | The radio's name, such as "Visa ending in 4242". |
description | ReactNode | – | A second line, such as "Expires 08/28". It describes the radio ( |
disabled | boolean | – | |
leading | ReactNode | – | A mark after the radio, such as a card brand's logo (Figma's 44 × 30 Mark); decorative, so put the brand's name in the text too. |
render | RenderProp<ChoiceCardState> | – | |
trailing | ReactNode | – | Content at the card's end, such as a "Default" |
valuerequired | string | – | This card's value: what |
- Also takes every attribute of
<label>excepthtmlFor;childrenis redeclared above. (Omit<ComponentPropsWithRef<"label">, "htmlFor">)
render takes an element, or, in client code, a function of the props and this state (ChoiceCardState):
| Name | Type | Default | Description |
|---|---|---|---|
disabledrequired | boolean | – |
ChoiceCardGroup
Client component Renders data-slot="choice-card-group" with the class nz-choice-card-group. Props: ChoiceCardGroupProps. Source: src/choice-card/choice-card.client.tsx.
| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – |
|
defaultValue | string | – | The card checked at first (uncontrolled; |
disabled | boolean | – | Disables every card. |
form | string | – | The |
invalid | boolean | – | An error message is presented (set by a Field): the checked card, or every card when none is checked, shows the critical edge. |
name | string | – | The radios' shared name, and the |
onValueChange | (value: string, details: ChangeDetails<ChoiceCardChangeReason>) => void | – | Called when the user checks a card; |
render | RenderProp<ChoiceCardGroupState> | – | |
required | boolean | – | A choice must be made before the form submits. |
value | string | null | – | Controlled: the checked card's |
- Also takes every attribute of
<div>exceptdefaultValue,onChange,role;childrenis redeclared above. (Omit<ComponentPropsWithRef<"div">, "defaultValue" | "onChange" | "role">)
render takes an element, or, in client code, a function of the props and this state (ChoiceCardGroupState):
| Name | Type | Default | Description |
|---|---|---|---|
disabledrequired | boolean | – | |
invalidrequired | boolean | – |
Other exports
ChoiceCardChangeReasontypeimport type { ChoiceCardChangeReason } from '@nizivo/react/choice-card'Why the value changed: the user checked a radio (a pointer or a key).
type ChoiceCardChangeReason = "input"ChoiceCardGroupPropstypeimport type { ChoiceCardGroupProps } from '@nizivo/react/choice-card'The props of
ChoiceCardGroup, listed above.ChoiceCardGroupStatetypeimport type { ChoiceCardGroupState } from '@nizivo/react/choice-card'The render state of
ChoiceCardGroup, listed above.ChoiceCardPropstypeimport type { ChoiceCardProps } from '@nizivo/react/choice-card'The props of
ChoiceCard, listed above.ChoiceCardStatetypeimport type { ChoiceCardState } from '@nizivo/react/choice-card'The render state of
ChoiceCard, listed above.
Data attributes and slots
Style parts by class (.nz-<slot>) and state by data attribute. Attribute meanings come from contracts/vocabulary.json.
| Slot | Rendered by | Data attributes |
|---|---|---|
choice-card.nz-choice-card | ChoiceCardroot (takes render); interactive, focus: nested-input; forced colours: border, outline on :has(> input:checked) |
|
choice-card-content.nz-choice-card-content | ChoiceCardinner element (CSS only) | none |
choice-card-description.nz-choice-card-description | ChoiceCardinner element (CSS only) | none |
choice-card-group.nz-choice-card-group | ChoiceCardGrouproot (takes render) |
|
choice-card-input.nz-choice-card-input | ChoiceCardinner element (CSS only); forced colours: border, fill on :checked | none |
choice-card-label.nz-choice-card-label | ChoiceCardinner element (CSS only) | none |
choice-card-leading.nz-choice-card-leading | ChoiceCardinner element (CSS only) | none |
choice-card-trailing.nz-choice-card-trailing | ChoiceCardinner element (CSS only) | none |
CSS hooks
Set a hook on the component's class or any ancestor to restyle it without specificity games; leave it unset for the default shown.
| Hook | Default | Read as |
|---|---|---|
--nz-choice-card-accent | --nz-bg-brand |
|
--nz-choice-card-bg | --nz-bg-surface |
|
--nz-choice-card-bg-selected | --nz-bg-brand-subtle |
|
--nz-choice-card-description-fg | --nz-text-secondary |
|
--nz-choice-card-edge-selected | --nz-border-brand |
|
--nz-choice-card-fg | --nz-text-primary |
|
--nz-choice-card-gap | --nz-space-3 |
|
--nz-choice-card-group-gap | --nz-space-3 |
|
--nz-choice-card-padding | --nz-space-4 |
|
--nz-choice-card-radius | --nz-radius-lg |
|
Layers: nz.components. Classes: .nz-choice-card, .nz-choice-card-content, .nz-choice-card-description, .nz-choice-card-group, .nz-choice-card-input, .nz-choice-card-label, .nz-choice-card-leading, .nz-choice-card-trailing.
Tokens this stylesheet reads (30)
--nz-bg-brand, --nz-bg-brand-subtle, --nz-bg-disabled, --nz-bg-muted, --nz-bg-surface, --nz-border-brand, --nz-border-control, --nz-border-control-hover, --nz-border-critical, --nz-border-default, --nz-border-strong, --nz-border-subtle, --nz-border-width-default, --nz-border-width-focus, --nz-duration-fast, --nz-easing-standard, --nz-font-body-m, --nz-font-body-m-strong, --nz-radius-full, --nz-radius-lg, --nz-size-control-lg, --nz-space-0-5, --nz-space-1, --nz-space-2, --nz-space-3, --nz-space-4, --nz-text-disabled, --nz-text-on-brand, --nz-text-primary, --nz-text-secondary
Accessibility
These parts are interactive. Each gets the focus outline and touch-action rule from @nizivo/react/css/base.css (the nz.base layer), so the floor survives a restyle:
choice-card: draws the outline when the hidden input inside it has keyboard focus.
In forced-colours mode these states keep a visible system colour: choice-card (border), choice-card:has(> input:checked) (outline), choice-card-input (border), choice-card-input:checked (fill).
Pattern: WAI-ARIA Authoring Practices.
Keyboard
| Keys | Action |
|---|---|
| Tab | Moves focus into the group, to the checked card; the next Tab leaves it. |
| ArrowDown, ArrowRight | Checks the next card and focuses it, skipping disabled ones. |
| ArrowUp, ArrowLeft | Checks the previous card and focuses it, skipping disabled ones. |
Source: native <input type="radio"> elements inside <label>s (choice-card.client.tsx); its only key handler marks the card data-focus-visible, because WebKit matches no :focus-visible on a radio reached by an arrow key. The browser lane (test/browser/choice-card.spec.tsx) checks Tab entry and exit, arrows and skipping a disabled card.
Figma
Node ids from design/component-map.json, which nz gen writes from component.json. The links open the Nizivo Figma library, which may ask you to sign in.
- Canonical: Figma node
7275:14174 - Ghost masters: Figma node
754:644
ChipGroup
Pill-shaped choices such as a product's colours or sizes, or filters. Native radios inside labels for one value, or checkboxes with multiple, so it submits, resets and works before hydration. Swatches go in each Chip's leading slot; an unavailable chip keeps its colour and is struck through. Inside a Field, give the Field group.
Combobox
A text input that suggests items from a list as the reader types, such as a country in a checkout form or search suggestions. Input's box, with Select's popup and rows; React Aria's ComboBox inside.