Skip to content
Nizivo UI

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.

Tier: client

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.

This card has expired. Choose another card or add one.
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.

NameTypeDefaultDescription
childrenrequiredReactNode–

The radio's name, such as "Visa ending in 4242".

descriptionReactNode–

A second line, such as "Expires 08/28". It describes the radio (aria-describedby) instead of joining its name.

disabledboolean–
leadingReactNode–

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.

renderRenderProp<ChoiceCardState>–
trailingReactNode–

Content at the card's end, such as a "Default" Badge (Figma's Show default): composition, not a prop. Its text joins the radio's name, after children.

valuerequiredstring–

This card's value: what onValueChange receives and the form submits.

  • Also takes every attribute of <label> except htmlFor; children is redeclared above. (Omit<ComponentPropsWithRef<"label">, "htmlFor">)

render takes an element, or, in client code, a function of the props and this state (ChoiceCardState):

NameTypeDefaultDescription
disabledrequiredboolean–

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.

NameTypeDefaultDescription
childrenReactNode–

ChoiceCard elements, possibly with other content between them (an "Add" row).

defaultValuestring–

The card checked at first (uncontrolled; form.reset() returns to it).

disabledboolean–

Disables every card.

formstring–

The <form> the radios belong to, when they are outside it.

invalidboolean–

An error message is presented (set by a Field): the checked card, or every card when none is checked, shows the critical edge.

namestring–

The radios' shared name, and the FormData key. Without one (and outside a named Field), a generated name groups the radios.

onValueChange(value: string, details: ChangeDetails<ChoiceCardChangeReason>) => void–

Called when the user checks a card; details.event is the native change event.

renderRenderProp<ChoiceCardGroupState>–
requiredboolean–

A choice must be made before the form submits.

valuestring | null–

Controlled: the checked card's value, or null for none.

  • Also takes every attribute of <div> except defaultValue, onChange, role; children is 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):

NameTypeDefaultDescription
disabledrequiredboolean–
invalidrequiredboolean–

Other exports

ChoiceCardChangeReason type

import type { ChoiceCardChangeReason } from '@nizivo/react/choice-card'

Why the value changed: the user checked a radio (a pointer or a key).

type ChoiceCardChangeReason = "input"

ChoiceCardGroupProps type

import type { ChoiceCardGroupProps } from '@nizivo/react/choice-card'

The props of ChoiceCardGroup, listed above.

ChoiceCardGroupState type

import type { ChoiceCardGroupState } from '@nizivo/react/choice-card'

The render state of ChoiceCardGroup, listed above.

ChoiceCardProps type

import type { ChoiceCardProps } from '@nizivo/react/choice-card'

The props of ChoiceCard, listed above.

ChoiceCardState type

import 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.

SlotRendered byData attributes
choice-card
.nz-choice-card
ChoiceCard
root (takes render); interactive, focus: nested-input; forced colours: border, outline on :has(> input:checked)
  • data-disabled (present or absent) ← disabled
    Mirrors the disabled attribute
choice-card-content
.nz-choice-card-content
ChoiceCard
inner element (CSS only)
none
choice-card-description
.nz-choice-card-description
ChoiceCard
inner element (CSS only)
none
choice-card-group
.nz-choice-card-group
ChoiceCardGroup
root (takes render)
  • data-disabled (present or absent) ← disabled
    Mirrors the disabled attribute
  • data-invalid (present or absent) ← invalid
    An error message is currently presented (not "fails constraints"); always the Field's own state, never the engine's
  • data-required (present or absent) ← required
    Mirrors the required attribute
choice-card-input
.nz-choice-card-input
ChoiceCard
inner element (CSS only); forced colours: border, fill on :checked
none
choice-card-label
.nz-choice-card-label
ChoiceCard
inner element (CSS only)
none
choice-card-leading
.nz-choice-card-leading
ChoiceCard
inner element (CSS only)
none
choice-card-trailing
.nz-choice-card-trailing
ChoiceCard
inner 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.

HookDefaultRead as
--nz-choice-card-accent--nz-bg-brand
  • --_nz-choice-card-radio-border on .nz-choice-card-input:checked
  • --_nz-choice-card-radio-ring on .nz-choice-card-input:checked
--nz-choice-card-bg--nz-bg-surface
  • background on .nz-choice-card
--nz-choice-card-bg-selected--nz-bg-brand-subtle
  • background on .nz-choice-card:has( > input:checked)
--nz-choice-card-description-fg--nz-text-secondary
  • color on .nz-choice-card-description
--nz-choice-card-edge-selected--nz-border-brand
  • --_nz-choice-card-edge on .nz-choice-card:has( > input:checked)
--nz-choice-card-fg--nz-text-primary
  • color on .nz-choice-card
--nz-choice-card-gap--nz-space-3
  • gap on .nz-choice-card
--nz-choice-card-group-gap--nz-space-3
  • gap on .nz-choice-card-group
--nz-choice-card-padding--nz-space-4
  • padding on .nz-choice-card
--nz-choice-card-radius--nz-radius-lg
  • border-radius on .nz-choice-card

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

KeysAction
TabMoves focus into the group, to the checked card; the next Tab leaves it.
ArrowDown, ArrowRightChecks the next card and focuses it, skipping disabled ones.
ArrowUp, ArrowLeftChecks 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.

On this page