Styling
HTMQL pages are styled by a three-layer CSS cascade, served from /lib and linked
in system/header.html in this order:
| Layer | File | Owned by | Purpose |
|---|---|---|---|
| 1 | missing.min.css |
upstream | Missing.css - a near-classless framework. Styles plain semantic HTML (<table>, <form>, <button>, <dialog>, …) and supplies a small set of utility classes and CSS variables. |
| 2 | style.css |
the framework | HTMQL's opinionated defaults layered on Missing: the sidebar layout, title bar, data-table styling, form/field helpers, and the field-grid / modal / toast / badge primitives. Shipped with the server; treat as framework code. |
| 3 | custom.css |
your app | App-specific overrides and one-off styles. Optional - add the file under libs/ and link it after style.css in system/header.html. |
Later layers win, so style.css overrides Missing, and custom.css overrides both.
Getting started
The goal is that a page needs little or no CSS of its own. Write semantic HTML and it is styled automatically:
<h1>Customers</h1>
<form hx-post="{{route}}">
<label>Name <input name="name"></label>
<button type="submit" class="ok">Save</button>
</form>
<table data-table>
<thead><tr><th data-key="name" data-type="text">Name</th></tr></thead>
<tbody>{{range .select}}<tr><td>{{.name}}</td></tr>{{end}}</tbody>
</table>
A typical page only reaches for a handful of classes:
- Layout for a header/edit form - wrap labelled fields in a
field-gridso they line up on desktop and stack on mobile with no extra work. - Control width - add one
f-*class (f-xs…f-2xl) to an input/select/textarea to suggest a width; on small screens it goes full-width automatically. - Interactive tables - add
data-tableto a<table>for sorting, filtering, and pagination (Tablekit.js). See Tables (Tablekit). - Intent colours -
ok/info/warn/badon a<button>or<div class="box">. - Status pills -
badge(give it your own colour class for the palette).
Recommended approach for customizing
- Don't edit
missing.min.cssorstyle.css. They are framework files and are overwritten when the server is upgraded. Put every app-specific rule incustom.css. - Prefer CSS variables over hard-coded values. Missing exposes variables such as
--accent,--fg,--bg,--gap, and--border-radius. Using them keeps custom styles consistent with the rest of the UI and lets you re-theme in one place. For example, set a brand accent once::root { --accent: #16529c; } - Style elements and existing primitives before inventing classes. A bare
<dialog>is already a modal; afield-gridalready lays out a form. Reach for a new class only when there is no primitive for the job. - Keep page files class-light. If you find yourself repeating the same markup +
classes across pages, factor it into a reusable template (
templates/) rather than copying it. - Promote, don't fork. When a custom pattern proves genuinely reusable across
apps, move it from
custom.cssup intostyle.cssso every app benefits - and drop the app-specific prefix from its class name when you do.
Class reference
Missing.css (Layer 1). Most styling is automatic from semantic HTML - write the
right element and it looks right. The tables below cover the classes useful for typical
business apps; a handful of decorative extras (massivetext, text-stroke,
aestheticbreak, font-family switches, …) are omitted - see missing.style
for the exhaustive list.
Styled automatically, no class needed: <table>, <form>, <fieldset>, <button>,
<input>, <select>, <textarea>, <dialog>, <details>/<summary>, <article>,
<section>, <figure>, <nav>, <progress>, <meter>, <kbd>, <mark>, headings,
and lists.
Components & helpers:
| Class / selector | Effect |
|---|---|
ok info warn bad |
Intent colour on a <button>, <a class="button">, <mark>, or <div class="box">. |
box |
A bordered, padded container (combine with an intent colour for callouts). |
chip |
A small inline tag/label. |
tool-bar |
A horizontal bar of controls (use on <figure>/<section>). |
titlebar |
A page/section heading band (restyled by style.css - see below). |
navbar |
Horizontal navigation bar. |
breadcrumbs |
Breadcrumb trail (on a <nav> with a list). |
list-of-links / nested-list |
Link list / indented nested list styling. |
iconbutton |
A button sized for a single icon/glyph. |
f-switch |
Render a checkbox as a toggle switch. |
sub-title |
A muted secondary heading line. |
allcaps bold italic monospace center |
Inline text styling. |
v-h |
Visually hidden (screen-reader only). |
sidebar-layout, #sidebar |
The two-column app shell. Owned by system/header.html - pages render inside #page and should not redefine the shell (see HTMQL interactions below). |
Density modifiers (set spacing on a container and its children):
| Class | Effect |
|---|---|
crowded / dense / packed |
Progressively tighter spacing (e.g. denser tables/forms). |
spacious / airy |
Looser spacing. |
*-inside (crowded-inside, …) |
Apply the density to children only, not the element itself. |
Layout & spacing utilities. These read as property:value and use a fixed set of
values, not arbitrary ones:
| Pattern | Notes |
|---|---|
width:100%, height:100% |
Full width / height. |
padding, margin (+ -block, -inline, -start, -end variants) |
Bare class = one --gap of space; the :0 form (e.g. padding-block:0) removes it. |
border:none (+ side variants) |
Remove a border. |
f-row / f-col (aliases flex-row / flex-column), flex-wrap:wrap |
Flexbox container + wrapping. |
flex-grow:0…flex-grow:12 |
Flex grow factor (fixed steps 0–12). |
align-items:center, justify-content:space-between, align-self:* |
Flex/grid alignment (values: start center end stretch baseline; justify also space-between/around/evenly). |
grid, grid-even-rows, grid-variable-cols |
Grid containers. |
overflow:auto / overflow-x:scroll / overflow-y:clip |
Overflow control. |
fixed sticky top bottom left right float:left float:right |
Positioning. |
hidden block inline flex-row contents |
Display. |
fullbleed fullscreen |
Break out to full width / full viewport. |
CSS variables worth knowing (set in
:root, used everywhere):--accent,--fg,--bg,--muted-fg,--gap,--border-radius,--line-length.
HTMQL interactions with Missing.css:
sidebar-layout/#sidebar- the app shell lives insystem/header.html; your page content is injected into#pageinside it. Don't re-create the shell in a page.titlebar-style.cssreplaces Missing's embossed grey bar with a subtle accent-tinted band. Use it the same way; it just looks different.<dialog>-style.cssstyles it as the app modal (see Layer 2). A bare<dialog>is all you need for a modal.
Nothing in Missing.css is disabled or unusable under HTMQL - these three are simply restyled or shell-owned.
style.css (Layer 2). HTMQL's additions:
| Class / selector | Effect |
|---|---|
field-grid |
Container that flows field cells into auto-wrapping columns. Tune with --field-min. See Field grid. |
field |
A label/control cell: caption stacked over its control. Works inside field-grid or standalone. |
field-label |
Marks the caption inside a field so it reads as a heading. |
span-2 span-3 span-all |
Widen a field cell within a field-grid (span-all = full row). |
f-xs f-sm f-md f-lg f-xl f-2xl |
Preferred width for one input/select/textarea (16 → 100 ch). Full-width on small screens. |
dialog (element) |
Styled as an app modal - <header> = title row, <footer> = actions. Open with me.showModal(). |
modal-wide |
Wider modal whose body (modal-body) scrolls between a pinned header/footer. |
toast / toast-hide |
Transient top-right notification; add toast-hide to fade out. |
badge |
A status pill; layer your own colour class on top. role="button" makes it interactive. |
titlebar |
Re-flattened from Missing's embossed bar into a subtle accent-tinted heading band. |
tool-bar-right |
Right-aligns tool-bar contents. |
table[data-lines] |
Inline-editable line-item table: fixed layout, controls fill their column. Add a <colgroup> for proportional widths. |
data-table (attr) |
Activates Tablekit (sort/filter/paginate). See Tables (Tablekit). |
num |
Numeric cell/value: right-aligned with tabular (equal-width) digits so columns of numbers line up. |
frame |
Full-width bordered box for maps/images/previews (a map container still needs an explicit height). |
text-ok text-warn text-bad |
Intent colours for plain text, using the same palette variables as Missing's ok/warn/bad (dark-mode aware). |
| (disabled controls) | Disabled buttons are muted/greyed; disabled inputs get a locked grey fill with readable text - automatic, no class. |
Field grid
field-grid is the preferred way to lay out detail/edit page headers. Each child is a
field cell; cells flow into as many equal columns as fit and collapse to one as the
viewport narrows, with no breakpoints or per-control widths. Avoid colspan tables for
form layout - they cannot reflow on small screens.
<div class="field-grid" style="--field-min: 12ch">
<label class="field"><span class="field-label">Name</span>
<input name="name"></label>
<label class="field"><span class="field-label">Qty</span>
<input type="number" name="qty"></label>
<label class="field span-all"><span class="field-label">Notes</span>
<textarea name="notes"></textarea></label>
</div>
--field-minsets the smallest a cell gets before the row wraps (default12ch).- Mark every caption with
field-label. span-2/span-3widen a cell when the grid is wide enough;span-allalways takes the full row.
Tables (Tablekit)
Plain <table> is styled automatically. For the interactive list pages that most
business apps need - sortable columns, client-side search, and pagination - add the
data-table attribute and the bundled tablekit.js takes over. No JavaScript to write.
<!-- Controls bar: search box, page-size selector, Prev/Next, status. -->
<figure class="tool-bar tool-bar-right" data-table-ui>
{{template "data-table-bar.html" .}}
</figure>
<table class="width:100%" data-table>
<thead>
<tr>
<th data-key="order" data-type="text">Order #</th>
<th data-key="date" data-type="date">Date</th>
<th data-key="total" data-type="number">Total</th>
<th data-no-search>Actions</th>
</tr>
</thead>
<tbody>
{{range .select}}
<tr>
<td>{{.OrderNumber}}</td>
<td data-sort="{{.OrderDate}}">{{ldate .OrderDate}}</td>
<td data-sort="{{.Total}}">{{money .Total}}</td>
<td><a href="/order/{{.OrderKey}}">Edit</a></td>
</tr>
{{end}}
</tbody>
</table>
Table & column attributes:
| Attribute | On | Effect |
|---|---|---|
data-table |
<table> |
Activates Tablekit (sorting, search, pagination). |
data-key="…" |
<th> |
Marks the column sortable; the value is its identifier. Omit to make a column non-sortable. |
data-type="text|number|date" |
<th> |
How that column sorts - numerically, chronologically, or alphabetically - regardless of how the cell is displayed. |
data-no-search |
<th> |
Excludes the whole column from search (e.g. an Actions column). |
By default cells sort and search by their visible text. Two per-cell/row overrides let you separate the displayed value from the sort/search value:
| Attribute | On | Effect |
|---|---|---|
data-sort="…" |
<td> |
Sort this cell by a raw value while showing a formatted one - e.g. data-sort="{{.OrderDate}}" on a cell that displays {{ldate .OrderDate}}, or a raw number on a {{money …}} cell. |
data-search="…" |
<td> or <tr> |
Extra hidden text to match on. On a <tr> it makes the whole row findable by terms that aren't shown (e.g. a customer code); on a <td> it augments that cell's search text. |
Accessibility: make sortable headers keyboard-operable by adding
role="button" tabindex="0"to each<th data-key>-style.cssalready shows the sort glyph and focus ring. (Omitted from the example above for brevity.)
The controls bar. Include {{template "data-table-bar.html" .}} inside an element
marked data-table-ui to get the standard search box, page-size selector, Prev/Next, and
a result-count status. The bar wires itself to the table via these hooks (already present
in the template - listed here for custom bars):
| Attribute | Element | Role |
|---|---|---|
data-table-ui |
container | Marks the controls region; ties it to the page's table. |
data-table-search |
<input type="search"> |
The search box. |
data-table-page-size |
<select> |
Rows per page (<option> values). |
data-table-prev / data-table-next |
<button> |
Pagination. |
data-table-status |
any element | Shows "Page X / Y (N items)". |
style.css styles all of this: .data-table-bar keeps the controls on one line (wraps
on small screens), sortable headers get a sort glyph and focus ring, and rows get a hover
tint. See the Class reference above.
Loading indicator (automatic). When an HTMX request swaps a data-table (or
originates inside data-table-ui) and takes longer than ~300 ms, Tablekit shows a
"Loading…" overlay - no markup required. To place the indicator yourself instead, add an
element with data-table-loading and the hidden attribute anywhere on the page;
Tablekit toggles its hidden attribute during loads.
Large tables. Add hx-disable to the <tbody> so HTMX doesn't process hx-*
attributes on every row at swap time - Tablekit removes the attribute and processes only
the rows on the visible page, and computes row search text lazily on the first keystroke.
For inline row editing (an edit form swapped into a row), use a data-lines table
instead - see the Class reference and the Common Page Patterns.
The page shell and toolbars
Pages render inside a fixed app shell defined in system/header.html: a left sidebar
(#sidebar, the auto-generated menu) beside a main content area. Your page's HTML is
injected into #page in that area, under the title bar (which shows the page's
<title>). You don't build the shell - just write page content.
A menu page (one with a #<seq> Label header line) that contains no <title>
element automatically gets its menu label as the document title - the server injects
<title>Label</title> ahead of the page output. Pages with dynamic titles (record
names, invoice numbers) should still declare their own <title>.
Keep exactly one
<title>element per page. The title bar is populated by HTMX, which finds the title withquerySelector("title")on the swapped fragment and uses it only when it is a top-level child of the fragment. A nested<title>- most commonly an SVG<title>used as a tooltip inside an inline<svg>(map markers, list icons) - is matched by that selector but rejected for being nested, leaving the title bar blank. The tell-tale symptom is a title that is blank after in-app (boosted) navigation but correct after a full page refresh (which uses the real<head>title). Usearia-labeloraria-hiddenon the<svg>for accessibility instead of an SVG<title>.
- On narrow screens the sidebar collapses into a slide-in drawer behind a ☰ Menu button - automatic (see Responsive design).
- A page that contains its own
<body>tag (print/PDF pages) bypasses the shell entirely - see Designing for print and PDF.
Action toolbars. Put a page's primary actions (Save, Cancel, …) in a tool-bar; add
tool-bar-right to push them to the right edge. Emphasise the primary action by wrapping
it in <strong>, and use intent colours for meaning:
<section class="tool-bar tool-bar-right">
<strong><button type="submit" form="my-form" class="ok">Save</button></strong>
<button type="button" class="bad">Delete</button>
<a href="/list">Back to list</a>
</section>
tool-bar lays the controls out in a single row with consistent spacing and wraps them on
small screens.
Responsive design
HTMQL pages are responsive by default - a page built from semantic HTML and the primitives above should already work on a phone with no extra effort. Two breakpoints do the work, automatically:
| Breakpoint | What happens |
|---|---|
| ≤ 75ch (~tablet) | The sidebar menu collapses into a slide-in drawer behind a ☰ Menu button; content takes the full width. |
| ≤ 768px (phone) | f-* width hints are ignored and controls go full-width; field-grid rows collapse toward one column; wide form/line tables become horizontally scrollable rather than overflowing the page. |
To get this for free, a page only needs to:
- Lay out form headers with
field-grid(not colspan tables) so fields reflow. - Size controls with
f-*classes rather than fixed pixel widths - they go full-width on phones automatically. - Let tables be tables -
data-tableanddata-linesalready handle small screens.
The viewport meta tag is already set in header.html, so you never add it per page. If you
must write a custom breakpoint, match the framework's phone tier: @media (max-width: 768px)
(the same tier missing.css uses, so both layers switch modes together).
View vs. edit forms (read-only state)
A common need is showing the same form read-only unless the user may edit it (permissions,
record status, …). Wrap the controls in a <fieldset> and toggle its disabled
attribute - one attribute disables every control inside it:
<form hx-put="{{route}}">
<fieldset {{if notauth "editor"}}disabled{{end}}>
<div class="field-grid">
<label class="field"><span class="field-label">Name</span>
<input name="name" value="{{.record.name}}"></label>
...
</div>
</fieldset>
{{if auth "editor"}}<button type="submit" class="ok">Save</button>{{end}}
</form>
style.css styles the disabled state automatically: inputs get a locked grey fill with
still-readable text, and disabled buttons are muted/greyed - so a read-only form clearly
reads as read-only with no extra classes. HTMQL uses <fieldset> as a structural wrapper
for exactly this purpose, and style.css strips the browser's default fieldset border so
the wrapper is invisible.
The disabled
<fieldset>is only the UI; the page header's authorization (e.g.PUT:+editor) is what enforces read-only on the server. See Authorization.
Icons
Menu entries take an optional inline SVG icon via the ##<svg> header line (see
Menu); style.css sizes and aligns them with the label via .nav-icon. By
default the icons inherit the link text colour; set --nav-icon-color (in custom.css,
on :root or #sidebar) to tint them all so they stand out - e.g. :root { --nav-icon-color: #1f75d6; }.
To colour a single menu entry differently, hard-code the colour in that page's
##<svg> icon (stroke="#e67e22" or fill="…"); a literal colour on the SVG overrides
both currentColor and --nav-icon-color.
To colour groups of related entries (e.g. all purchasing pages one colour, all field
pages another), set --nav-icon-color per group by route prefix in custom.css - no
per-page edits, and new pages join the group automatically:
#sidebar a[href^="/po"] .nav-icon { --nav-icon-color: #1f75d6; } /* purchasing */
#sidebar a[href^="/sites"] .nav-icon,
#sidebar a[href^="/tasks"] .nav-icon { --nav-icon-color: #16803a; } /* field work */
Keep the label text one colour and let only the icon carry the group colour - coloured nav text hurts readability and contrast.
Anywhere else, just drop an inline <svg> into a button or heading - use
stroke="currentColor" / fill="currentColor" so the icon inherits the surrounding text
colour, and size it in em so it scales with the text. For an icon-only button, use
Missing's iconbutton and always supply an aria-label:
<button class="iconbutton" aria-label="Delete">
<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none"
stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4h8v2m-9 0v14h10V6"/></svg>
</button>
Dark mode and theming
Theming. The whole UI keys off a small set of CSS variables, so re-theming is a one
-block change in custom.css. The most useful is the accent colour:
:root { --accent: #16529c; } /* links, focus rings, title bar, table hover, … */
Other variables worth knowing: --fg, --bg, --muted-fg, --gap (the base spacing
unit), --border-radius, and --line-length. Prefer these over hard-coded values in
custom styles so everything stays consistent.
Dark mode. Missing.css ships with color-scheme: light dark, so by default the app
follows the operating system's light/dark setting automatically - no work required.
To override that, add a class to the <html> element in system/header.html:
class="-dark-theme"- always dark.class="-no-dark-theme"- always light.- (no class) - follow the OS (the default).
Because colours come from variables, custom styles written against --fg/--bg/--accent
adapt to dark mode for free; hard-coded hex colours do not.
Designing for print and PDF
Print and PDF pages are standalone documents, not app pages. Because they include a
<body> tag they bypass the app shell (sidebar, title bar) - exactly what you want for a
clean printout. They are produced by the print and pdf special aliases; see
Special Page Features for the alias mechanics. This section is
about how to style them.
Style print/PDF pages with an inline <style> block - not the app stylesheets. The
print alias fires window.print() immediately after the page renders, so a linked
stylesheet can lose the race and not be applied when the dialog opens. Inlining the rules
guarantees they're present. (The app's missing.css / style.css are also the wrong tool
on paper - screen styling like the sidebar layout doesn't belong in a printout.)
<title>Invoice #{{.select.number}}</title>
<style>
/* Make background colours and borders actually print. */
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important;
box-sizing: border-box; }
body { margin: 1cm; font-size: 10pt; font-family: Calibri, Arial, sans-serif; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #000; padding: 4px 6px; vertical-align: top; }
th { background: #d0d0d0; text-align: left; }
</style>
<body>
... document content ...
</body>
libs/print.css is a reference/starting point, not a stylesheet print pages link. It
collects the common print rules below; copy the parts you need into the page's inline
<style>. The one exception is the pdf alias - it renders via headless Chrome, which
does fully load the page first, so a PDF page may instead link it:
<link rel="stylesheet" href="/lib/print.css" media="print"> (the PO PDF page does this).
The browser-print path should always inline.
Common print helpers (define inline, or copy from print.css):
| Class | Effect |
|---|---|
table.no-border |
Remove all borders (e.g. a letterhead laid out as a borderless table). |
table.fixed-columns |
Fixed layout; long values wrap instead of widening columns. |
txt-l / txt-c / txt-r |
Text align left / center / right. |
txt-vt / txt-vm / txt-vb |
Vertical align top / middle / bottom. |
txt-xs … txt-xxl |
Print font sizes (8pt → 16pt). |
For multi-page documents, control breaks with the CSS browsers honour when printing:
break-inside: avoid on a row/block keeps it together, break-after: page forces a new
page. Margins for the print path come from your body { margin: … }; for the pdf
path they come from the alias (margin_inches / margin_cms) or config.json defaults.