DecisionCard

A verdict, the figures behind it, and an action that exists only when the verdict allows one.

moleculesSource
import { DecisionCard } from '@elirobinson/react/components/molecules/DecisionCard';

Styles: @elirobinson/react/styles/molecules/DecisionCard.css — already included when you import @elirobinson/react/styles.css.

Worth it

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

Matches

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.

Skip it

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

PropTypeDefaultDescription
headlinerequiredstring——
verdictrequired"go" | "no" | "hold"——
verdictLabelrequiredstring——
actionReactNode——
caveatstring——
closingstring——
codestring—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"metricHow `figures` is drawn — see `DecisionFigureLayout`. Defaults to `metric`.
figuresDecisionFigure[]——
headingLevel2 | 3 | 4 | 5 | 62Heading 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.
subjectstring——
total{ label: string; value: string; }——

Also accepts all HTMLAttributes<HTMLDivElement> props.

Accessibility

  • headline is 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's label is a <dt> and its value a <dd>, so the pairing holds without the grid.
  • A figure is keyed by its id when it has one and by its position otherwise, so labels may repeat freely. id never reaches the DOM; kind is the only figure field that does, as data-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 no aria-label to fall out of step with the text.
  • total and contrast render 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 is aria-hidden and verdictLabel is 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 action is 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.