Skip to content
weftGitHub

LearnStyling and warp

Icons

weft has its own icon set, @weft-js/warp-icons: 651 icons drawn for warp, each in a hollow and a solid form, and each drawn again for warp's three expressions. warp depends on it, so an app with warp already has it. You can browse every one on the icon browser.

The set is built to cost nothing you don't use. An icon is a small component; an unused one tree-shakes away, and one you use adds about 164 bytes of brotli with all three tiers in.

Icons

Saved

import { state } from '@weft-js/core'import { BellIconAction, CheckIcon, HeartIcon, HeartIconSolid } from '@weft-js/warp-icons'import { Button, Card, Icon, Row, Switch, Text, Toggle } from '@weft-js/warp'export default function Icons() {  const liked = state(false)  const solid = state(false)  return (    <Card title="Icons">      <Row gap="group" align="baseline">        {/* Decorative: the word beside it says the same, so it stays hidden from screen readers. */}        <Text><CheckIcon /> Saved</Text>        {/* By name, with a label because nothing else names it. */}        <Icon name="star" solid={solid.value} label="Favourite" size={24} />        {/* Hover or focus the button and the bell rings. */}        <Button emphasis="outline" tone="neutral"><BellIconAction /> Notify me</Button>      </Row>      <Switch checked={solid.value} onChange={(e: Event & { currentTarget: HTMLInputElement }) => (solid.value = e.currentTarget.checked)}>        Solid star      </Switch>      {/* Icon only, so `label` is its name. The name stays the same; aria-pressed says whether it's on. */}      <Toggle pressed={liked.value} icon={liked.value ? HeartIconSolid : HeartIcon} label="Like" onClick={() => (liked.value = !liked.value)} />    </Card>  )}

Try this: Turn on Solid star, hover the Notify me button, and press the heart. The heart keeps its name, Like, while its pressed state flips.

Three ways to use one

tsx
import { CheckIcon } from '@weft-js/warp-icons'import Icons from '@weft-js/warp-icons'import { Icon } from '@weft-js/warp'export function Saved() {  return (    <p>      <CheckIcon /> <Icons.Check /> <Icon name="check" />    </p>  )}

All three draw the same check, and the build compiles all three to the same thing. Use whichever reads best:

  • A named import, CheckIcon, when you know the icon. This is the usual form.
  • The pack, Icons.Check, when a file uses many and a long import line gets in the way.
  • <Icon name>, when the name is data, like a menu item's icon: 'pencil'. Icon is exported from both @weft-js/warp and @weft-js/warp-icons/icon.

warp's own components take icons the same way. Button, Toggle, menu items and Segmented options have an icon prop that takes a name or the component:

tsx
import { TrashIcon } from '@weft-js/warp-icons'import { Button, Menu } from '@weft-js/warp'export function Actions() {  return (    <>      <Button icon="pencil">Edit</Button>      <Button icon={TrashIcon} tone="critical" emphasis="soft">Delete</Button>      <Menu label="More" items={[{ label: 'Copy link', icon: 'link' }, { label: 'Archive', icon: 'archive' }]} />    </>  )}

Names and variants

Names are kebab-case in <Icon name> and PascalCase in exports. Each icon has these forms:

ExportPack<Icon>What it is
BellIconIcons.Bellname="bell"hollow, the default
BellIconHollowIcons.BellHollowthe same, by its full name
BellIconSolidIcons.BellSolidname="bell" solidthe solid form
BellIconActionIcons.BellActionname="bell" actionanimated (five icons have one)

Solid is a second drawing, not the outline filled in. Use it for the "on" state of something, like a favourite that's set, or a selected tab's icon. An open mark like an arrow gets a notched badge as its solid, with the arrow cut out of it.

Decorative or named

An icon is decorative by default: it renders with aria-hidden="true" and focusable="false", so a screen reader skips it. That is right when text beside it already says the same thing, as in <CheckIcon /> Saved. Reading "check mark, Saved" would only add noise.

When the icon is the only thing that carries the meaning, give it a label. It becomes role="img" with that name:

tsx
import { AlertIcon } from '@weft-js/warp-icons'export function Warning() {  return <AlertIcon label="Warning" />}

