Skip to content
weftGitHub

LearnDescribing the UI

Describing the UI

weft is a TypeScript framework for building user interfaces. A user interface is made of small pieces, such as buttons, text and images, and weft lets you combine them into reusable, nestable components. From web sites to apps, everything on the screen can be broken down into components. In this chapter you'll learn to write, configure and show weft components. If you know React, most of this will look familiar, with one important difference, which has a page of its own: a weft component runs once.

Your first component

A weft app is built from isolated pieces of UI called components. A component is a TypeScript function whose name starts with a capital letter and which returns markup. Components can be as small as a button or as large as a page. Here Gallery renders three Profile components:

Amazing scientists
KKatherine Johnson, mathematician
MMario Molina, chemist
HHedy Lamarr, inventor
import { Badge, Card, Row, Stack } from '@weft-js/warp'// One component, defined once at the top level of the file.function Profile(props: { name: string; field: string }) {  return (    <Row gap="related">      <Badge emphasis="solid" tone="accent">{props.name[0]}</Badge>      <span>        <strong>{props.name}</strong>, {props.field}      </span>    </Row>  )}// Another component that uses it three times.export default function Gallery() {  return (    <Card title="Amazing scientists">      <Stack gap="related">        <Profile name="Katherine Johnson" field="mathematician" />        <Profile name="Mario Molina" field="chemist" />        <Profile name="Hedy Lamarr" field="inventor" />      </Stack>    </Card>  )}

Try this: Add a fourth Profile to Gallery. Profile is written once and used as many times as you like.

Read Your first component to learn how to define and use components, and what weft's compiler does with them.

Importing and exporting components

You can declare many components in one file, but large files get hard to navigate. To fix that, export a component into its own file, and import it from another:

tsx
// src/components/Scientist.tsxexport function Scientist() {  return <p>Katherine Johnson, mathematician</p>}
tsx
// src/routes/hall-of-fame.tsximport { Scientist } from '#/components/Scientist'export default function HallOfFame() {  return (    <section>      <h1>Amazing scientists</h1>      <Scientist />      <Scientist />    </section>  )}

Read Importing and exporting components to learn how to split components into their own files, and what weft's tools look for in your exports.

Writing markup with TSX

Each component is a function that may contain markup the browser shows. That markup is TSX: an HTML-like syntax inside TypeScript. It's stricter than HTML: there's one root, and every tag is closed. weft accepts HTML's attribute names and React's, so class and className both work:

Hedy Lamarr's todos
  • Invent new traffic lights
  • Rehearse a movie scene
  • Improve the spectrum technology

Both class and className work; this paragraph uses className.

import { Card } from '@weft-js/warp'// The HTML this started as, made into TSX: one root, every tag closed, attributes as JSX.export default function TodoList() {  return (    <Card title="Hedy Lamarr's todos">      <ul class="todos">        <li>Invent new traffic lights</li>        <li>Rehearse a movie scene</li>        <li>Improve the spectrum technology</li>      </ul>      <label for="note">A note</label>      <br />      <input id="note" placeholder="Anything else?" />      <p className="hint" aria-live="polite" data-kind="hint">        Both class and className work; this paragraph uses className.      </p>    </Card>  )}

Try this: Compare the TSX with the HTML you would write: every tag is closed, and the whole card has one root.

Read Writing markup with TSX to learn how to write valid markup, convert HTML to TSX, and use the attributes weft adds, such as ref.

JavaScript in TSX with braces

Braces open a window from markup back into JavaScript. Use them to show a variable, call a function, or pass an object. In weft, braces are also live: an expression in braces that reads state runs again, by itself, when that state changes. Drag the slider and only the list's font size is written:

Gregorio Y. Zara's todos

To do on Monday:

  • Improve the videophone
  • Prepare aeronautics lectures
