Skip to content
ONIWC
iwc 0.1.0

UI development: screens and elements

.ui.iwc: a screen

Purpose: one page of the site, at one route. When: create one file per URL pattern (/, /products, /products/:slug). No registration is needed.

.ui.iwc
{
  "name": "Products",
  "route": "/products",
  "layout": "main.layout.iwc",
  "title": "All products",
  "description": "Everything we make.",
  "auth": "optional",
  "className": "page-products",
  "styleSource": "styles/products.css",
  "style": { "background": "var(--color-surface)" },
  "sections": [
    { "type": "section", "elements": [{ "type": "text", "variant": "h1", "content": "All products" }] }
  ]
}
PropertyTypeRequiredMeaning
namename (letters, digits, space, -, _)yesShown in tooling; the page title when title is missing
route/path/:param/*yesThe URL. :name captures a segment; * matches the rest. Unique across screens
sectionsarray of elementsyesThe page content, top to bottom
layoutlayout filenoThe frame around the page. Without one, content goes in a bare <main>
titlestringno<title>, through app.head.title ("%s · Shop")
descriptionstringno<meta name="description"> (default app.head.description)
authrequired | optional | nonenorequired redirects visitors who aren't signed in to the login page, with ?next=
rolesarray of role namesnoOnly these roles (needs "auth": "required"); others get 403
className, style, styleSourcenoClasses, CSS and a stylesheet for the page's content wrapper

Elements

Everything inside sections is an element: a JSON object with a type. Elements nest through elements. The full list:

GroupTypes
Containerscontainer section box row column stack grid header footer nav main aside sidebar spacer divider
Contenttext image video audio svg icon placeholder badge rating review
Actionsbutton link
Datacard list table chart (and any container with data)
Formsform (see forms)
Interactivenavbar dropdown modal drawer tabs accordion carousel slider
Compositioncomponent slot (see components)

Unknown types are errors. A property that doesn't apply to a type is an error too: "source" does not apply to text elements (it is used by: image, video, audio, svg, icon).

Properties every element accepts

PropertyTypeMeaning
typeone of the types aboveRequired
idletters, digits, -, _HTML id: the target of open/close/toggle/scroll actions and #anchor links
classNamestringExtra CSS classes. Any token is allowed, including utility syntax (md:grid-cols-2)
style{property: value}CSS, camelCase or kebab-case: {"padding": "24px", "maxWidth": "60ch"}
responsive{breakpoint: style}CSS applied at or below a breakpoint's width: {"mobile": {"padding": "12px"}}
states{hover, focus, active}CSS for :hover, :focus-visible, :active
transitionstringCSS transition, e.g. "transform 200ms ease"
animationobjectEntrance animation: name (fade fade-up fade-down slide-left slide-right zoom none), trigger (visible, the default, or load), duration, delay (600ms, 1s). Respects reduced-motion settings
visibleobjectRender only for {"auth": "signed-in"}, {"auth": "signed-out"} or {"roles": ["admin"]}. Decided on the server: hidden elements are never sent
ariaLabelstringAccessible name (icon-only buttons need one)
tagHTML tagRender a container or text as another inert tag: ul li article figure h1–h6 nav time strong …
attributes{name: value}Extra HTML attributes: data-*, aria-*, role, title, lang, dir, width, height, hidden. Others are ignored
styleSourcepathA stylesheet to include with this element
data, item, empty, errorMake the element data-driven. See data binding

Containers

element (in a .iwc file)
{
  "type": "section",
  "id": "features",
  "className": "band",
  "layout": { "direction": "row", "align": "center", "justify": "between", "gap": "24px", "wrap": true },
  "responsive": { "tablet": { "flexDirection": "column" } },
  "elements": [
    { "type": "text", "variant": "h2", "content": "Features" },
    { "type": "spacer", "style": { "height": "24px" } },
    { "type": "divider" }
  ]
}
TypeRendersNotes
container, box, main<div>Generic wrappers
section, header, footer, nav, asidethat tagnav also takes items ([{label, target}]) and renders them as links
sidebar<aside>
rowflex rowChildren side by side
column, stackflex columnChildren stacked
gridCSS gridUse columns
spacerempty <div>Size it with style
divider<hr>

Container properties:

  • layout: flexbox. direction (row column row-reverse column-reverse), align (start center end stretch baseline), justify (start center end between around evenly; note between, not space-between), gap ("16px"), wrap (boolean).
  • columns: on any container, makes it a grid. A number is that many equal columns; a string is a CSS grid-template-columns ("2fr 1fr").
  • responsiveColumns: columns per breakpoint, {"tablet": 2, "mobile": 1}.
  • gap: gap between grid or flex children.
  • content / bind: containers may also hold text directly.
element (in a .iwc file)
{
  "type": "grid",
  "columns": 4,
  "responsiveColumns": { "tablet": 2, "mobile": 1 },
  "gap": "24px",
  "elements": [
    { "type": "box", "className": "tile", "content": "One" },
    { "type": "box", "className": "tile", "content": "Two" },
    { "type": "box", "className": "tile", "content": "Three" },
    { "type": "box", "className": "tile", "content": "Four" }
  ]
}

Text

element (in a .iwc file)
{ "type": "text", "variant": "h1", "content": "Hello" }
PropertyMeaning
contentLiteral text. Line breaks (\n) are kept. HTML is escaped, never interpreted
bindText from data instead: "name", "user.name", "props.title", "params.slug", "query.q", "state.cart.total" (see data binding)
varianttitle→h1, h1…h6, heading→h2, subheading→h3, paragraph→p (default), caption→small, label→span, span, eyebrow→p, quote→blockquote, code→pre. Each also adds the class iwc-text-<variant>
formattext number (1,234) currency ($1,234.00) percent (0.25→25%) date (2026-11-05) datetime
currencyISO code for format: currency (USD EUR GBP INR JPY AUD CAD have symbols; others print the code)
prefix, suffixAdded around the value: "prefix": "Total: "

A text element needs content or bind.

element (in a .iwc file)
{ "type": "text", "variant": "subheading", "bind": "price", "format": "currency", "currency": "EUR", "prefix": "From " }

Media

element (in a .iwc file)
{ "type": "image", "source": "/assets/hero.jpg", "alt": "A brass lamp in a reading corner", "ratio": "4/3", "fit": "cover" }
TypeProperties
imagesource (an /assets/… path, an https:// URL, or a non-SVG data:image/…), alt (warning if missing), fit (cover contain fill none), ratio (16/9). Bound images use "binds": {"source": "image", "alt": "name"}. Images load lazily; loading: eager loads at once, priority also asks for it first (the picture visitors see first)
video, audiosource, poster (video), autoplay (also mutes, as browsers require), loop, muted, controls (audio shows controls unless autoplaying)
svgsource: an SVG file shown as an image (SVG is never inlined)
iconname: one of alert arrow-left arrow-right award basket bell book briefcase calendar camera cart chart check check-circle chevron-down chevron-left chevron-right chevron-up clock close compare copy cpu credit-card download dumbbell edit external eye file filter footprints gem gift globe grid headset heart heart-outline help home image info list lock log-out mail map-pin menu minus more package percent phone plus printer refresh search settings share shield shirt sliders sofa sort sparkles star store tag thumbs-up trash trending truck upload user users wallet x-circle zap; or source: your own image file. Add ariaLabel if the icon carries meaning. name may be an expression ("=done ? 'check' : 'package'"); a literal name that isn't in the set is a compile error
placeholderA neutral block for media you don't have yet

Asset paths are checked at compile time: asset /assets/hero.jpg not found.

element (in a .iwc file)
{
  "type": "row",
  "layout": { "gap": "12px" },
  "elements": [
    { "type": "button", "label": "Shop now", "action": "navigate", "target": "/products" },
    { "type": "button", "label": "Size guide", "variant": "secondary", "action": "open", "target": "size-guide" },
    { "type": "link", "label": "Read the docs", "target": "https://example.com/docs", "newTab": true }
  ]
}
PropertyMeaning
labelThe text (or use content/bind, or child elements for icon buttons)
variantButtons: primary (default), secondary, ghost; any other word adds the class iwc-button-<word>
actionWhat happens on click (below). A link without an action just navigates to target
targetA route (/products, filled from data: /products/:slug), URL, #anchor, mailto:, tel:, or an element id
newTabOpen in a new tab (with rel="noopener noreferrer")
store, mutation, valueFor action: "state"
api, valueFor action: "api"
actionEffectNeeds
navigateGo to target (renders as a real link)target
openOpen the dialog/element with id targettarget
closeClose it (or the dialog the button is in, without target)
toggleOpen/close ittarget
scrollSmooth-scroll to the element with id targettarget
stateRun a client-state mutation, e.g. add to cartstore, mutation, optional value
apiCall an API with value as the JSON body; on success go to target or show "Done", on failure show the errorapi
logoutSign out, then go to target (default /)

Links in a page are checked: a target route that no screen has is a warning; javascript: URLs are compile errors.

element (in a .iwc file)
{ "type": "button", "variant": "ghost", "ariaLabel": "Remove", "action": "api", "api": "note-delete.api.iwc", "target": "/dashboard", "elements": [{ "type": "icon", "name": "trash" }] }

The api action fills :id-style path parameters from the current record, so a delete button inside a list deletes its own row.

Badges, ratings and reviews

element (in a .iwc file)
{
  "type": "column",
  "elements": [
    { "type": "badge", "content": "New" },
    { "type": "rating", "rating": 4.5 },
    { "type": "review", "rating": 5, "content": "Beautifully made.", "author": "Maya, Bristol" },
    { "type": "review", "items": [
      { "rating": 5, "content": "Worth every penny.", "author": "Tom" },
      { "rating": 4, "content": "Lovely, slow delivery.", "author": "Priya" }
    ] }
  ]
}

rating takes a number (0–5) or bind. review takes rating, content and author (or binds for each), or items for several.

Cards

A card lays out a record: media, badge, title, subtitle, text, price, plus child elements (actions) below.

element (in a .iwc file)
{
  "type": "card",
  "fields": { "title": "name", "subtitle": "maker", "text": "summary", "price": "price", "image": "image", "badge": "label", "link": "/products/:slug" },
  "currency": "USD",
  "elements": [{ "type": "button", "label": "Add to cart", "variant": "secondary", "action": "state", "store": "cart", "mutation": "add", "value": { "id": "item.id", "name": "item.name", "price": "item.price", "quantity": 1 } }]
}

Each fields value is a data path; link may be a route pattern filled from the record.

Lists, tables and charts

element (in a .iwc file)
{ "type": "list", "items": [{ "label": "Shipping", "target": "/help/shipping" }, { "label": "Returns", "target": "/help/returns" }, { "label": "Plain text item" }] }

list renders <ul>: items (with optional target links), or elements (each in an <li>), or a data source with an item.

element (in a .iwc file)
{
  "type": "table",
  "data": { "source": "DATABASE.orders", "columns": ["total", "status", "createdAt"], "sort": { "field": "createdAt", "order": "desc" }, "limit": 20 },
  "cells": [
    { "field": "createdAt", "label": "Placed", "format": "date" },
    { "field": "status", "label": "Status" },
    { "field": "total", "label": "Total", "format": "currency" }
  ],
  "currency": "USD",
  "empty": { "type": "text", "content": "No orders yet." }
}
element (in a .iwc file)
{ "type": "chart", "chart": { "kind": "bar", "x": "name", "y": "stock", "height": 220 }, "data": { "source": "DATABASE.products", "columns": ["name", "stock"] } }

Tables need cells and data; charts need chart (kind: bar or line, x, y, height) and data. Charts are inline SVG drawn on the server. scale multiplies values for display and format/currency write them ("scale": 0.01, "format": "currency", "currency": "EUR" shows amounts stored in cents); the top of the scale is labelled.

Interactive elements

element (in a .iwc file)
{
  "type": "navbar",
  "className": "site-header",
  "opensOn": "hover",
  "collapseAt": "tablet",
  "elements": [
    { "type": "link", "label": "Brand", "target": "/", "className": "brand" },
    { "type": "link", "label": "Sign in", "target": "/login", "visible": { "auth": "signed-out" } }
  ],
  "items": [
    { "label": "Shop", "items": [
      { "label": "All", "target": "/products" },
      { "label": "Lighting", "target": "/products?category=lighting" }
    ] },
    { "label": "About", "target": "/about" }
  ]
}
  • elements are always visible (brand, account, cart). items are the menu links. An item with its own items becomes a dropdown, nested to any depth.
  • Below the collapseAt breakpoint (mobile, the default, or tablet), the links fold behind a menu button. collapse: "all" folds every element except the first (the brand) as well.
  • opensOn: click (default) or hover. Hover falls back to click on touch screens.
  • The link matching the current page gets aria-current="page".
element (in a .iwc file)
{
  "type": "dropdown",
  "label": "Account",
  "opensOn": "click",
  "items": [{ "label": "Profile", "target": "/account" }, { "label": "Orders", "target": "/account/orders" }],
  "elements": [{ "type": "button", "label": "Sign out", "variant": "ghost", "action": "logout" }]
}

items are links, and elements can hold anything, including whole panels for a mega menu. Escape or a click outside closes it.

element (in a .iwc file)
{
  "type": "column",
  "elements": [
    { "type": "button", "label": "Size guide", "action": "open", "target": "size-guide" },
    { "id": "size-guide", "type": "modal", "elements": [{ "type": "text", "variant": "h3", "content": "Size guide" }, { "type": "text", "content": "Floor lamps are 150–190cm." }] },
    { "type": "button", "label": "Filters", "action": "toggle", "target": "filters" },
    { "id": "filters", "type": "drawer", "side": "right", "elements": [{ "type": "text", "content": "Filter options" }] }
  ]
}

Both render a native <dialog> with a close button; Escape closes them. drawer takes side (left default, right, top, bottom).

tabs and accordion

element (in a .iwc file)
{
  "type": "tabs",
  "tabs": [
    { "label": "Details", "elements": [{ "type": "text", "content": "Solid oak." }] },
    { "label": "Shipping", "elements": [{ "type": "text", "content": "Ships in 2 days." }] }
  ]
}
element (in a .iwc file)
{
  "type": "accordion",
  "open": true,
  "items": [
    { "title": "Delivery", "content": "2–3 days." },
    { "title": "Returns", "elements": [{ "type": "link", "label": "Read the policy", "target": "/returns" }] }
  ]
}

open: true opens the first panel.

element (in a .iwc file)
{
  "type": "carousel",
  "autoplay": true,
  "interval": "6s",
  "elements": [
    { "type": "image", "source": "/assets/slide-1.jpg", "alt": "Workshop" },
    { "type": "image", "source": "/assets/slide-2.jpg", "alt": "Showroom" }
  ]
}

Each child is a slide, with previous/next buttons and swipe. Autoplay pauses on hover and focus, and is off for visitors who prefer reduced motion. slider is the same element.

A full page

See examples/cookbook/ui/landing.ui.iwc: a hero with an entrance animation, a feature grid, featured products from the database, reviews and an FAQ accordion. The store's ui/home.ui.iwc adds a carousel, a modal with tabs, static and live product grids, and stats.

Common mistakes

MessageCause
text elements need "content" or "bind"A text with neither
button elements need a "label"Give label, content, bind or child elements
action "open" needs a "target"open/close/toggle/scroll/navigate need target
"layout" must be an object, got stringIn an element, layout is flexbox settings. The page's layout file goes on the screen, not the element
"justify" must be one of … got "space-between" (did you mean "between"?)Use between, around, evenly
a list data source needs an "item" element to repeatData containers need item (or mode: "single")
slot elements only appear inside componentsslot is for component roots
image has no "alt" text (warning)Describe the image, or use "alt": "" for decoration

Source: iwc/docs/ui-development.md