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.
From Figma 05 Commerce, Discount Card (7275:14130; variants Active 7275:14131, Scheduled
7275:14144, Expired 7275:14157). Uses Card,
Badge, Meter,
Text and NumberValue, with the Tag
icon from @nizivo/icons.
Examples
Discounts
Figma 05's Discount Card (7275:14130): Active, Scheduled and Expired, plus a code with no usage limit, which Figma does not draw. Usage is a Meter read as "310 of 500 used". The expired card keeps full-contrast text: only its status, its code and its meter turn neutral. A server composition: it ships no JavaScript.
- AUTUMN20Active
20% off all orders
UsageEnds Nov 30, 2026
- FREESHIPScheduled
Free shipping over $50
UsageStarts Nov 1, 2026
- SUMMER15Expired
15% off summer linen
UsageEnded Sep 30, 2026
- WELCOME10Active
10% off a first order
1,284 used · no limit
Ends Dec 31, 2026
import type { JSX } from 'react';
import { Tag } from '@nizivo/icons';
import { Badge, type BadgeTone } from '@nizivo/react/badge';
import { Card } from '@nizivo/react/card';
import { Meter } from '@nizivo/react/meter';
import { NumberValue } from '@nizivo/react/number-value';
import { Text } from '@nizivo/react/text';
type Status = 'active' | 'scheduled' | 'expired';
/** Each status's words and tone. The words carry the meaning; the tone only repeats it. */
const STATUS: Record<Status, { label: string; tone: BadgeTone }> = {
active: { label: 'Active', tone: 'success' },
scheduled: { label: 'Scheduled', tone: 'info' },
expired: { label: 'Expired', tone: 'neutral' },
};
// An explicit locale and time zone, so the server and the browser print the same text.
const DAY = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
const COUNT = new Intl.NumberFormat('en-US');
interface Discount {
code: string;
title: string;
status: Status;
used: number;
/** The most uses allowed; `null` for no limit. */
limit: number | null;
/** A UTC instant: when it starts (scheduled) or when it ends (active, expired). */
date: string;
}
const DISCOUNTS: Discount[] = [
{
code: 'AUTUMN20',
title: '20% off all orders',
status: 'active',
used: 310,
limit: 500,
date: '2026-11-30T23:59:00Z',
},
{
code: 'FREESHIP',
title: 'Free shipping over $50',
status: 'scheduled',
used: 0,
limit: 1000,
date: '2026-11-01T00:00:00Z',
},
{
code: 'SUMMER15',
title: '15% off summer linen',
status: 'expired',
used: 500,
limit: 500,
date: '2026-09-30T23:59:00Z',
},
{
code: 'WELCOME10',
title: '10% off a first order',
status: 'active',
used: 1284,
limit: null,
date: '2026-12-31T23:59:00Z',
},
];
/** The schedule line: when the discount starts, ends or ended. */
function schedule(discount: Discount): string {
const day = DAY.format(new Date(discount.date));
if (discount.status === 'scheduled') return `Starts ${day}`;
return discount.status === 'expired' ? `Ended ${day}` : `Ends ${day}`;
}
/** Usage: a Meter against the limit, read as "310 of 500 used"; with no limit, a count. */
function Usage({ discount }: { discount: Discount }): JSX.Element {
if (discount.limit === null) {
return (
<Text tone="secondary">
<NumberValue value={discount.used} /> used · no limit
</Text>
);
}
return (
<Meter
label="Usage"
value={discount.used}
max={discount.limit}
tone={discount.status === 'expired' ? 'neutral' : 'brand'}
valueText={`${COUNT.format(discount.used)} of ${COUNT.format(discount.limit)} used`}
/>
);
}
/** One discount: its code, what it gives, its status, how much of it is used and its dates. */
function DiscountCard({ discount }: { discount: Discount }): JSX.Element {
const status = STATUS[discount.status];
return (
<Card.Root
className="nzp-discount-card"
render={<article aria-label={`${discount.code}, ${discount.title}`} />}
>
<div className="nzp-discount-card-top">
<Text
render={<span />}
variant="mono-m-strong"
className={
discount.status === 'expired'
? 'nzp-discount-card-code nzp-discount-card-code-expired'
: 'nzp-discount-card-code'
}
>
<Tag size={16} />
{discount.code}
</Text>
<Badge tone={status.tone}>{status.label}</Badge>
</div>
<Card.Title>{discount.title}</Card.Title>
<Usage discount={discount} />
<Text tone="secondary">{schedule(discount)}</Text>
</Card.Root>
);
}
export function DiscountCardExample(): JSX.Element {
return (
<ul className="nzp-discount-card-grid" aria-label="Discounts">
{DISCOUNTS.map((discount) => (
<li key={discount.code}>
<DiscountCard discount={discount} />
</li>
))}
</ul>
);
}
Stylesheet
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
@layer nz.patterns {
.nzp-discount-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
gap: var(--nz-space-4);
margin: 0;
padding: 0;
list-style: none;
}
.nzp-discount-card {
gap: var(--nz-space-4);
block-size: 100%;
}
.nzp-discount-card-top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--nz-space-2);
}
/* the code: Figma's dashed chip, in Mono */
.nzp-discount-card-code {
display: inline-flex;
align-items: center;
gap: var(--nz-space-2);
padding: var(--nz-space-1) var(--nz-space-2);
border: var(--nz-border-width-default) dashed var(--nz-border-brand);
border-radius: var(--nz-radius-sm);
background: var(--nz-bg-brand-subtle);
color: var(--nz-text-brand);
}
/* expired: the code turns neutral; the text keeps its contrast (no opacity) */
.nzp-discount-card-code-expired {
border-color: var(--nz-border-strong);
background: var(--nz-bg-subtle);
color: var(--nz-text-secondary);
}
}
Notes
- Usage is a Meter. Figma draws a raw track; here it is a native
<meter>named "Usage", whosevalueTextis shown and read as "310 of 500 used" instead of a bare percentage. Expired's data is corrected too: Figma's fill is 252 px in a 250 px track. - No limit. Figma has no unlimited code. A meter needs a maximum, so the fourth card says its count in words ("1,284 used · no limit") instead.
- Expired keeps its contrast. Figma dims the whole Expired card with
opacity: 0.70, which takestext/secondaryto 3.25:1 andtext/tertiaryto 2.57:1. Here only the status Badge, the code and the meter turn neutral, and the words say "Expired" and "Ended"; the text stays at full contrast. - Dates. Scheduled gets its start and the others their end, which Figma leaves out, through
Intl.DateTimeFormatwith an explicit time zone. - The code looks copyable but is plain text. A copy button with a "Copied" announcement is a client island; add one beside the code in your app if you need it.
- i18n. The counts go through
Intl.NumberFormat;{used} of {limit} usedis one message for a translation, never concatenated numbers.
Order table
An order list whose rows link to their orders, with the date, the customer, payment and fulfilment as two Badges with words, and the total. A server composition; it ships no JavaScript.
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.