Skip to content
kerfjsGitHub

LearnStyling and veneer

Styling and veneer

So far you've built components that hold state and respond to events. This chapter is about how they look, and about Veneer, Kerf's UI toolkit. Kerf 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:

An order card

Order #1042

2 bags of coffee · open

import { state } from '@kerf-js/core'import { group } from '@kerf-js/core/style'import {  Card,  Segmented,  Stack,  Switch,} from '@kerf-js/veneer'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.

tsx
import { derived } from '@kerf-js/core'import { group } from '@kerf-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 }) {  const { count } = props  const level = derived(() =>    count === 0 ? 'none' : count < 5 ? 'low' : 'plenty'  )  return (    <span {...badge({ level: level.value })}>      {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 kerf check catch.

Using Veneer

Veneer 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:

Billing
import { state } from '@kerf-js/core'import {  Button,  Card,  Input,  Switch,} from '@kerf-js/veneer'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.

tsx
import { Button, Card, Input, Stack } from '@kerf-js/veneer'export function Billing() {  return (    <Card title="Billing">      <Stack gap="related">        <Input label="Billing email" type="email" />        <Button>Save</Button>      </Stack>    </Card>  )}

Read Using Veneer to learn how to compose forms, dialogs, menus and tables, set up the veneer() plugin, and style a component's insides.

Theming

The look is a set of --veneer-* 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:

CoffeeHouse blend, 250 g
In stock
import { state } from '@kerf-js/core'import {  Badge,  Button,  Card,  Input,  Row,  Segmented,  Stack,} from '@kerf-js/veneer'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-paint        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.

tsx
import { Card, Text } from '@kerf-js/veneer'export function Sidebar() {  return (    <aside      data-theme="dark"      data-paint      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

@kerf-js/veneer-icons has 651 icons, each hollow and solid, drawn for Veneer's three expressions. One you use costs about 164 bytes; one you don't costs nothing:

Icons

Saved

import { state } from '@kerf-js/core'import {  BellIconAction,  CheckIcon,  HeartIcon,  HeartIconSolid,} from '@kerf-js/veneer-icons'import {  Button,  Card,  Icon,  Row,  Switch,  Text,  Toggle,} from '@kerf-js/veneer'export default function Icons() {  const liked = state(false)  const solid = state(false)  return (    <Card title="Icons">      <Row        gap="group"        align="baseline"      >        {/* The word beside it says the same, so the            decorative icon is hidden from screen            readers. */}        <Text>          <CheckIcon /> Saved        </Text>        {/* A label names it; nothing else does. */}        <Icon          name="star"          solid={solid.value}          label="Favourite"          size={24}        />        {/* Hover or focus to ring the bell. */}        <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: `label` names it. 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.

tsx
import { CheckIcon } from '@kerf-js/veneer-icons'import { Button } from '@kerf-js/veneer'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 @kerf-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:

ts
import { defineConfig } from '@kerf-js/core/config'import { text } from '@kerf-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 Veneer, and how variable fonts are trimmed.

Accessibility

Veneer's components are native elements with names, keyboard patterns and focus management built in. You own the structure and the words, and kerf check catches the common mistakes in markup you write yourself:

Yirgacheffe

A washed coffee with notes of lemon and black tea.

Subscribe monthly?

Focus moved into this dialog. Escape closes it and puts focus back on Subscribe.

import { state } from '@kerf-js/core'import {  Button,  Card,  Menu,  Modal,  Row,  Stack,  Tabs,  Text,} from '@kerf-js/veneer'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.

tsx
import { state } from '@kerf-js/core'import { Button, Text } from '@kerf-js/veneer'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 Veneer 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.