Icons
icon() returns an inline <svg>. Every glyph is drawn on the same 24×24 grid as unfilled strokes in currentColor, so it inherits the colour and the font size of whatever it sits in and needs no styling of its own.
stack({ direction: 'row', gap: 'md', align: 'center' },
icon('check'),
icon('search'),
icon('trash'),
icon('settings'),
)In a component
An icon is a child like any other. Because it sizes itself in em, it matches the label beside it without being told how big that label is:
stack({ direction: 'row', gap: 'sm', align: 'center', wrap: true },
button({ color: 'primary' }, icon('download'), 'Download'),
button({ variant: 'outline' }, icon('external-link'), 'Open'),
button({ size: 'sm', variant: 'soft', color: 'danger' }, icon('trash'), 'Delete'),
iconButton({ label: 'Search', variant: 'soft', icon: icon('search') }),
)Size
The default is 1em — the size of the surrounding text. size takes a token or any CSS length when you want to break away from it:
stack({ direction: 'row', gap: 'md', align: 'center' },
icon('star', { size: 'sm' }),
icon('star'),
icon('star', { size: 'lg' }),
icon('star', { size: '2rem' }),
icon('star', { size: '3rem' }),
)Colour
There is no colour prop. An icon is drawn in currentColor, so it takes the colour of its context — which is what makes one set work inside five palettes:
stack({ direction: 'row', gap: 'md', align: 'center' },
text({ style: 'color: var(--su-primary)' }, icon('heart', { size: 'lg' })),
text({ style: 'color: var(--su-success)' }, icon('check-circle', { size: 'lg' })),
text({ style: 'color: var(--su-warning)' }, icon('alert-triangle', { size: 'lg' })),
text({ style: 'color: var(--su-danger)' }, icon('x-circle', { size: 'lg' })),
text({ tone: 'muted' }, icon('info', { size: 'lg' })),
)Accessible names
An icon is aria-hidden by default, which is right far more often than not: an icon next to the word “Delete” should not be announced a second time. Give it a label only when the icon carries the whole meaning, and it becomes role="img" with that name.
icon('trash') // decorative — hidden
button(icon('trash'), 'Delete') // the word does the talking
icon('trash', { label: 'Delete' }) // announced as an image
// An icon-only button labels the button, not the glyph inside it
iconButton({ label: 'Delete', icon: icon('trash') })Filled
filled paints a glyph instead of outlining it. It is the same path either way — only the fill attribute changes — so the two forms share an outer edge exactly and cannot drift apart.
stack({ direction: 'row', gap: 'md', align: 'center' },
icon('bell', { size: 'lg' }),
icon('bookmark', { size: 'lg' }),
icon('folder', { size: 'lg' }),
icon('heart', { size: 'lg' }),
icon('star', { size: 'lg' }),
)And the same names filled:
stack({ direction: 'row', gap: 'md', align: 'center' },
icon('bell', { filled: true, size: 'lg' }),
icon('bookmark', { filled: true, size: 'lg' }),
icon('folder', { filled: true, size: 'lg' }),
icon('heart', { filled: true, size: 'lg' }),
icon('star', { filled: true, size: 'lg' }),
)It is a prop rather than a second set of names because the filled state is nearly always a state — saved, liked, rated — so it wants a boolean, not a different string:
icon('heart', { filled: liked })
icon('bookmark', { filled: saved, label: saved ? 'Saved' : 'Save' })
// rather than
icon(liked ? 'heart-filled' : 'heart')The status glyphs fill differently, because their mark sits inside the shape. Painting the circle would swallow the tick, so the mark is knocked back out of it instead:
stack({ direction: 'row', gap: 'md', align: 'center' },
icon('check-circle', { filled: true, size: 'lg' }),
icon('x-circle', { filled: true, size: 'lg' }),
icon('info', { filled: true, size: 'lg' }),
icon('help', { filled: true, size: 'lg' }),
icon('alert-triangle', { filled: true, size: 'lg' }),
)Those carry a second drawing — the shape solid with the mark cut out of it by fill-rule: evenodd — because a knockout cannot be had from the outline path by changing an attribute. The outer shape is drawn at the outline’s outer edge, so the two forms still end on the same silhouette. It is the same prop either way; which mechanism a glyph uses is its own business.
A chevron has no inside to paint at all — it is an open line — so it fills to the triangle its own three points describe, keeping the stroke that rounds the corners:
stack({ direction: 'row', gap: 'md', align: 'center' },
icon('chevron-up', { filled: true, size: 'lg' }),
icon('chevron-down', { filled: true, size: 'lg' }),
icon('chevron-left', { filled: true, size: 'lg' }),
icon('chevron-right', { filled: true, size: 'lg' }),
)fillableIcons() lists everything that answers to filled. A glyph without a filled form ignores it and stays outlined — filling eye would lose the pupil and tag its hole, so neither pretends to.
Spin
spin rotates the glyph — meant for spinner, though nothing stops you spinning refresh while something reloads. It slows to a crawl rather than stopping under prefers-reduced-motion, because a spinner that stops looks broken.
stack({ direction: 'row', gap: 'md', align: 'center' },
icon('spinner', { spin: true, size: 'lg' }),
icon('refresh', { spin: true, size: 'lg' }),
button({ variant: 'soft' }, icon('spinner', { spin: true }), 'Saving…'),
)The set
Names describe the drawing rather than the job it does — x-circle, not error — because the same drawing gets used for unrelated jobs, and a name that describes the picture stays true when it does. The aliases below cover the common intents.
Brands
Eight brand marks come with the set — facebook, google, instagram, linkedin, tiktok, whatsapp, x-twitter and youtube. They still take size and label and still draw in currentColor:
stack({ direction: 'row', gap: 'md', align: 'center', wrap: true },
icon('facebook', { size: 'lg' }),
icon('instagram', { size: 'lg' }),
icon('x-twitter', { size: 'lg' }),
icon('youtube', { size: 'lg' }),
icon('whatsapp', { size: 'lg' }),
button({ variant: 'soft', color: 'neutral' }, icon('linkedin'), 'Share'),
)They are reproductions of other people’s marks rather than drawings in this library’s style, so they break two of its rules on purpose: they are solid shapes rather than strokes, which is what a logo is, and their proportions are the brand’s rather than this grid’s. filled means nothing to them — they already are.
The artwork is from Simple Icons, which releases it under CC0. That covers the drawing, not the trademark: use these to point at the thing they name — a profile link, a share button — and not on a product of your own.
It is x-twitter, not x, because x already aliases close and a close button turning into a logo would be a nasty surprise. twitter resolves to it too.
Aliases
Each of these renders a glyph listed above, under the name you are more likely to reach for:
success → check-circle
warning → alert-triangle
danger, error → x-circle
x, cross → close
question → help
loading → spinner
cog, gears → gear
delete, trash-can → trash
pencil → edit
notification → bell
dots → more-horizontal
bolt, lightning → zap
arrow-back → arrow-left
arrow-forward → arrow-right
cart → shopping-cart
bag → shopping-bag
card → credit-card
cash, money → banknote
delivery, shipping → truck
shop → store
discount, sale → percent
login, sign-in → log-in
logout, sign-out → log-out
map-pin, marker → location
mobile → smartphone
like → thumbs-up
dislike → thumbs-down
comment, message, chat → comment-bubble
ai, magic → sparkles
printer → print
accessibility, a11y → universal-access
twitter → x-twitter
Your own icons
registerIcons() adds a glyph, or replaces a built-in. The markup is the <svg>’s contents — shapes on the same 24×24 grid, left unfilled so currentColor reaches them. Call it once from a module your pages import:
import { registerIcons } from 'sitelo/ui'
registerIcons({
logo: '<path d="M4 20 12 4l8 16z"/>',
// A name that already exists replaces it everywhere, which is how you
// restyle a built-in without forking the library.
check: '<path d="m5 13 4 4 10-11"/>',
// One closed shape, so it can answer to `filled` like the built-ins.
pin: { markup: '<path d="M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11z"/>', fillable: true },
})import { icon } from 'sitelo/ui'
icon('logo') // your glyph
icon('check') // now yours as well
registerIcons({ check: null }) // and back to the built-inWhy inline, and not a sprite
Icons render into the page rather than being pulled from an icons.svg with <use>. A sprite saves on the order of a hundred gzipped bytes of HTML per page and costs a round trip to do it — repeated markup is exactly the case gzip is best at, so most of what a sprite exists to dedupe has been deduped already. Inline also means there is no file to emit, no base path to configure, and nothing that can go missing from dist — the same trade styles() makes.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Which glyph. Can be passed as the first argument instead. |
size | 'sm' | 'md' | 'lg' | string | 'md' | A token, or any CSS length. Default is 1em. |
label | string | — | Announce it as an image with this name, instead of hiding it. |
spin | boolean | false | Rotate it continuously. |
filled | boolean | false | Paint the glyph rather than outline it. Ignored by glyphs that cannot be filled. |
An unknown name renders nothing at all rather than throwing — a cosmetic prop should not be able to fail a build. hasIcon(name) tells you whether one exists, and iconNames() lists them all, and fillableIcons() the ones that take filled.