DecisionCard
A verdict, the figures behind it, and an action that exists only when the verdict allows one.
import { DecisionCard } from '@elirobinson/react/components/molecules/DecisionCard';Styles: @elirobinson/react/styles/molecules/DecisionCard.css — already included when you import @elirobinson/react/styles.css.
Annual maintenance plan
The plan costs less than the two repairs a year this unit averages.
- Plan
- $480
- Average repair
- $310
- Repairs covered
- Unlimited
Cost this year$480
Paying per repair$620
Priced for one unit. A second unit adds $180 and changes the maths.
Show code
import { Button } from '@elirobinson/react/components/atoms/Button';
import { DecisionCard } from '@elirobinson/react/components/molecules/DecisionCard';
export default function Basic() {
return (
<DecisionCard
verdict="go"
verdictLabel="Worth it"
subject="Annual maintenance plan"
headline="The plan costs less than the two repairs a year this unit averages."
figures={[
{ label: 'Plan', value: '$480', kind: 'cost' },
{ label: 'Average repair', value: '$310', kind: 'cost' },
{ label: 'Repairs covered', value: 'Unlimited', kind: 'coverage' },
]}
total={{ label: 'Cost this year', value: '$480' }}
contrast={{ label: 'Paying per repair', value: '$620' }}
caveat="Priced for one unit. A second unit adds $180 and changes the maths."
action={<Button variant="accent">Renew the plan</Button>}
/>
);
}When to use it
DecisionCard is for a surface that has reached a conclusion and has to show its work: a
verdict at the head, the figures behind it, a contrasting figure to measure against, the
caveat that qualifies it, and — only sometimes — an action. It composes VerdictBadge for
the marker, so the verdict is a glyph and a word rather than a colour.
Use it when the reader's next question is "why?" A surface that only announces an outcome doesn't need this; a surface that has to justify one does.
Named for the shape, not the subject
DecisionCard is named for its shape — verdict, figures by kind, contrast figure, caveat,
conditional action — and not for any particular thing a product puts in it. That shape is
a quote, an eligibility result, or a risk assessment just as readily as it is a
recommendation, which is why the name says "decision" and not something narrower.
The same reasoning drives kind on a figure. It renders as data-kind rather than
resolving to a class from a fixed enum, so a product can group its figures — costs,
coverage, timing, whatever it actually tracks — and style those groups from its own
stylesheet, without the system having to learn the product's vocabulary or grow a union
type every time a product invents a category.
Showing a derivation
8471.30 Portable computer, under 10 kg
- from
- Declared weight is 1.4 kg, under the 10 kg line
- from
- Display, keyboard and processor are in one unit
- rule
- Heading 8471, subheading .30: portable machines
A docking station shipped in the same box is classified on its own.
Show code
import { Button } from '@elirobinson/react/components/atoms/Button';
import { DecisionCard } from '@elirobinson/react/components/molecules/DecisionCard';
export default function Derivation() {
return (
<DecisionCard
verdict="go"
verdictLabel="Matches"
code="8471.30"
headline="Portable computer, under 10 kg"
figureLayout="prose"
figures={[
{ id: 'weight', label: 'from', value: 'Declared weight is 1.4 kg, under the 10 kg line' },
{ id: 'unit', label: 'from', value: 'Display, keyboard and processor are in one unit' },
{ id: 'rule', label: 'rule', value: 'Heading 8471, subheading .30: portable machines' },
]}
caveat="A docking station shipped in the same box is classified on its own."
action={<Button variant="accent">Use this code</Button>}
style={{ maxWidth: 400 }}
/>
);
}Some verdicts are an identifier reached by rules — a tariff code, a plan, a rate class — and the reader checks the rules, not a number. Two props draw that shape.
code sets a short identifier apart before headline: in mono, on the headline's
baseline, with the headline dropped to the body size beside it so the code leads. It renders
inside the heading element, so the heading's accessible name is still one phrase — "8471.30
Portable computer, under 10 kg" — and heading navigation lands on both at once. Don't fold the
code into headline as one string to get it on screen; it then takes the display face and
reads as part of the sentence.
figureLayout="prose" draws figures for values that are sentences. The default,
metric, sets each value right-aligned in mono, which is right for "$1,240" and wrong for
"Declared weight is 1.4 kg, under the 10 kg line": in a narrow column every sentence wraps
into a right-ragged mono block. prose is a two-column grid instead — a small mono label, then
a left-aligned sans sentence that wraps inside its own column — and every label shares one
column width however the sentences wrap. It is one layout for the whole list: a card mixing
metrics and sentences is two cards' worth of figures.
Rows like these repeat their label by design, so give each figure an id. It is the
figure's React key and is never rendered, which leaves kind free to mean a kind.
The footer guarantee
Extended device coverage
The coverage costs more than the repair it protects you from.
- Coverage
- $240
- Screen repair
- $150
- Deductible
- $79
Cost over two years$240
One out-of-pocket repair$150
A second repair inside the term would change this.
I'd skip it and put the $240 toward the replacement instead.
Show code
import { DecisionCard } from '@elirobinson/react/components/molecules/DecisionCard';
export default function NoAction() {
return (
<DecisionCard
verdict="no"
verdictLabel="Skip it"
subject="Extended device coverage"
headline="The coverage costs more than the repair it protects you from."
figures={[
{ label: 'Coverage', value: '$240', kind: 'cost' },
{ label: 'Screen repair', value: '$150', kind: 'cost' },
{ label: 'Deductible', value: '$79', kind: 'cost' },
]}
total={{ label: 'Cost over two years', value: '$240' }}
contrast={{ label: 'One out-of-pocket repair', value: '$150' }}
caveat="A second repair inside the term would change this."
closing="I'd skip it and put the $240 toward the replacement instead."
/>
);
}When action is absent, DecisionCard renders no .ds-decision__foot element at all
— not a disabled button, not a hidden one, nothing. This is a product guarantee, not a
style choice, and it has its own test in the package.
The reason is blunt. A decision surface that can render a "pay now" control under a "do
not buy" verdict is one CSS bug away from taking a user's money against its own advice,
and a disabled button is exactly that bug waiting for a stylesheet to lose. One override,
one stray disabled={false}, one screen reader that ignores hidden, and the control is
live again. The only way to be certain that can't happen is for the element not to exist —
so a negative verdict is structurally incapable of offering a payment control.
closing is what carries the last word instead. It renders in the body, above where a
footer would have been, so a card that recommends against something still ends on a
sentence rather than trailing off. Pass closing on every negative verdict; it's the
replacement for the button you're not shipping.
If you need a control near a "no" — "remind me later," "see other options" — put it outside the card, where it can be labelled for what it actually does and can't inherit the card's authority.
Heading level
headline renders as a real heading element, not a styled paragraph — it is the card's
title in the document outline, so a screen reader's heading navigation has to be able to
find it. There is no single correct level for an arbitrary outline, so headingLevel is
exposed rather than hardcoded: leave the default (headingLevel={2}, an <h2>) for a card
sitting under the page's own <h1>, and pass headingLevel={3} for one inside an <h2>
section. An out-of-range value falls back to 2 rather than crashing the tree.
The range starts at 2 where Accordion's starts at 1. An accordion can legitimately be the
only thing on a page; a card that claimed the document's <h1> would be claiming to be the
page. The type ramp is carried by the class, so every level looks identical and only the
outline changes.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
headlinerequired | string | — | — |
verdictrequired | "go" | "no" | "hold" | — | — |
verdictLabelrequired | string | — | — |
action | ReactNode | — | — |
caveat | string | — | — |
closing | string | — | — |
code | string | — | A short identifier set apart before `headline`, in mono, on the headline's baseline — a billing code, a SKU, a plan name. It renders inside the heading, so the heading's accessible name stays one phrase: "99417 Prolonged office E/M". When it is present `headline` drops to the body size, so the code leads and the headline describes it. An empty string counts as no code. |
contrast | { label: string; value: string; } | — | — |
figureLayout | "metric" | "prose" | metric | How `figures` is drawn — see `DecisionFigureLayout`. Defaults to `metric`. |
figures | DecisionFigure[] | — | — |
headingLevel | 2 | 3 | 4 | 5 | 6 | 2 | Heading level (2-6) for `headline`, which renders as the real heading element rather than a styled paragraph. There is no single correct level for an arbitrary document outline, so this is exposed rather than hardcoded — a card sitting under a page `<h1>` leaves the default (`headingLevel={2}`), one inside an `<h2>` section passes `headingLevel={3}`. Defaults to 2. |
subject | string | — | — |
total | { label: string; value: string; } | — | — |
Also accepts all HTMLAttributes<HTMLDivElement> props.
Accessibility
headlineis a real<h2>–<h6>(see "Heading level" above), so the card has a title in the document outline and heading navigation lands on it.- The figures render as a real
<dl>: each figure'slabelis a<dt>and itsvaluea<dd>, so the pairing holds without the grid. - A figure is keyed by its
idwhen it has one and by its position otherwise, so labels may repeat freely.idnever reaches the DOM;kindis the only figure field that does, asdata-kind. - With
code, the heading holds the code and the headline as two spans with a space between them, so its accessible name is one phrase and there is noaria-labelto fall out of step with the text. totalandcontrastrender as paragraphs rather than more<dl>entries — they're a summation and a comparison, not further items of the same list.- The verdict marker comes from
VerdictBadge: its glyph isaria-hiddenandverdictLabelis the accessible text. Write it as the verdict in words. - The card itself has no role and no live region. If a verdict updates in place, announce that from wherever your product owns status messages.
- Keyboard: only the node you pass as
actionis focusable. When there's no action there's nothing to tab to, which is the intended outcome, not an oversight. - The ref forwards to the outer
<div>.
Do
- Pass closing on every negative verdict — it is what replaces the missing footer.
- Give the reader the contrast figure; a number with nothing to measure against persuades nobody.
- State the caveat that would change the verdict, in the caveat slot.
- Use kind to group figures your product already groups, and style them from your own stylesheet.
- Give figures an id, unique within the list, whenever two of them can share a label.
Don't
- Pass a disabled button as action to "grey out" an offer — omit action instead.
- Reintroduce a footer with a wrapper element that renders whether or not there is an action.
- Put a payment control outside the card and style it to look like it belongs to a "no" verdict.
- Use it for a plain summary with no judgement in it; that is Card or StubCard.
- Put row identity into kind to keep keys unique — that is what id is for.
Product theming
DecisionCard inherits the product token layer through the VerdictBadge it composes:
the verdict fills and foregrounds read --product-verdict-* with a fallback to the
matching system status token. The convention is documented in
docs/agents/product-token-layer.md, and the fallback keeps it optional — a product that
declares nothing gets the system palette.