Iconography
The icon set is part of the package: import { SearchIcon } from 'tenet-ui/icons'. Every icon is a React component named <Thing>Icon, drawn in currentColor, sized 16, 20, or 24, and decorative by default. Meaning comes from the text or control next to an icon, or from its label when it stands alone.
- Icons come from
tenet-ui/iconsand are named<Thing>Iconin PascalCase (SearchIcon,TrashIcon,PlusIcon). The manifest atgenerated/icons.jsonlists every name with its keywords; an icon that is not in it does not exist in the system. - Arbitrary inline
<svg>is not written in app code when an icon with that meaning exists in the set. When no icon exists, the gap is a request to add one toicons/manifest.json; a one-off<svg>in the meantime still follows rules 3, 5, and 6. sizeis16,20, or24.16is for icons inline with text and inside controls (ButtonleadingIcon,TextInputleadingVisual,TagleadingIcon,Menuitems, table cells).20is for large controls, list rows, and standaloneIconButtons in toolbars that sit apart from text.24is forEmptyStateicon,Bannericon, and page headers. Nothing else sizes an icon.- An icon is decorative by default: it renders
aria-hidden="true"and screen readers skip it. It stays decorative whenever visible text or an accessible name already carries its meaning: next to a label, inside a namedIconButton, inside aButtonwith children. - An icon is meaningful only when it is the sole carrier of information and that information is not a status (a paperclip marking that a row has attachments, a trend arrow beside a bare number). Then it gets
label, which setsrole="img"andaria-label. The label names the meaning, not the drawing:label="Has attachments", notlabel="Paperclip". A status is a word in aTag(rule 12). IconButtonalways hasaria-label(oraria-labelledby) describing the action (“Delete row”, not “Trash”). Itsiconstays decorative; passinglabelto the icon inside anIconButtonwould double-announce.- A
Buttonwith an icon and text keeps the icon decorative; the text is the name. Text is preferred to an icon-only control wherever there is room. - Icons take colour from
currentColor. Colour comes from the parent (Text tone="danger", aButtonvariant), never from afill,stroke, orcolorliteral on the icon. An icon is never coloured with a status hue unless the status meaning applies (see color.md). - Icons align to the text baseline through the component that hosts them (
Button,TextInput,Tag,EmptyState). App code does not nudge icons with margins orvertical-align. - One icon per concept across the app: search is always
SearchIcon, add is alwaysPlusIcon, delete is alwaysTrashIcon. Two different glyphs for the same action is a defect. - Emoji are not icons. A
Menutrigger, aTooltipexample, or a button never uses⋯,🗑, or✕as its glyph; it uses the set. - An icon never replaces a status word. A coloured check in a table cell is
<Tag variant="success">Published</Tag>, not a loneCheckIcon.
size | Where |
|---|---|
16 | Button leadingIcon/trailingIcon, TextInput leadingVisual/trailingVisual, Tag leadingIcon, Menu items, inline with Text |
20 | size="large" controls, list rows, standalone IconButtons in toolbars |
24 | EmptyState icon, Banner icon, page and dialog headers |
Do / Don’t
Section titled “Do / Don’t”- ✅
import { SearchIcon, TrashIcon } from 'tenet-ui/icons' - ✅
<Button leadingIcon={<PlusIcon />}>Create story</Button>(decorative icon, text is the name) - ✅
<IconButton icon={<TrashIcon size={20} />} variant="danger" aria-label="Delete story" onClick={remove} />(named action, decorative icon) - ✅
<TextInput aria-label="Search stories" leadingVisual={<SearchIcon />} />(16 inside a control) - ✅
<EmptyState icon={<InboxIcon size={24} />} title="No stories yet" description="Stories you draft or import appear here." /> - ✅
<Text tone="danger"><AlertTriangleIcon label="Warning" /> 3 stories failed to import</Text>only when the icon adds information the text lacks; otherwise omitlabel - ❌
<svg viewBox="0 0 24 24"><path d="M21 21l-4.35-4.35…" /></svg>(hand-inlined search glyph;SearchIconexists) - ❌
<IconButton icon={<TrashIcon />} onClick={remove} />(no accessible name) - ❌
<IconButton icon={<TrashIcon label="Delete" />} aria-label="Delete" />(double announcement; the icon stays decorative) - ❌
<TrashIcon style={{ color: '#a8341f' }} />(abrick.500literal; wrap inText tone="danger"or use thedangervariant of the host) - ❌
<SearchIcon size={18} />(off the 16/20/24 scale) - ❌
<Button>🗑 Delete</Button>(an emoji standing in forTrashIcon)