Foundations / Icons
Icons
Icons are the glyph set Alpha publishes, for use inside its own components and in your markup. They take their colour and their size from whatever they sit in, so a glyph dropped into a button or a tag matches it without being told to.
The set is drawn on a 20×20 grid, almost all of it at a 2px stroke with round caps and joins, and painted in currentColor. Those four decisions are the whole foundation, and each of them is doing work: a shared grid is what makes two icons look like a pair rather than two drawings, a shared stroke weight is what stops one reading as bolder than its neighbour, and currentColor is what lets a glyph follow a disabled label, a hover state and a theme toggle without a prop for any of them.
They ship as one component per drawing, from their own entry point — @growth-protocol-ai/alpha/icons. That split is the point of it: the root entry point stays the component API rather than the glyph vocabulary, and an application that imports two icons ships two rather than the set. There is no icon on the root barrel and no way to reach one from it.
import { Button } from '@growth-protocol-ai/alpha';
import { PlusIcon, SearchIcon } from '@growth-protocol-ai/alpha/icons';
<SearchIcon />
<SearchIcon size="lg" />
<SearchIcon aria-label="Search" />
<Button iconLeft={<PlusIcon />}>Add member</Button>The slots that take one are not closed the same way: Button's iconLeft and iconRight, Dropdown's leading and Modal's icon are all ReactNode, so nothing forces a glyph to come from Alpha, and each component normalises whatever arrives. That is also why the slot takes the element and not the component: iconLeft={<PlusIcon />}. If you are typing a slot of your own against the component instead, AlphaIcon is the type for it.
A glyph is drawn at 20px on a 20×20 grid with a 2px stroke and round caps — which is what makes them look like one hand rather than a collection. The count is under the field, so it is whatever the package currently ships. They are drawn in currentColor, so each cell below is only setting a text colour and the icons follow it; toggle the theme and nothing here is repainted by name.
The labels drop the suffix every export shares — Trash is TrashIcon — so the grid reads as drawings rather than as a column of the same word; click one for its exact import. The field matches the descriptions as well as the names, so delete reaches TrashIcon without your knowing what it is called.
The list is closed: these are drawings made for this set, and a Lucide or Heroicons glyph beside them reads as a mistake rather than a substitute. Names describe the drawing, not the feature it happens to serve — LayoutIcon rather than UiBuilderIcon, RocketIcon rather than WorkflowsIcon — because a shared package outlives one product's vocabulary. The brand marks are the exception — TalosIcon, ZeusIcon and AskZeusIcon carry the product name, because that is what someone reaching for one will search for.
199 of 199
Not all of them are pure outlines. A good many are drawn solid — the triangles, the letterforms, the paint wells in PaletteIcon — and some mix the two, the way RocketIcon fills only its porthole, because that is how the library draws them. For TrashIcon the weight is the point — it is the one action in a row that cannot be taken back, and a solid glyph says so before the label does.
The ramp stops at 24 on purpose: the stroke is drawn into the path at 2, so it scales with the box — 1.6px at sm, 2.4px at lg — and a glyph at 32 or 48 reads far heavier than it was drawn. What picks a size is the control the glyph sits in, never how important it is. sm and lg beat whatever the surrounding component would have imposed, so passing one is how you overrule a slot. md is the drawn size and carries no rule of its own, so a slot that sizes its own furniture still wins — which is exactly what makes omitting size, and passing md, the same thing.
smmdlg| size | When to use |
|---|---|
sm | 16px. Dense surfaces — a table cell, a tag, a compact list row. |
md | 20px. The default, and the size every glyph was drawn at. Buttons, fields, nav items, and the answer when nothing suggests otherwise. |
lg | 24px. A glyph standing on its own — an empty state, a feature tile. |
Decorative, and the default
Left unnamed, a glyph renders aria-hidden and focusable="false": out of the accessibility tree and out of the tab order, so a screen reader moves past it to the label that already says what the control does. This is nearly every icon in a product, and it is what you get by writing nothing.
Named, and therefore content
An aria-label or an aria-labelledby switches the glyph to role="img", so it is announced rather than skipped. Reach for it only where the meaning is nowhere else on the row — a cell whose whole content is a drawing — because naming a glyph beside a label that repeats it makes a screen reader say the same word twice.
Sized by what it sits in
At md the size rides on the svg’s width and height attributes and no CSS rule, which is what lets a component size its own furniture: Button pins a nested glyph to 20px, Tabs to 18, Tag to 14, and EmptyState takes it to 24. An explicit sm or lg is a class, and beats all of them.
Import icons from @growth-protocol-ai/alpha/icons, never from the root — the root entry point carries components and nothing else, so an icon cannot be reached from it. Import the ones you use by name and the rest are dropped from your bundle. They ship as components and nothing else: there is no sprite, no icon font and no download, so a glyph cannot be referenced from CSS or from markup the package did not render.
An icon takes no children — the drawing is the whole of its content, and putting anything inside one does not compile. A count or a dot over a glyph is markup around it rather than in it, and the box that arrangement needs belongs to the wrapper.
Leave icons unlabelled. A glyph beside a label that already names the action is furniture, so it ships aria-hidden — adding an aria-label makes a screen reader announce the same thing twice.
The exception is a glyph that is the only content: an icon-only button, a status cell. Label the button, not the icon — Button takes aria-label and the glyph inside it should stay hidden.
Recolour the parent, never the icon. Every glyph is drawn in currentColor, which is what makes it follow a disabled label, a hover state and a theme toggle for free. A colour set on the icon opts out of all four.
Do not reach outside the set. These are custom drawings; a public-library glyph next to them is visibly a different hand. If something is missing, have it drawn rather than inlining a path.
One glyph per meaning across a product. PlusIcon for create and EditIcon for modify, everywhere. The set is wide enough now to offer a product two plausible drawings for one idea, and picking both is how a reader ends up having to learn which screen uses which.
A Filled suffix is the solid half of a pair, not a heavier glyph for a different idea. StarIcon and StarFilledIcon are one control’s two states, as are SwatchIcon and SwatchFilledIcon and SparklesIcon and SparklesFilledIcon — reach for the pair where something toggles, and for one of them alone everywhere else.
RedoIcon and UndoIcon are a mirror pair and belong on screen together. Showing one alone reads as a missing control rather than a deliberate one.
An arrow moves something; a chevron opens something. A delta that rose takes ArrowUpIcon, a select that drops down takes ChevronDownIcon, and swapping the two makes a control look like a direction.
Write the import; do not build a name-to-component map to look one up. There is no dynamic icon API because nothing in the products needs one yet — a glyph chosen at runtime from configuration is the case that would earn it, and generated code is not.
In a control’s slot
Button’s iconLeft and iconRight, Dropdown’s leading and Modal’s icon each take a ReactNode, so what you pass is the element — <PlusIcon />, not PlusIcon — and the control normalises whatever arrives and fixes its size. Leave size off inside one: a button is shrink-to-fit, so a pinned glyph gives back the width it does not use and a row of buttons stops lining up. A slot of your own is typed against AlphaIcon, which also admits an icon you drew.
When the glyph is the only content
An icon-only button takes the name and the glyph inside it stays hidden, because the button is the control and labelling both announces the word twice. The glyph itself is named only where there is no control to hold the name — a bare mark in a table cell, a state shown as a drawing in a list. The question is which element the reader is addressing, and the name goes there.
Overruling a slot looks like this. The same button three times, with the glyph left to the slot, then pinned to sm and to lg. The button's height does not move — that is fixed by its own size — but its width is shrink-to-fit, so it gives back the 4px a small glyph does not use.
The disabled pair is worth seeing together. Both buttons are off, and the glyph fades with the label in each because it is drawn in currentColor — there is no disabled variant on an icon and there does not need to be. It cuts both ways: artwork with colours of its own — a flag, an avatar — keeps them when the control is disabled, because there is no currentColor for it to follow.
Every icon takes the same props, plus anything an svg takes — there is no per-glyph API. Accessibility follows from aria-label alone: without one an icon is aria-hidden, and with one it becomes role="img".
| Prop | Type | Default | Notes |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | 16, 20 or 24px. sm and lg overrule the surrounding component; md is the drawn size and lets that component size its own furniture, which is what omitting the prop also does. |
aria-label | string | — | Names the glyph, which also switches it from aria-hidden to role="img". Only for an icon carrying meaning nothing near it repeats. |
aria-labelledby | string | — | The same switch, for a name already on the page: point it at the id of the element carrying that name rather than writing the word a second time. |
className | string | — | Where a rotation or a transition goes. One class of your own beats the root; out-sizing an explicit sm or lg takes more, which is deliberate. |
icon | [data-slot] | — | Every glyph is an svg carrying data-slot="icon", beside data-name — the export without its Icon suffix, kebab-cased, so SearchIcon is search and ArrowUpRightIcon is arrow-up-right — and data-size. It is the only selector into a glyph the package offers, and the one to write an application’s own rule against. |
Not in the set
Some glyphs are component furniture rather than published icons, and stay inside the components that draw them: the button's loading arc, the clear × the text field and Search each draw, the dropdown's search magnifier, and the tag's + and ×. The first is an animation rather than an icon. The rest are drawn to tighter insets than PlusIcon, CloseIcon and SearchIcon — a tag's pair sit in a 14×14 box at about 40% of it, where the published glyphs fill nearly 60% of the 20×20 grid — so they are not those glyphs under another name. Spinner draws nothing at all: its ring is a gradient and a mask.
There is also no sprite, no icon font and no download. The glyphs ship as React components and nothing else, and there is no way to reference one from CSS or from markup the package did not render.
And there is no DynamicIcon — no component that takes a glyph name as a string and looks it up. A registry mapping every name to its component is exactly the object that would defeat the split this page opens with, since importing one icon would then reach every glyph in the set. It would be worth that cost for a feature that receives an icon name at runtime from an API, a database or a builder configuration; nothing does yet. Generated code is not that case — an assistant writing against Alpha should emit the same static import a person would.