Skip to content
FoundationsIcons

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.

ExportUse
ArrowRightforward affordance, links between resources
Checkconfirmation, completed state
ChevronDowndisclosure, dropdown, expandable row
ChevronRightinline trailer, breadcrumb separator, lifecycle progression
Searchsearch inputs, command palette trigger
Xclose, 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, or stroke-width on the root <svg>. The wrapper sets them; authoring them on the source SVG breaks currentColor inheritance.
  • 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.

SizePixelsWhere it ships
sm16pxdense table cells, compact rows, inline-with-caption
md20pxinline with body text, button leading / trailing icon
lg24pxdefault — navigation, cards, anything not specified
numbercustomescape 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.

Set the colour on the parent via a semantic text token. The icon inherits via currentColor.
Don't pass 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.

Every icon-only button carries aria-label. The label is concrete ("Close filing", not "Click here").
Don't ship a bare <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.

On this page