GizStack
View .mdOpen in ChatGPTOpen in Claude

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 .ghtml file (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

text
 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 layout

Three rules follow from it:

  1. A screen uses parts. A card is class="ui-card", not a rule of your own that looks like a card.
  2. A value is a token. A color, a size, a radius or a shadow is var(--…). A raw #5b5bd6 or 13px is a defect.
  3. 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:

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:

brand.json
{
  "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:

main.giz
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 projects screen at 1280 px: a header with its action, two figures, the table and the form side by side, in the teal brand

The same screen at 390 px: the action under the title, the table and the form one under the other

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

PartMarkupNotes
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 actionsThe actions go to the right; they wrap under the title when narrow.
ui-card<section>: an h3, then the content; .in pads itA 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-tilesa list of ui-tileApps, 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 bNames shrink with an ellipsis.
ui-seglinks, or a Menu of more; aria-current on the current oneA choice of views. It scrolls sideways; it never wraps.
ui-emptya mark, a b title, one sentence, at most one actionNothing to show yet.

Data

PartMarkupNotes
ui-table<table>; th.num, td.num for numbersHairline 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 actionsWhat 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-chipthe Chip component, tone ok, warn or badA 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-outputhead with data-s="ok" or "failed", h3 and pre panesThe output of a run. An empty pre shows its data-empty text.
Regionthe Region component, a Load valueLoading, empty, failed and ready, with the same markup everywhere (data states).

Forms and actions

PartMarkupNotes
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-fieldthe Field component, or <label class="ui-field">A label, a hint and the line for the field's problem.
ui-checkthe Check componentA 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-actionsa div or nav of buttonsA 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.

GroupTokensUse
colorbg, surface, hover, fg, muted, line, line-strongPage, surfaces, text and edges.
accent, on-accent, linkThe one accent: the main action, focus, selection.
info, success, warning, danger, scrimStates, and the shade behind a dialog.
code-*, term-*Code blocks and the terminal, dark in both themes.
fontsans, mono
text2xs 11, xs 12, sm 13, base 14, lg 16, xl 20, 2xl 28 pxThe body is --text-base.
radiusmd controls, lg cards, xl dialogs, 2xl large panels, full pills
shadowsm controls and cards, pop menus and dialogs, float a part over the page
spacing4 pxEvery gap and padding is calc(var(--spacing) * N).
controlh 32 pxButtons, fields and selects.

Write a value as its token, and a tint as a mix of tokens:

css
.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 nameApplies
"" (after the base)Everywhere: it changes the base. Use it for a brand.
darkBy the browser's color scheme, and by data-theme="dark" on the root. data-theme="light" turns it off.
any otherBy 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:

theme.giz
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:

css
@layer base, components;
@layer components { /* every part */ }
Where a rule isWins overUse it for
@layer basenothingThe app's element defaults: a, svg, button.
@layer componentsbaseGizWeb's parts.
Tailwind utilitiescomponentsOne-off changes on one element.
A rule outside a layer, a scoped <style>all layers, whatever their specificityThe 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(…):

css
.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:

css
@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.

SettingWhat the parts do
Dark modeThe dark tokens, by the browser's scheme or data-theme.
prefers-contrast: moreSolid edges, no translucent glass, underlined links.
forced-colors: activeSystem colors that keep each shape visible.
prefers-reduced-motion: reduceNo 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 leftEvery margin, padding and position is logical (inline-start, inline-end).
PrintNo site header, footer, docs menus or actions.
KeyboardA 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.

  1. Search web/components/ui.css and web/components/*.ghtml for a part that is close. Extend it before you add one.
  2. 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.
  3. If the part needs more than one element, write a component in web/components/NAME.ghtml that takes its words from the caller.
  4. Check it in dark mode, at 390 px, with the keyboard, and under forced-colors.
  5. Test its markup in tests/web/ui.test.giz or tests/web/controls.test.giz, and add its row to this guide or to the components reference.
  6. 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.