Payment method
A choice of saved cards, one ChoiceCard of a native radio group each, the brand in words, the last four digits, the expiry spoken as a month and year, and the default marked with a Badge.
From Figma 05 Commerce, Payment Method (7275:14174; Default 7275:14175, Selected 7275:14183).
Uses ChoiceCard, Field,
Badge, Button and
Stack, with the CreditCard and Plus icons from
@nizivo/icons.
Examples
Saved cards
Figma 05's Payment Method (7275:14174) as a choice of saved cards: a ChoiceCard per card, native radios in a Field that names the group, so arrow keys move the choice and the form submits it. Each card is read as "Visa ending in 4242, Default", described by "Expires August 2028". The generic mark stands in for a card brand's logo, which is the app's to supply under the brand's rules.
import type { ComponentPropsWithRef, JSX } from 'react';
import { CreditCard, Plus } from '@nizivo/icons';
import { Badge } from '@nizivo/react/badge';
import { Button } from '@nizivo/react/button';
import { ChoiceCard, ChoiceCardGroup } from '@nizivo/react/choice-card';
import { Field } from '@nizivo/react/field';
import { Stack } from '@nizivo/react/stack';
// The spoken expiry, "August 2028": an explicit locale and time zone, so the server and the browser
// print the same text.
const EXPIRY = new Intl.DateTimeFormat('en-US', {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
interface SavedCard {
id: string;
brand: string;
last4: string;
/** 1 to 12. */
expMonth: number;
expYear: number;
isDefault: boolean;
}
const CARDS: SavedCard[] = [
{ id: 'pm_1', brand: 'Visa', last4: '4242', expMonth: 8, expYear: 2028, isDefault: true },
{ id: 'pm_2', brand: 'Mastercard', last4: '5100', expMonth: 11, expYear: 2027, isDefault: false },
{
id: 'pm_3',
brand: 'American Express',
last4: '0005',
expMonth: 3,
expYear: 2029,
isDefault: false,
},
];
/** Stands in for your router's link component, such as Next's `Link`. */
function Link({ children, ...props }: ComponentPropsWithRef<'a'>): JSX.Element {
return <a {...props}>{children}</a>;
}
/** "Expires 08/28" on screen; "Expires August 2028" for screen readers. */
function Expiry({ card }: { card: SavedCard }): JSX.Element {
const month = String(card.expMonth).padStart(2, '0');
const year = String(card.expYear % 100).padStart(2, '0');
const spoken = EXPIRY.format(new Date(`${card.expYear}-${month}-01T00:00:00Z`));
return (
<>
<span aria-hidden="true">{`Expires ${month}/${year}`}</span>
<span className="nz-visually-hidden">{`Expires ${spoken}`}</span>
</>
);
}
/** The card's name: the brand in words, the bullets hidden, the last four digits in Mono. */
function CardName({ card }: { card: SavedCard }): JSX.Element {
return (
<>
{card.brand}
<span className="nz-visually-hidden"> ending in</span>{' '}
<span className="nzp-payment-method-number">
<span aria-hidden="true">•••• </span>
{card.last4}
</span>
</>
);
}
export function PaymentMethodExample(): JSX.Element {
return (
<form action="#" aria-label="Payment" className="nzp-payment-method">
<Stack gap="4">
<Field label="Payment method" name="paymentMethod" group required>
<ChoiceCardGroup defaultValue="pm_1">
{CARDS.map((card) => (
<ChoiceCard
key={card.id}
value={card.id}
leading={
<span className="nzp-payment-method-mark">
<CreditCard size={18} />
</span>
}
description={<Expiry card={card} />}
trailing={card.isDefault ? <Badge dot={false}>Default</Badge> : undefined}
>
<CardName card={card} />
</ChoiceCard>
))}
</ChoiceCardGroup>
</Field>
<div>
<Button
variant="tertiary"
leadingIcon={<Plus />}
render={<Link href="#add-payment-method" />}
>
Add a payment method
</Button>
</div>
</Stack>
</form>
);
}
Stylesheet
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
@layer nz.patterns {
.nzp-payment-method {
max-inline-size: 27rem;
}
/* Figma's 44 × 30 card mark: a generic card glyph where the brand's logo goes */
.nzp-payment-method-mark {
display: inline-grid;
flex: none;
place-items: center;
inline-size: 2.75rem;
block-size: 1.875rem;
border-radius: var(--nz-radius-xs);
background: var(--nz-bg-inverse);
color: var(--nz-icon-inverse);
}
/* the last four digits in Mono, kept on one line with their bullets */
.nzp-payment-method-number {
font: var(--nz-font-mono-m-strong);
white-space: nowrap;
}
}
Notes
- Client JavaScript. ChoiceCard and its Field are client components. The radios are native and
in a
<form>, so the choice works and submits before they hydrate. - A radio group. Figma draws a fake radio (an ellipse with a 5 px stroke, 1.57:1 against white). Here each card is a label around a native radio, the group is named "Payment method" by its Field, and arrow keys move the choice.
- Spoken in words. A card's name is "Visa ending in 4242, Default": the bullets are hidden and
"ending in" is visually hidden. Its description is "Expires August 2028" for screen readers, built
with
Intl.DateTimeFormat, while the screen shows Figma's "Expires 08/28". - Brand marks. The generic card glyph stands in for Figma's 44 × 30 mark. Card brands' logos have their own rules and colours, so they are the app's to supply; the brand's name is in the text either way.
- No layout shift. Figma's selected row grows from 73 to 74 px as its stroke thickens; ChoiceCard keeps its size when chosen.
- Adding a card is a link drawn as a tertiary Button, below the group, which Figma does not draw.
Discount card
A discount code, what it gives, its status, how much of it is used as a Meter and its dates, including an expired state that keeps its contrast. A server composition; it ships no JavaScript.
Shipment tracker
A shipment's steps as an ordered list, the current one marked with aria-current="step" and every step's status said in words, a column on a narrow card and a row on a wide one. A server composition; it ships no JavaScript.