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.

alert-triangle
arrow-down
arrow-left
arrow-right
arrow-up
banknote
bell
bold
bookmark
calendar
camera
check
check-circle
chevron-down
chevron-left
chevron-right
chevron-up
chevrons-left
chevrons-right
clock
close
code
coins
comment-bubble
copy
credit-card
database
download
edit
external-link
eye
eye-off
facebook
feather
file
filter
folder
gear
gift
globe
google
heart
help
home
image
info
instagram
italic
key
layers
link
linkedin
location
lock
log-in
log-out
mail
menu
minus
moon
more-horizontal
more-vertical
package
percent
pin
plus
print
receipt
refresh
search
settings
share
shopping-bag
shopping-cart
smartphone
sparkles
spinner
star
store
sun
tag
terminal
thumbs-down
thumbs-up
tiktok
trash
truck
universal-access
unlock
upload
user
users
video
wallet
whatsapp
x-circle
x-twitter
youtube
zap

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:

successcheck-circle

warningalert-triangle

danger, errorx-circle

x, crossclose

questionhelp

loadingspinner

cog, gearsgear

delete, trash-cantrash

penciledit

notificationbell

dotsmore-horizontal

bolt, lightningzap

arrow-backarrow-left

arrow-forwardarrow-right

cartshopping-cart

bagshopping-bag

cardcredit-card

cash, moneybanknote

delivery, shippingtruck

shopstore

discount, salepercent

login, sign-inlog-in

logout, sign-outlog-out

map-pin, markerlocation

mobilesmartphone

likethumbs-up

dislikethumbs-down

comment, message, chatcomment-bubble

ai, magicsparkles

printerprint

accessibility, a11yuniversal-access

twitterx-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-in

Why 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

PropTypeDefaultDescription
namestringWhich glyph. Can be passed as the first argument instead.
size'sm' | 'md' | 'lg' | string'md'A token, or any CSS length. Default is 1em.
labelstringAnnounce it as an image with this name, instead of hiding it.
spinbooleanfalseRotate it continuously.
filledbooleanfalsePaint 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.