For an icon-only button, put the label on the button, not the icon. warp's Button and Toggle take label for exactly this, and it becomes the button's accessible name:

tsx
import { Button } from '@weft-js/warp'export function Close(props: { onClose: () => void }) {  return <Button emphasis="ghost" tone="neutral" icon="x" label="Close" onClick={props.onClose} />}

Size

Icons take warp's icon sizes from their context, so an icon in a small button is small. size sets one by hand, in pixels or any CSS length: <StarIcon size={24} />, <StarIcon size="1.5em" />.

Tiers follow the expression

Each icon is drawn three times, once for each expression: outline at quiet and balanced (finer at quiet), and pixel art on a 16×16 grid at vivid. With no options, every icon carries all three and CSS shows the one for the nearest data-expression. Switching needs no script, doesn't flash and doesn't change the icon's box, and a nested data-expression is honoured, as for every other dial. Theming has the dials.

To show one tier whatever the page's expression, pass tier:

tsx
import { HeartIcon } from '@weft-js/warp-icons'export function Pixel() {  return <HeartIcon tier="vivid" size={32} />}

The icons() plugin

weft add icons installs the package, if it isn't there, and adds icons() to weft.config.ts. With no options the plugin changes no output; it tells agents how the names work and lets you set two things:

<!-- example: skip @weft-js/warp-icons/plugin has no type declarations yet (TS7016 under strict) -->

ts
import { defineConfig } from '@weft-js/core/config'import { warp } from '@weft-js/warp/plugin'import { icons } from '@weft-js/warp-icons/plugin'export default defineConfig({  plugins: [warp(), icons({ tier: 'balanced' })],})
  • tier (quiet, balanced or vivid) compiles one tier in and drops the others, warp's own icons included. The icons stop following data-expression after that. Run weft plugins sync if you change it.
  • source loads icons one at a time from a URL instead of building them in. It's for names that are only known at runtime, below.

A wrong name

<Icon name> is typed, so a misspelt name is a type error in your editor. Untyped code gets the same from the compiler and from weft check, and it fails the build:

text
I001 no icon 'chekc', did you mean 'check'?

A variant an icon doesn't have is I001 too, such as action on an icon that has no animation.

Names known only at runtime

The build follows a name through variables, props, conditionals and maps, and ships the icons it finds. This is followed all the way, so it ships only circle, truck and check:

tsx
import { Icon, type IconName } from '@weft-js/warp'type Status = 'open' | 'shipped' | 'delivered'const ICON: Record<Status, IconName> = { open: 'circle', shipped: 'truck', delivered: 'check' }export function StatusIcon(props: { status: Status }) {  return <Icon name={ICON[props.status]} label={props.status} />}

A name the build can't follow, such as one that comes from your database, is still legal. weft check reports it as I002 with the file and what it costs, and that use gets one of two things:

  • The pack, as its own chunk (the default). All 651 icons, about 111 KB of brotli, loaded only by a page that reaches that use. Other pages don't pay for it.
  • Single icons from a URL, with icons({ source: 'https://cdn.example.com/icons/' }). Nothing of the pack is built; each icon is fetched the first time it's drawn. weft icons publish <dir> writes every icon and variant as .svg files with a manifest.json, for you to host at that URL.

A remote icon renders an empty <svg> of the right size until it arrives, so nothing shifts, and it stays empty on the server, so server rendering needs no network.

I003 is the same thing for the pack itself: passing Icons around as a value (into a function, say) means the build can't tell which icons you use, so all of them ship with the page.

Action icons

Five icons have a built-in animation: trash (the lid lifts), bell (it rings), check (it draws on), refresh (it spins) and copy (the sheet stacks).

tsx
import { RefreshIconAction } from '@weft-js/warp-icons'import { Button, Icon } from '@weft-js/warp'export function Actions(props: { syncing: boolean }) {  return (    <>      <Button emphasis="outline" tone="neutral"><Icon name="trash" action /> Empty bin</Button>      <RefreshIconAction action={props.syncing ? 'on' : 'hover'} label="Sync" />    </>  )}