Text size16px
import { state } from '@weft-js/core'import { Card, Slider } from '@weft-js/warp'const person = {  name: 'Gregorio Y. Zara',  theme: { background: 'var(--warp-color-accent-soft)', color: 'var(--warp-color-accent-text)' },}const today = new Date(2026, 10, 2)function formatDate(date: Date) {  return new Intl.DateTimeFormat('en-GB', { weekday: 'long' }).format(date)}export default function TodoList() {  const size = state(16)  return (    <Card title={`${person.name}'s todos`}>      <p>To do on {formatDate(today)}:</p>      {/* Double braces: an object inside the braces. Reading size.value makes this style live. */}      <ul style={{ ...person.theme, padding: 'var(--warp-space-group)', borderRadius: 'var(--warp-radius-control)', fontSize: size.value }}>        <li>Improve the videophone</li>        <li>Prepare aeronautics lectures</li>      </ul>      <Slider label="Text size" min={12} max={28} value={size.value} format={v => `${v}px`} onChange={v => (size.value = v)} />    </Card>  )}

Try this: Drag the slider. Only the list's font-size changes: the style object reads size.value, so it is a live binding.

Read JavaScript in TSX with braces to learn where braces go, what double braces are, and why a .value read inside braces stays up to date.

Passing props to a component

Components use props to talk to each other. A parent passes information to its children as props, which look like HTML attributes but can hold any value: objects, arrays, functions, and markup as children. In weft, props are never stale: the child runs once, and every read of a prop gets the parent's current value.

Split the bill

Each of you pays $20.00

