LearnDescribing the UI
Writing markup with TSX
TSX is TypeScript with markup in it. It lets you write HTML-like tags inside a .tsx file, right next to the logic that fills them in. Most of what you write is HTML you already know; this page covers the few rules that differ, and the handful of attributes weft adds.
- 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.
Markup and logic together
The web grew up with content in HTML, design in CSS and logic in JavaScript, each in its own file. As pages became more interactive, logic came to decide much of the content: whether a message shows, what a button says, which rows a table has. So TSX puts them together. A component's markup and the logic that drives it live in the same function, and a change to one is next to the other.
A sidebar component and a form component each keep their markup and logic together, and you can still keep their styles where you like: see Styling and style groups.
TSX looks like HTML but it is stricter, and it can hold live values. weft's compiler reads it at build time and turns it into the code that builds and updates the page (see Your first component). There is no React.createElement and no virtual DOM: the tags you write become real DOM nodes, built once from a template.
Converting HTML to TSX
Say you have this HTML and want it in a component:
<h1>Hedy Lamarr's Todos</h1><img src="https://i.imgur.com/yXOvdOSs.jpg" alt="Hedy Lamarr" class="photo"><ul> <li>Invent new traffic lights <li>Rehearse a movie scene <li>Improve the spectrum technology</ul>Pasting it into a component as it is doesn't compile:
<!-- example: skip deliberately invalid TSX, to show the error -->
export default function TodoList() { return ( // This doesn't compile <h1>Hedy Lamarr's Todos</h1> <img src="https://i.imgur.com/yXOvdOSs.jpg" alt="Hedy Lamarr" class="photo"> <ul> <li>Invent new traffic lights <li>Rehearse a movie scene <li>Improve the spectrum technology </ul> )}HTML forgives an unclosed <li> and two elements side by side. TSX doesn't. The compiler reports a syntax error as W000, and nothing else in the file is checked until it's fixed, so fix these first. The rules below are what change, and the fixed version is at the end of this section.
The rules of TSX
1. Return a single root element
To return several elements from a component, wrap them in one parent. A <div> works:
export function Todos() { return ( <div> <h1>Hedy Lamarr's Todos</h1> <ul> <li>Invent new traffic lights</li> </ul> </div> )}If you don't want an extra <div> in the page, use a fragment, written <> and </>:
export function Todos() { return ( <> <h1>Hedy Lamarr's Todos</h1> <ul> <li>Invent new traffic lights</li> </ul> </> )}A fragment groups things without leaving any element of its own in the page.
Deep diveWhy one root?
A tag in TSX is an expression: it produces one value, as 1 + 2 does. A function can't return two values without putting them in something, in the same way that return 1 2 isn't JavaScript. A parent element or a fragment is that something. In weft the value is the DOM nodes themselves: a fragment hands back its children as a list, and the place it lands in puts them all in order.
2. Close all the tags
Every tag must be closed. Tags that are empty in HTML, such as <img>, <input> and <br>, close themselves: <img />, <br />. Tags that wrap content need their end tag: <li>oranges</li>.
3. Attribute names: both spellings work
HTML attributes are written in TSX as they are in HTML, and most are one lowercase word: src, alt, href, placeholder. Where React renamed an attribute to suit JavaScript, weft accepts React's name as well, so code written either way works:
| HTML | React's name | weft accepts |
|---|---|---|
class | className | both |
for | htmlFor | both |
tabindex | tabIndex | both |
readonly, maxlength, colspan | readOnly, maxLength, colSpan | both |
stroke-width (SVG) | strokeWidth | both |
The compiler turns React's names into the real attribute at build time, so they cost nothing when the page runs. On SVG elements, the camelCase presentation names (strokeWidth, fillOpacity, clipPath) become their dashed attributes, and names that SVG itself spells in camelCase, such as viewBox, stay as written. The full list is in React prop names.
Two kinds of attribute keep their dashes: aria-* and data-*. Write aria-label="Close" and data-tone="warn", as in HTML.
Booleans and missing values
A boolean attribute with no value, such as <input disabled />, is true. With braces, disabled={false} leaves the attribute off entirely, and so do null and undefined. That's how you turn an attribute on and off:
import { state } from '@weft-js/core'export function Send() { const sending = state(false) return ( <button type="button" disabled={sending.value} onClick={() => (sending.value = true)}> {sending.value ? 'Sending…' : 'Send'} </button> )}A few attributes take the words true and false as values rather than meaning on or off: aria-*, contentEditable, spellCheck, draggable. For those, weft writes false as the word, so aria-pressed={false} is aria-pressed="false".
The fixed version
With one root, closed tags and the attribute names above, the HTML from the start of the page becomes:
export default function TodoList() { return ( <> <h1>Hedy Lamarr's Todos</h1> <img src="https://i.imgur.com/yXOvdOSs.jpg" alt="Hedy Lamarr" class="photo" /> <ul> <li>Invent new traffic lights</li> <li>Rehearse a movie scene</li> <li>Improve the spectrum technology</li> </ul> </> )}Comments
HTML's <!-- comment --> isn't valid TSX. Inside markup, put a JavaScript comment in braces:
export function Note() { return ( <p> {/* This comment never reaches the page. */} Ship on Friday. </p> )}Events
An attribute that starts with on and a capital letter is an event listener: onClick, onInput, onKeyDown, onPointerMove. The compiler takes the part after on, lowercases it, and listens for that event, so onKeyDown listens to keydown. A handler is a function, and its event's currentTarget is typed as the element it's on:
import { state } from '@weft-js/core'export function Name() { const name = state('') return ( <label> Your name{' '} <input value={name.value} onInput={e => (name.value = e.currentTarget.value)} /> </label> )}A few names follow React rather than the DOM: onDoubleClick listens to dblclick, an on…Capture name such as onClickCapture listens in the capture phase, and onChange fires on every edit, as it does in React, rather than when the field loses focus. Common events such as clicks and key presses are delegated: one listener on the document serves every element, so a list of a thousand buttons doesn't add a thousand listeners. Responding to events covers events in depth.
style
style takes a string, as in HTML, or an object, as in React:
export function Swatch() { return ( <> <p style="color: var(--warp-color-accent-text)">A string, as in HTML</p> <p style={{ color: 'var(--warp-color-accent-text)', paddingInline: 'var(--warp-space-group)' }}>An object</p> </> )}In the object, write names in camelCase (paddingInline) or kebab-case in quotes ('padding-inline'), and custom properties as '--name'. A number gets px unless the property has no unit, such as opacity or zIndex, as in React. null, undefined and booleans mean "no declaration", so color: urgent && 'red' sets the colour only when urgent is true. When a value in the object reads state, only the declarations that changed are written.
class as a string or an object
class (or className) takes a string. It also takes an object whose keys are class names and whose values say whether each is on, which saves building strings by hand:
import { state } from '@weft-js/core'export function Tab(props: { label: string }) { const active = state(false) return ( <button type="button" class={{ tab: true, active: active.value }} onClick={() => (active.value = !active.value)}> {props.label} </button> )}With the object form, weft adds and removes only the classes that changed, and leaves any other class on the element alone.
Attributes weft adds
ref: the element itself
Sometimes you need the DOM element: to focus it, measure it or hand it to a library. ref gives it to you. Assign it to a variable, or pass a function that receives it:
import { onMount } from '@weft-js/core'export function Search() { let input!: HTMLInputElement onMount(() => input.focus()) return <input ref={input} aria-label="Search" />}ref={input} assigns the element to input when it's made. There is no useRef and no .current: the variable is the element. Refs and the DOM has more.
bind: on a component prop
<Stepper bind:value={guests} /> passes a box to a component prop so the child's writes reach it. It works on components only: on an HTML element such as <input bind:value={…}> it's W061. For a field, read the value and write it in a handler, as the Name example above does. Writing to a parent with bind: explains the whole idea.
textContent and innerHTML
textContent={text} sets an element's text in one write. innerHTML={html}, and React's spelling dangerouslySetInnerHTML={{ __html: html }}, set its HTML. An element with either can't have children too, because they would replace them; the compiler reports it.
key
key marks which item of a list an element belongs to, so the list can move rows instead of rebuilding them. It isn't written to the page. Rendering lists is where it matters.
Types
TSX in weft is checked by TypeScript with weft's own JSX types. Each HTML element knows its events, so e.currentTarget in onInput on an <input> is an HTMLInputElement. Other attributes are left open: any name with any value is accepted, because HTML has hundreds of attributes and custom ones are common. So TypeScript won't catch a misspelt attribute such as palceholder; the browser ignores it and so would you, until you look.
Recap
- TSX keeps markup and the logic that fills it in one place, and compiles to real DOM nodes.
- Return one root: an element, or a fragment
<>…</>. - Close every tag; empty ones close themselves (
<br />). - HTML and React attribute names both work:
classorclassName,fororhtmlFor. aria-*anddata-*keep their dashes;data-*booleans are presence flags (W043).onXattributes are events,styletakes a string or an object,classtakes a string or an object of flags.refgives you the element,bind:works on component props only (W061), andinnerHTMLisn't escaped.
Try it
Challenge 1
Convert some HTML to TSX
This was pasted from an HTML page and doesn't compile. Fix it.
<!-- example: skip the starting point, deliberately invalid -->
export default function Bio() { return ( <div class="intro"> <h1>Welcome to my website!</h1> </div> <p class="summary"> You can find my thoughts here. <br><br> <b>And <i>pictures</b></i> of scientists! </p> )}Show the solution
There are three problems: two roots, unclosed <br> tags, and <b> and <i> closed in the wrong order.
export default function Bio() { return ( <> <div class="intro"> <h1>Welcome to my website!</h1> </div> <p class="summary"> You can find my thoughts here. <br /> <br /> <b> And <i>pictures</i> </b>{' '} of scientists! </p> </> )}class stays as it is: weft accepts it as well as className.
Challenge 2
Fix the click handler
This button logs "Saved" once when the page loads, and never again. Why?
<!-- example: skip the starting point, deliberately wrong -->
export function Save() { const save = () => console.log('Saved') return <button type="button" onClick={save()}>Save</button>}Show the solution
onClick={save()} calls save while the button is being made and passes its result, undefined, as the handler. Pass the function itself:
export function Save() { const save = () => console.log('Saved') return ( <button type="button" onClick={save}> Save </button> )}Challenge 3
A flag CSS can select
This menu should be styled by .menu[data-open] while it's open, but the rule never matches when it's closed either way, and weft check warns about the attribute. Make data-open follow the open box.
<!-- example: skip the starting point, which reports W043 -->
import { state } from '@weft-js/core'export function Menu() { const open = state(false) return ( <div class="menu" data-open={true}> <button type="button" onClick={() => (open.value = !open.value)}>Menu</button> </div> )}Show the solution
A literal {true} is always on, which is what W043 points out. Read the box instead: true writes data-open="" and false removes it, which is what [data-open] selects.
import { state } from '@weft-js/core'export function Menu() { const open = state(false) return ( <div class="menu" data-open={open.value}> <button type="button" onClick={() => (open.value = !open.value)}> Menu </button> </div> )}