Design screens with GizWeb
GizWeb ships one design system. Every app uses it: a site of pages, an admin, an agent workspace, a data studio. You write what a screen shows. The parts give it a look that works on a phone and on a desktop, in light and dark, with a keyboard and a screen reader, and under every accessibility setting of the browser.
- The guide is for developers who build screens with GizWeb. It assumes you can serve a page
(GizWeb guide) and write a
.ghtmlfile (GHTML). - The quick start builds one screen and changes its brand. Each later section answers one question.
- Each example ran on the current toolchain. The screenshots are of that run.
The model in one picture
tokens web/theme/base.json, dark.json --color-accent, --text-sm, --radius-lg, --spacing
│ a brand or a tenant: an overlay of a few tokens
▼
parts web/components/ui.css, *.ghtml .ui-card, .ui-table, .ui-button, <Dialog>, <Menu>
│ cascade layer `components`
▼
screens your .ghtml files which parts, in which order, with what data
scoped <style>: only the screen's own layoutThree rules follow from it:
- A screen uses parts. A card is
class="ui-card", not a rule of your own that looks like a card. - A value is a token. A color, a size, a radius or a shadow is
var(--…). A raw#5b5bd6or13pxis a defect. - A missing part goes into GizWeb. Then every app gets it, with its dark mode, its focus ring and its phone layout.
This repository holds its own code to the same rule (the "One design system" line of the contributor guide).
Quick start: a screen and a brand
A site of pages is a folder (GHTML §7). This page shows a list of projects, two figures and a form. It writes no style of its own except the one rule that puts the two cards side by side.
pages/index.ghtml:
<script>
type Project = { name: string; owner: string; state: string; runs: i64; };
const projects: Vec<Project> = [Project("Billing", "Mina", "live", 1204), Project("Search", "Jon", "building", 87)];
</script>
<main class="ui-page">
<header class="ui-pagehead">
<div><h1>Projects</h1><p>Every project of the team, and its last deploy.</p></div>
<div class="ui-actions"><a class="ui-button pri" href="/new">New project</a></div>
</header>
<div class="ui-stats">
<div><small>Projects</small><b>{projects.length}</b></div>
<div><small>Runs today</small><b>1,291</b><span>+12%</span></div>
</div>
<div class="cols">
<section class="ui-card">
<h3>Projects</h3>
<table class="ui-table">
<thead><tr><th>Name</th><th>Owner</th><th>State</th><th class="num">Runs</th></tr></thead>
<tbody>
<tr each={p of projects}>
<td><a href={"/p/" + p.name}>{p.name}</a></td><td>{p.owner}</td>
<td><span class="ui-badge" data-s={p.state}>{p.state}</span></td><td class="num">{p.runs}</td>
</tr>
</tbody>
</table>
</section>
<section class="ui-card">
<h3>Invite</h3>
<form class="ui-form in">
<label>Email<input name="email" type="email" required/></label>
<button class="ui-button pri">Send the invitation</button>
</form>
</section>
</div>
</main>
<style>
/* Only this screen's own layout: the two cards side by side when the page is wide enough. */
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); gap: calc(var(--spacing) * 4); }
</style>brand.json names only the tokens that the brand changes:
{
"color": {
"$type": "color",
"accent": { "$value": "#0f766e" },
"link": { "$value": "#0f766e" }
},
"radius": { "$type": "dimension", "lg": { "$value": "14px" } }
}main.giz puts the brand over the default theme and serves the site:
import {System} from "gizlang:system";
import * as Web from "gizweb";
import * as Pages from "gizweb:pages";
import * as Tokens from "gizweb:tokens";
import {text as brand} from "./brand.json";
import {site} from "./pages/.routes";
/** The site on port 8080, in the default theme with the brand's tokens over it. */
function main(sys: System): i64 {
const theme = Tokens.css(Web.uiTheme().concat([Tokens.Theme("", brand)]), false).unwrap();
return Web.listen(sys, 8080, Pages.shell(theme), Pages.serving(Pages.standalone), (own: System) => Pages.handler(site(), Pages.shell(theme), Pages.standalone(own)));
}On a desktop, and at 390 px:


