Skip to content
Nizivo UI

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

payment-method.css
@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.

On this page