import { state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'function Price(props: { dollars: number }) {  return <strong>${props.dollars.toFixed(2)}</strong>}export default function Tip() {  const bill = state(40)  return (    <Card title="Split the bill">      <p>Each of you pays <Price dollars={bill.value / 2} /></p>      <Button onClick={() => (bill.value += 10)}>Add a $10 dish</Button>    </Card>  )}

Try this: Press Add a $10 dish. Price updates, though neither component ran again.

Read Passing props to learn how to pass and read props, and the rules for writing them: const is a live alias, let writes locally, and only bind: writes back to the parent.

Conditional rendering

Your components will often show different things in different situations. In weft you use JavaScript's ? : and && inside the markup, where they stay live. Because the body runs once, an if with an early return would only check once, so the compiler asks you to branch in the markup instead:

Sally Ride's packing list
import { state } from '@weft-js/core'import { Card, Checkbox, Stack } from '@weft-js/warp'function Item(props: { name: string; packed: boolean; onToggle: () => void }) {  return (    <Checkbox checked={props.packed} onChange={props.onToggle}>      {props.packed ? <del>{props.name}</del> : props.name}      {props.packed && <small> (packed)</small>}    </Checkbox>  )}export default function PackingList() {  const packed = state({ suit: true, helmet: false, photo: false })  const toggle = (k: 'suit' | 'helmet' | 'photo') => (packed.value = { ...packed.value, [k]: !packed.value[k] })  return (    <Card title="Sally Ride's packing list">      <Stack gap="related">        <Item name="Space suit" packed={packed.value.suit} onToggle={() => toggle('suit')} />        <Item name="Helmet with a golden leaf" packed={packed.value.helmet} onToggle={() => toggle('helmet')} />        <Item name="Photo of Tam" packed={packed.value.photo} onToggle={() => toggle('photo')} />      </Stack>    </Card>  )}

Try this: Tick and untick items. The ternary swaps plain text for a struck-through name, and && adds or removes "(packed)".

Read Conditional rendering to learn the ways to show things conditionally, and what happens to the markup and its state when a condition flips.

Rendering lists

You'll often show several components from a collection of data. Use filter and map in the markup, and give each row a key that identifies its item, so weft can move rows instead of rebuilding them when the list changes:

Scientists
  • Creola Katherine Johnson: mathematician
  • Mario José Molina-Pasquel Henríquez: chemist
  • Mohammad Abdus Salam: physicist
  • Percy Lavon Julian: chemist
  • Subrahmanyan Chandrasekhar: astrophysicist

5 of 5

import { derived, state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'const people = [  { id: 0, name: 'Creola Katherine Johnson', profession: 'mathematician' },  { id: 1, name: 'Mario José Molina-Pasquel Henríquez', profession: 'chemist' },  { id: 2, name: 'Mohammad Abdus Salam', profession: 'physicist' },  { id: 3, name: 'Percy Lavon Julian', profession: 'chemist' },  { id: 4, name: 'Subrahmanyan Chandrasekhar', profession: 'astrophysicist' },]export default function Scientists() {  const only = state<string | null>(null)  // Filter first, then map: the filtered list is a derived value, so it follows `only`.  const shown = derived(() => (only.value ? people.filter(p => p.profession === only.value) : people))  return (    <Card title="Scientists">      <Button size="sm" emphasis="outline" onClick={() => (only.value = null)}>Everyone</Button>{' '}      <Button size="sm" emphasis="outline" onClick={() => (only.value = 'chemist')}>Chemists</Button>      <ul>        {shown.value.map(p => (          <li key={p.id}>            <b>{p.name}</b>: {p.profession}          </li>        ))}      </ul>      <p>{shown.value.length} of {people.length}</p>    </Card>  )}

Try this: Press Chemists, then Everyone. The rows that stay are the same rows: only the ones that left or came back are made.

Read Rendering lists to learn how to render lists, where keys come from, and how to keep large lists fast.

Components run once

This is the idea that makes weft different from React. A React component runs again on every change. A weft component runs once: its body sets things up, and the compiler wires each expression in its markup straight to the DOM node it fills. A change runs only the bindings that read it. So there are no hook rules, no dependency arrays and no memo, and a local variable lives as long as the component:

One body, many updates

0 clicks. This line has been worked out 1 times.

The component body ran once: it made the button and the paragraph above.

import { derived, state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'export default function RunsOnce() {  const clicks = state(0)  // A plain local. The body runs once, so this variable is made once and lives as long as the component.  let worked = 0  const line = derived(() => {    worked += 1    return `${clicks.value} clicks. This line has been worked out ${worked} times.`  })  return (    <Card title="One body, many updates">      <p>{line.value}</p>      <p>The component body ran once: it made the button and the paragraph above.</p>      <Button onClick={() => clicks.value++}>Click</Button>    </Card>  )}

Try this: Click a few times. The line is worked out again on every click, but the counter inside it keeps counting: the body that made it never ran again.

Read Components run once to learn the mental model, what it means for your code, and the mistakes the compiler catches.

Your UI as a tree

weft sees your app as trees. The render tree is your components nested inside each other. The ownership tree has the same shape and decides what is cleaned up when part of the page goes away. The module tree is your imports, and weft reads it to decide which components need JavaScript in the browser at all:

A branch and its owner
  • Page
    • Sidebar
      • Search
    import { onCleanup, onMount, state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'// Each node reports when it is made and when its owner cleans it up.function Part(props: { name: string; say: (line: string) => void; children?: unknown }) {  onMount(() => props.say(`${props.name} made`))  onCleanup(() => queueMicrotask(() => props.say(`${props.name} cleaned up`)))  return (    <li>      {props.name}      <ul>{props.children}</ul>    </li>  )}export default function Tree() {  const open = state(true)  const log = state<string[]>([])  const say = (line: string) => (log.value = [...log.value.slice(-5), line])  return (    <Card title="A branch and its owner">      <ul>        <Part name="Page" say={say}>          {open.value && (            <Part name="Sidebar" say={say}>              <Part name="Search" say={say} />            </Part>          )}        </Part>      </ul>      <Button onClick={() => (open.value = !open.value)}>{open.value ? 'Remove' : 'Add'} the sidebar</Button>      <pre>{log.value.join('\n')}</pre>    </Card>  )}

    Try this: Remove the sidebar, then add it back. Its owner cleans up Sidebar and Search together, and adding it makes both again.

    Read Your UI as a tree to learn how these trees work and how islands and hydration follow from them.

    What's next

    Head to Your first component to start reading this chapter page by page. Or, if you're already comfortable with these topics, read about Adding interactivity.