Icons
Every Matter icon ships from a single package, on a single grid, with a single stroke. The shape is the same whether it lands in a button, a table row, or a 64px hero tile — only the size changes. Colour is never authored on the icon; it inherits from the surrounding text.
The catalogue
Icons live at @matter/icons. Each glyph is a named export wrapping the shared <Icon> primitive — tree-shakable, no runtime registry.
| Export | Use |
|---|---|
ArrowRight | forward affordance, links between resources |
Check | confirmation, completed state |
ChevronDown | disclosure, dropdown, expandable row |
ChevronRight | inline trailer, breadcrumb separator, lifecycle progression |
Search | search inputs, command palette trigger |
X | close, dismiss, clear input |
The catalogue grows by adding an SVG to packages/icons/src/svg/<kebab-name>.svg and running bun run --filter @matter/icons generate. The codegen emits a React wrapper to src/generated/<PascalName>.tsx and re-exports from the package root. Hand-edits to src/generated/ are not preserved.
Authoring grid
Every icon is drawn on a 0 0 24 24 viewBox. The codegen rejects any other viewBox. Inside that grid:
- 1.5px stroke at the authored 24px scale.
- Clean integer coordinates wherever possible — strokes snap to the pixel grid at 1× DPR.
- Stroke linecap and linejoin are
round. The wrapper owns these — author the path geometry, not the stroke attributes. - No
stroke,fill, orstroke-widthon the root<svg>. The wrapper sets them; authoring them on the source SVG breakscurrentColorinheritance. - No
<text>inside icons. Glyphs are pure geometry.
Sizing grammar
Three canonical sizes via the size prop, plus a numeric escape hatch for one-off needs.
| Size | Pixels | Where it ships |
|---|---|---|
sm | 16px | dense table cells, compact rows, inline-with-caption |
md | 20px | inline with body text, button leading / trailing icon |
lg | 24px | default — navigation, cards, anything not specified |
| number | custom | escape hatch for hero tiles, marquees, oversized affordances |
import { ChevronRight, Search } from "@matter/icons";
<Search size="md" /> {/* inside an input */}
<ChevronRight size="sm" /> {/* inside a table row */}
<ChevronRight /> {/* lg — the default */}
<Search size={64} /> {/* hero tile, escape hatch */}The default is lg. Reach for the default unless the surrounding row, button, or input dictates otherwise — most icon callsites should not be specifying a size at all.
Stroke, not fill
Matter's icons are stroked outlines. Fills are reserved for solid status dots (--status-ok-dot, --status-warning-dot) and for the Matter spark ✺ — not for the icon family. The 1.5px stroke gives the family its hairline character, paired with the warm-gray hairline tokens at every divider.
If a design calls for a solid glyph, reach for the spark, a status dot, or a brand mark — not a filled version of an outline icon. The catalogue is single-weight by design.
Colour policy
Icons inherit their colour from currentColor. Set it on the parent via a text colour token, never on the icon itself.
<span style={{ color: "var(--text-secondary)" }}>
<Search size="md" />
</span>
<button className="text-fg-muted">
<ChevronDown size="sm" />
</button>currentColor plus the semantic ring means an icon next to body text automatically tracks the body colour, and an icon in a muted caption tracks the muted colour. Theme switches and forced-colors mode work without per-icon overrides.
currentColor.fill="#0D0D0D" or stroke="black" to the icon. Hard-coded colours break theming and forced-colors mode.Accessibility
Every Matter icon is decorative by default — the <Icon> wrapper sets aria-hidden="true". That's the right default: an icon next to a label is redundant for screen readers, and the label carries the meaning. Two rules cover the rest of the surface.
Decorative — pair with a visible label. When the icon sits next to text that names the action, the icon stays aria-hidden. Screen readers announce the label and skip the icon.
<button>
<ChevronRight size="sm" />
Continue
</button>Semantic — icon-only, must carry a label. When the icon is the only affordance, the button or link MUST carry an aria-label. The icon's aria-hidden does not move — the label sits on the interactive element, where assistive tech reads it.
<button aria-label="Close">
<X size="md" />
</button>
<button aria-label="Search filings">
<Search size="md" />
</button>Pass aria-hidden={false} on the icon only when the icon itself is the semantic content — extremely rare, and almost always a sign that a text label should have been used instead.
aria-label. The label is concrete ("Close filing", not "Click here").<X /> as a button child with no label. Screen-reader users will hear "button" with no name.Worked example
A composed search input, with a leading icon and a clear affordance — the canonical shape across app, web, and docs.
import { Search, X } from "@matter/icons";
function SearchField({ value, onChange, onClear }) {
return (
<label className="search-field">
<Search size="md" />
<input
aria-label="Search filings"
onChange={(e) => onChange(e.target.value)}
type="search"
value={value}
/>
{value ? (
<button aria-label="Clear search" onClick={onClear} type="button">
<X size="sm" />
</button>
) : null}
</label>
);
}The leading Search is decorative — the input's own aria-label carries the role. The trailing X is semantic — the button it lives in carries the label.
For agents
Every icon name surfaces in the manifest at /design-system.json under components[] — alongside its import_path and source_url. The full catalogue, with author-time SVG paths, lives in packages/icons/src/svg/.
curl -s https://design.mattermode.com/design-system.json \
| jq '.components[] | select(.import_path == "@matter/icons")'When adding an icon programmatically, write the SVG to src/svg/<kebab-name>.svg and run the generator. Do not hand-author files under src/generated/ — the next regeneration will overwrite them.