The screen did not choose a font, a color, a border, a padding or a breakpoint. Pages.shell("") serves the
same screen in the default theme. Without Pages.serving, the site still works; only the grid's imports need it.
Parts
A part is a class on a native element, or a .ghtml component when it needs more than one element. A part has
no state and needs no script; the browser gives the behavior (<details>, <dialog>, popover, radio
inputs).
Page structure
| Part | Markup | Notes |
|---|---|---|
ui-page | <main class="ui-page"> | A page of a surface: parts one under another, as wide as reads well. |
ui-pagehead | <header>: an h1, an optional p, then the actions | The actions go to the right; they wrap under the title when narrow. |
ui-card | <section>: an h3, then the content; .in pads it | A titled section. A table or a list in a card goes edge to edge. It has no outer margin: the page or the grid that holds it sets the gap. |
ui-stats | <div> of <div><small>name</small><b>value</b><span>note</span></div> | Figures in a row that wraps. |
ui-tiles | a list of ui-tile | Apps, agents, files. --tile-min sets the least width; aria-selected marks the chosen one. |
ui-fold | <details class="ui-fold"><summary> | A folded section; the mark turns when it opens. |
ui-crumbs | <nav> of links, separators with aria-hidden, the current place as b | Names shrink with an ellipsis. |
ui-seg | links, or a Menu of more; aria-current on the current one | A choice of views. It scrolls sideways; it never wraps. |
ui-empty | a mark, a b title, one sentence, at most one action | Nothing to show yet. |
Data
| Part | Markup | Notes |
|---|---|---|
ui-table | <table>; th.num, td.num for numbers | Hairline rows, a tint under the pointer. Numbers line up at their end and do not break. |
ui-list | <ul>; each li is a name (div of b and small), then a badge or actions | What follows the end of a row takes the row's width. |
ui-badge | <span class="ui-badge" data-s="live"> | A state: a dot and a word. data-s is live, building, failed, new, preview, rejected or replaced. |
ui-chip | the Chip component, tone ok, warn or bad | A short tag with a tone. |
ui-tag | <span class="ui-tag"> | A plain label beside a name. A state with a meaning is a badge. |
| meter | <progress max="100" value={n} aria-label="Used"> | How much of a whole is done or used. A page sets only its width. |
ui-output | head with data-s="ok" or "failed", h3 and pre panes | The output of a run. An empty pre shows its data-empty text. |
| Region | the Region component, a Load value | Loading, empty, failed and ready, with the same markup everywhere (data states). |
Forms and actions
| Part | Markup | Notes |
|---|---|---|
ui-form | <form class="ui-form"> of <label>Name<input/></label> | A stacked form. Every input, select and textarea gets the control look. |
inline | <form class="inline"> | A form on one line, for filters and search. It wraps when narrow. |
ui-field | the Field component, or <label class="ui-field"> | A label, a hint and the line for the field's problem. |
ui-check | the Check component | A checkbox and its text on one line. |
ui-button | <button> or <a>; pri, ghost, danger, ic (an icon only) | aria-pressed="true" makes a toggle. |
ui-actions | a div or nav of buttons | A row of actions that wraps. |
[data-err] | <small data-err> | The kernel writes a refusal there. It takes its own row, in the danger color, and is gone while empty. |
The components with more than one element are Field, DateField, Check, Chip, Combobox, Dialog, Menu
and Chart. Their props and markup are in the components reference.
Documentation and site parts (Callout, Tabs, SiteHeader, DocsLayout, …) are there too, and so are the
agent parts (ChatWorkspace, ChatMessage, the composer).
Tokens
The tokens are DTCG JSON in web/theme/base.json, with a dark.json overlay. Each group is a Tailwind v4 theme
namespace, so color.accent is --color-accent and, under Tailwind, bg-accent.
| Group | Tokens | Use |
|---|---|---|
color | bg, surface, hover, fg, muted, line, line-strong | Page, surfaces, text and edges. |
accent, on-accent, link | The one accent: the main action, focus, selection. | |
info, success, warning, danger, scrim | States, and the shade behind a dialog. | |
code-*, term-* | Code blocks and the terminal, dark in both themes. | |
font | sans, mono | |
text | 2xs 11, xs 12, sm 13, base 14, lg 16, xl 20, 2xl 28 px | The body is --text-base. |
radius | md controls, lg cards, xl dialogs, 2xl large panels, full pills | |
shadow | sm controls and cards, pop menus and dialogs, float a part over the page | |
spacing | 4 px | Every gap and padding is calc(var(--spacing) * N). |
control | h 32 px | Buttons, fields and selects. |
Write a value as its token, and a tint as a mix of tokens:
.note { color: var(--color-muted); font-size: var(--text-sm); border-radius: var(--radius-md); }
.picked { background: color-mix(in oklab, var(--color-accent) 12%, transparent); }Themes
Tokens.css(themes, tailwind) writes the CSS of a list of themes. The first is the base. Each later theme
names only the tokens it changes, and a token that the base does not have is an error that names the theme and
the token.
Text must read in every theme a page can show: the base after its brands, and each overlay over it. A text color on
what it is read on (fg, muted and link on bg and surface, on-accent on accent, the tones on surface,
the code colors) below 4.5:1 is an error that names the pair and the ratio. A color that is not hex is not checked.
| Theme name | Applies |
|---|---|
"" (after the base) | Everywhere: it changes the base. Use it for a brand. |
dark | By the browser's color scheme, and by data-theme="dark" on the root. data-theme="light" turns it off. |
| any other | By data-theme="NAME" on the root: a tenant, a high-contrast variant. |
Web.uiTheme() is the default base and its dark overlay. Add your themes after it:
import * as Web from "gizweb";
import * as Tokens from "gizweb:tokens";
/** The default theme with a brand over it: BRAND for light pages, BRAND_DARK over the default dark values. */
export function theme(brand: string, brandDark: string): string throws string {
return try Tokens.css(Web.uiTheme().concat([Tokens.Theme("", brand), Tokens.Theme("dark", brandDark)]), false);
}Give the result to Pages.shell(theme). The parts follow it, so one file of a few tokens changes every screen.
An app shell of its own takes the same sheet: App.shell(lang, handlers, theme + Web.uiComponents()).
With Tailwind. Write the tokens as Tailwind's @theme (Tokens.css(…, true)) and give them to
Tailwind.stylesheet; its sheet goes to Pages.shell(css). A utility then reads the same tokens:
class="text-muted rounded-lg". See GHTML §6.
The cascade
GizWeb's parts are in the cascade layer components, after base:
@layer base, components;
@layer components { /* every part */ }| Where a rule is | Wins over | Use it for |
|---|---|---|
@layer base | nothing | The app's element defaults: a, svg, button. |
@layer components | base | GizWeb's parts. |
| Tailwind utilities | components | One-off changes on one element. |
A rule outside a layer, a scoped <style> | all layers, whatever their specificity | The screen's own layout. |
So a screen never raises a selector or writes !important to restyle a part. A plain .cols .ui-card { box-shadow: none }
in a page beats the part.
A screen's own style
A .ghtml file's <style> is scoped. The compiler gives each element of the file a class g-HASH and adds it to
each selector. A part that a component writes is not an element of your file, so a scoped rule does not reach it.
Name it with :global(…):
.bar > :global(.ui-menu) { margin-inline-start: auto; }A rule that reaches nothing is a silent defect, so read each selector against the markup it is meant for.
Keep in the screen's style only its own layout: a grid of its regions, an order, a gap. Leave colors, type, edges and states to the parts and tokens.
Size by the container. A part that sits in a pane sizes by the pane, not by the window. The agent
workspace names two containers: chat (the conversation) and surface (the open surface). A rule for a narrow
pane is a container query:
@container surface (max-width: 940px) { .board { grid-template-columns: 1fr; } }Use @media only for what is the window's: the page header and the docs menu.
Built in: you write none of it
Every part already answers these settings. A screen made of parts gets them without one line of its own.
| Setting | What the parts do |
|---|---|
| Dark mode | The dark tokens, by the browser's scheme or data-theme. |
prefers-contrast: more | Solid edges, no translucent glass, underlined links. |
forced-colors: active | System colors that keep each shape visible. |
prefers-reduced-motion: reduce | No motion that is not needed. |
Touch (pointer: coarse) | 40 px buttons, and 16 px text in fields so that a phone does not zoom. |
| Right to left | Every margin, padding and position is logical (inline-start, inline-end). |
| No site header, footer, docs menus or actions. | |
| Keyboard | A visible :focus-visible ring in --color-link; menus and dialogs move with the arrow keys and close on Escape. |
Add a part to GizWeb
When no part fits, add one to GizWeb, not to your app.
- Search
web/components/ui.cssandweb/components/*.ghtmlfor a part that is close. Extend it before you add one. - Write the rules in
ui.css, after one comment line that says what the part is and its markup. Use only tokens. Use logical properties. - If the part needs more than one element, write a component in
web/components/NAME.ghtmlthat takes its words from the caller. - Check it in dark mode, at 390 px, with the keyboard, and under
forced-colors. - Test its markup in
tests/web/ui.test.gizortests/web/controls.test.giz, and add its row to this guide or to the components reference. - Remove the copies that apps kept of it, in the same change.
Review a screen
- Each section, table, list, form and button is a part.
- No raw color, size, radius or shadow. A
style=""holds only custom properties. The compiler checks all but sizes. - The screen's
<style>holds only its layout, and each of its selectors reaches an element. - Each image, button and control has a name a screen reader says (the compiler checks it).
- A pane's narrow layout is a container query.
- The screen reads well at 390 px and at 1280 px, in light and dark.
Where to go next
- GizWeb guide: routes, actions, forms and live updates.
- GHTML: the markup language, scoped style and Tailwind.
- Components reference: props and markup of each component, data states and Web Components.
- Web architecture: how the shell, the kernel and the parts fit together.