Components / core

Icon

Phosphor's web components were rejected. They need JavaScript and would put <ph-check> in the markup contract, which a Vue or Astro consumer would then have to install.

Rendered from the package · open on its own

Summary

A glyph in the token icon box.

APG patternnone
Built onnative elements
Statusstable
Since0.1.0

Use it anywhere an icon appears rather than inlining an <svg>, so the box comes from the contract and not from whoever pasted the path.

import { ICONS, ICONS_BOLD } from '@studio-edit/ds/icons';

<Icon path={ICONS.uploadSimple} />
<Icon path={ICONS.trash} size="md" label="Delete project" />

Icons lists every glyph the package vendors, by name, and filters. There are few, and that is the design: the system owns the box and never the glyph, so what ships is what the components draw plus what the documentation shows. An application brings its own set.

Anatomy

An <svg> carrying data-part="icon". The part is tolerated on two carriers: an <svg> takes the box directly, and a wrapping <span> takes the box and lets its child fill it. Only the wrapper form can hold a font glyph, because a glyph is text.

PartElementRequired
icon<svg>yes

Properties

PropertyValuesDefaultWhat it does
pathstringrequiredThe d attribute from a Phosphor path.
sizesm · md · lg · stringsmToken box size. Defaults to sm (--se-icon-sm).
labelstringnoneAccessible name. Omit for decorative icons.
styleReact.CSSPropertiesnoneInline styles merged onto the <svg>. The box is already set from size; overriding it here puts a length outside the contract into the markup.

States

_None. Rest is the only condition it has._

Behavior

None.

Accessibility

Decorative by default. Without a label it is aria-hidden, which is right almost always: an icon beside a visible label repeats it, and announcing both makes the reader hear the same thing twice.

Pass label only when the icon is a control's only content. Then it becomes the control's accessible name, and without it the control has no name at all.

fill is inherited from the part rather than set on the <svg>, so both carriers paint the same color. Every specimen in this repo writes fill="currentColor" as an attribute, which is why the first version of that rule passed while half the anatomy painted black.

Keyboard

None of its own. It is not focusable and takes no keys.

Roles, states and properties

AttributeSet by
aria-hiddenset at render
aria-labelset at render
roleset at render

Focus

Never takes focus. An icon inside a control belongs to the control.

Screen reader

Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.

Contrast and target size

npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.

It sets no target size of its own; anything interactive inside it brings its own.

Tested

  • test/browser/icon-box.spec.mjs
  • test/browser/site.spec.mjs

Known gaps

_No upstream requirement is recorded as not applicable._

Appearance

TokenUsed for
--se-icon-lglg
--se-icon-mdmd
--se-icon-smblock-size, font-size, inline-size, sm

Icon weight follows what the icon sits next to, per 2.11: a glyph beside a medium-weight label takes the strong face, which is a family swap rather than a font-weight, because an icon font ships each weight as its own typeface.

Motion

None. An icon that animates on its own is a spinner.

Content

None — it has no text. The label, when there is one, is the control's name and names the action: Delete project, not Trash icon.

Usage

Three sizes, and they are not interchangeable. sm sits in a button or a row, md beside body text or in an alert, lg in an empty state. Reaching past the box for a decorative flourish is how a page ends up teaching a size the system does not produce.

When not to use it. As the only distinction between two controls. An icon-only row of buttons is a memory test unless every glyph is unambiguous, and most are not.

A consumer without React gets the files, not the component. Every glyph in every weight ships as an SVG in the package — @studio-edit/ds/assets/regular/caret-down.svg, assets/bold/caret-down-bold.svg — so a Vue, Astro or plain-HTML page inlines one into an element carrying data-part="icon" and the box and ink come from the same rules. The glyph data behind this component is the second route (@studio-edit/ds/icons), and --se-font-icon is the third: it names the face an icon font occupies if an application brings one, and ships nothing.

Notes

Icon.jsx writes its box as an inline style, so the shared CSS rule never applies to it and a React consumer's icon is sized by the binding rather than the stylesheet. Harmless today, because both say --se-icon-sm, and worth collapsing when the binding is next opened. Recorded in docs/audit/DEFERRED.md.

Phosphor's web components were rejected. They need JavaScript and would put <ph-check> in the markup contract, which a Vue or Astro consumer would then have to install.