LearnStyling and warp
Styling and warp
So far you've built components that hold state and respond to events. This chapter is about how they look, and about warp, weft's UI toolkit. weft gives styling a small core (attributes that follow state) and a design language on top: tokens for colour, space and type, components that use them, and diagnostics that say when a value leaves them. You can use as much or as little of it as you like.
Styling and style groups
class and style are attributes that read state like any other. For your own components, a style group names a thing, its variations and its pieces, in tokens, and compiles each use to plain data-* attributes:
Order #1042
2 bags of coffee · open
import { state } from '@weft-js/core'import { group } from '@weft-js/core/style'import { Card, Segmented, Stack, Switch } from '@weft-js/warp'type Status = 'open' | 'shipped' | 'late'// The look of one thing, in tokens: its variations are an axis, its pieces are parts.const orderCard = group('learn-order-card', { base: { surface: 'raised', pad: 'group', radius: 'surface', layout: 'stack', gap: 'tight', border: 'tone' }, axes: { status: { open: { tone: 'info' }, shipped: { tone: 'positive' }, late: { tone: 'critical' } } }, parts: { title: { text: 'heading' }, meta: { text: 'caption', ink: 'tone' } }, states: { selected: { ring: 'tone' } }, defaults: { status: 'open' },})const STATUSES = [ { value: 'open' as const, label: 'Open' }, { value: 'shipped' as const, label: 'Shipped' }, { value: 'late' as const, label: 'Late' },]export default function Order() { const status = state<Status>('open') const selected = state(false) return ( <Card title="An order card"> <Stack gap="related"> <Segmented label="Status" value={status.value} options={STATUSES} onChange={next => (status.value = next)} /> <Switch checked={selected.value} onChange={(e: Event & { currentTarget: HTMLInputElement }) => (selected.value = e.currentTarget.checked)}> Selected </Switch> <article {...orderCard({ status: status.value, selected: selected.value })}> <p {...orderCard.title}>Order #1042</p> <p {...orderCard.meta}>2 bags of coffee · {status.value}</p> </article> </Stack> </Card> )}Try this: Pick Late, then turn on Selected. The tone and the ring change, and only two attributes on the article were written.
import { group } from '@weft-js/core/style'const badge = group('stock-badge', { base: { layout: 'inline', padInline: 'related', radius: 'round', text: 'caption', emphasis: 'soft' }, axes: { level: { plenty: { tone: 'positive' }, low: { tone: 'caution' }, none: { tone: 'critical' } } }, defaults: { level: 'plenty' },})export function Stock(props: { count: number }) { return <span {...badge({ level: props.count === 0 ? 'none' : props.count < 5 ? 'low' : 'plenty' })}>{props.count} left</span>}Read Styling and style groups to learn how class and style work, where plain CSS fits, how style groups are built, and what the D-codes in weft check catch.
Using warp
warp is a toolkit of components (forms, dialogs, menus, tables, charts and larger blocks) with keyboard and screen reader support built in. Import what you use, and name things by meaning rather than look:
import { state } from '@weft-js/core'import { Button, Card, Input, Switch } from '@weft-js/warp'export default function Billing() { const saved = state(false) const submit = (e: Event) => { e.preventDefault() saved.value = true } return ( <Card title="Billing"> <form onSubmit={submit}> <Input label="Billing email" type="email" /> <Switch defaultChecked>Send a receipt</Switch> <Button type="submit">{saved.value ? 'Saved' : 'Save'}</Button> </form> </Card> )}Try this: Hover the field and the button, then press Tab to move through them.
import { Button, Card, Input, Stack } from '@weft-js/warp'export function Billing() { return ( <Card title="Billing"> <Stack gap="related"> <Input label="Billing email" type="email" /> <Button>Save</Button> </Stack> </Card> )}Read Using warp to learn how to compose forms, dialogs, menus and tables, set up the warp() plugin, and style a component's insides.
Theming
The look is a set of --warp-* tokens, and six dials (theme, expression, density, motion, fields and flair) change their values. Each dial is a data-* attribute, on the page or on any element:
import { state } from '@weft-js/core'import { Badge, Button, Card, Input, Row, Segmented, Stack } from '@weft-js/warp'type Theme = 'light' | 'dark'type Expression = 'quiet' | 'balanced' | 'vivid'type Density = 'compact' | 'cozy' | 'comfortable'const THEMES = [{ value: 'light' as const, label: 'Light' }, { value: 'dark' as const, label: 'Dark' }]const EXPRESSIONS = [{ value: 'quiet' as const, label: 'Quiet' }, { value: 'balanced' as const, label: 'Balanced' }, { value: 'vivid' as const, label: 'Vivid' }]const DENSITIES = [{ value: 'compact' as const, label: 'Compact' }, { value: 'cozy' as const, label: 'Cozy' }, { value: 'comfortable' as const, label: 'Comfortable' }]export default function Dials() { const theme = state<Theme>('dark') const expression = state<Expression>('balanced') const density = state<Density>('cozy') return ( <Stack gap="related"> <Segmented label="Theme" value={theme.value} options={THEMES} onChange={next => (theme.value = next)} /> <Segmented label="Expression" value={expression.value} options={EXPRESSIONS} onChange={next => (expression.value = next)} /> <Segmented label="Density" value={density.value} options={DENSITIES} onChange={next => (density.value = next)} /> {/* The dials are data attributes. Everything inside this div reads its tokens from them. */} <div data-theme={theme.value} data-expression={expression.value} data-density={density.value}> <Card eyebrow="Coffee" title="House blend, 250 g"> <Stack gap="related"> <Input label="Gift note" /> <Row gap="tight" justify="between"> <Badge tone="positive">In stock</Badge> <Button>Add to cart</Button> </Row> </Stack> </Card> </div> </Stack> )}Try this: Switch the theme, the expression and the density. The card and its field change look, and the code inside the div never changes.
import { Card, Text } from '@weft-js/warp'export function Sidebar() { return ( <aside data-theme="dark" data-density="compact"> <Card title="Today"> <Text subtle>Dark and compact, whatever the page around it is.</Text> </Card> </aside> )}Read Theming to learn how dark mode works, how to add a theme switch that remembers, and how to set your brand colour and tokens safely.
Icons
@weft-js/warp-icons has 651 icons, each hollow and solid, drawn for warp's three expressions. One you use costs about 164 bytes; one you don't costs nothing:
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.
import { CheckIcon } from '@weft-js/warp-icons'import { Button } from '@weft-js/warp'export function Done() { return ( <> <p><CheckIcon /> Saved</p> <Button emphasis="ghost" tone="neutral" icon="x" label="Close" /> </> )}Read Icons to learn the three ways to use an icon, when one needs a label, and what happens when a name is only known at runtime.
Fonts
The text() plugin from @weft-js/text is the one place an app declares its fonts. The build subsets them, preloads the ones the first paint needs, and writes a fallback matched to each font's metrics, so the page doesn't jump when the font arrives:
import { defineConfig } from '@weft-js/core/config'import { text } from '@weft-js/text'export default defineConfig({ plugins: [ text({ fonts: { body: { family: 'Inter', src: 'assets/fonts/inter.woff2', fallback: 'Arial', axes: { wght: [100, 900] } }, }, }), ],})Read Fonts to learn what the build does with each face, how to use one in CSS and in warp, and how variable fonts are trimmed.
Accessibility
warp's components are native elements with names, keyboard patterns and focus management built in. You own the structure and the words, and weft check catches the common mistakes in markup you write yourself:
A washed coffee with notes of lemon and black tea.
Roasted on Monday, shipped on Tuesday.
Unopened bags can be returned within 30 days.
import { state } from '@weft-js/core'import { Button, Card, Menu, Modal, Row, Stack, Tabs, Text } from '@weft-js/warp'type Tab = 'details' | 'shipping' | 'returns'const TABS = [ { value: 'details' as const, label: 'Details' }, { value: 'shipping' as const, label: 'Shipping' }, { value: 'returns' as const, label: 'Returns' },]const COPY: Record<Tab, string> = { details: 'A washed coffee with notes of lemon and black tea.', shipping: 'Roasted on Monday, shipped on Tuesday.', returns: 'Unopened bags can be returned within 30 days.',}export default function Keyboard() { const tab = state<Tab>('details') const confirm = state(false) return ( <Card title="Yirgacheffe"> <Stack gap="related"> <Tabs label="Product" value={tab.value} options={TABS} onChange={next => (tab.value = next)}> {value => <Text>{COPY[value]}</Text>} </Tabs> <Row gap="tight"> <Menu label="Share" trigger={{ emphasis: 'outline', tone: 'neutral' }} items={[{ label: 'Copy link' }, { label: 'Email' }]} /> <Button emphasis="soft" onClick={() => (confirm.value = true)}>Subscribe</Button> </Row> </Stack> <Modal open={confirm} size="sm" title="Subscribe monthly?" footer={<Button data-close="">Done</Button>}> <Text subtle>Focus moved into this dialog. Escape closes it and puts focus back on Subscribe.</Text> </Modal> </Card> )}Try this: Click Details, then use only the keyboard: the arrows move between tabs, Tab reaches Share, and Escape closes what you open.
import { state } from '@weft-js/core'import { Button, Text } from '@weft-js/warp'export function Details() { const open = state(false) return ( <> <Button emphasis="link" aria-expanded={open.value} onClick={() => (open.value = !open.value)}>Shipping</Button> {open.value && <Text>Roasted on Monday, shipped on Tuesday.</Text>} </> )}Read Accessibility to learn what warp handles, what you still own, which diagnostics catch accessibility mistakes, and how to test for them.
What's next
Head to Styling and style groups to start the chapter page by page.