action (or action="hover") plays on hover, focus or press of the icon or its parent, so an icon inside a button plays when the button is hovered. 'focus' and 'press' narrow it to one of those, and 'on' plays now; refresh keeps spinning while it's on. The animation is CSS only, and each one's stylesheet ships only if that icon is used. The motion dial and the system's reduced-motion setting turn it off.

Colour

Icons draw in currentColor, so an icon takes the colour of the text around it, and a tone on a button colours its icon too. Two hooks go further:

css
/* The stroke that says what the icon means (the "weft") in the accent colour. */.feature svg[data-icon] { --warp-icon-weft-color: var(--warp-color-accent-text); }/* A solid icon on a coloured surface: cut its marks out in that surface's colour. */.badge-solid svg[data-icon] { --warp-icon-knock: var(--warp-color-accent-solid); }

A solid icon's cut-out marks are drawn in --warp-icon-knock, which defaults to Canvas, the browser's page background colour. On a solid button, set it to the button's fill so the marks read as holes.

Recap

  • Use CheckIcon, Icons.Check or <Icon name="check">; all three build to the same bytes, and unused icons cost nothing.
  • Icons are decorative by default. Give one a label when it alone carries meaning, and give an icon-only button a label.
  • Each icon has hollow (default) and solid forms, drawn for three tiers that follow data-expression.
  • A wrong name is I001. A name the build can't follow is I002; map data to known names, or serve icons from a URL with icons({ source }).
  • trash, bell, check, refresh and copy animate with action, and respect reduced motion.

Try it

Challenge 1

Name the icon buttons

This toolbar works with a mouse, but a screen reader announces three unnamed buttons. Fix it.

<!-- example: skip the challenge's starting code, which has the D031 mistakes on purpose -->

tsx
import { Button, Row } from '@weft-js/warp'export function Formatting() {  return (    <Row gap="tight">      <Button emphasis="ghost" tone="neutral" icon="bold" />      <Button emphasis="ghost" tone="neutral" icon="italic" />      <Button emphasis="ghost" tone="neutral" icon="link" />    </Row>  )}
Show the solution
tsx
import { Button, Row } from '@weft-js/warp'export function Formatting() {  return (    <Row gap="tight" role="toolbar" aria-label="Formatting">      <Button emphasis="ghost" tone="neutral" icon="bold" label="Bold" />      <Button emphasis="ghost" tone="neutral" icon="italic" label="Italic" />      <Button emphasis="ghost" tone="neutral" icon="link" label="Add link" />    </Row>  )}

label becomes each button's accessible name, which also clears D031. Naming the group as a toolbar tells a screen reader user what the three belong to.

Challenge 2

Solid when it's on

Make a Favourite toggle that shows the hollow star when off and the solid star when on, with a constant name.

Show the solution
tsx
import { state } from '@weft-js/core'import { StarIcon, StarIconSolid } from '@weft-js/warp-icons'import { Toggle } from '@weft-js/warp'export function Favourite() {  const on = state(false)  return <Toggle pressed={on.value} icon={on.value ? StarIconSolid : StarIcon} label="Favourite" onClick={() => (on.value = !on.value)} />}

Toggle sets aria-pressed, so the state is announced, and the name "Favourite" stays the same either way. The solid form is the visual "on", matching what a screen reader hears.

Challenge 3

Keep the pack out of the page

A notification's kind comes from the server as a string, and the component below makes weft check report I002 (and a type error, since a string isn't an IconName). The kinds are order, payment and message. Rewrite it so the build knows every icon.

<!-- example: skip the challenge's starting code, which has the type error on purpose -->

tsx
import { Icon } from '@weft-js/warp'export function Notice(props: { kind: string; text: string }) {  return <p><Icon name={props.kind} /> {props.text}</p>}
Show the solution
tsx
import { Icon, type IconName } from '@weft-js/warp'type Kind = 'order' | 'payment' | 'message'const ICON: Record<Kind, IconName> = { order: 'package', payment: 'wallet', message: 'mail' }export function Notice(props: { kind: Kind; text: string }) {  return <p><Icon name={ICON[props.kind]} /> {props.text}</p>}

The map turns open data into three names the build can see, so only those three icons ship. Narrowing kind to a union also moves the check to where the data enters your app, which is where a bad value should be caught.