Styling — HTMQL Documentation

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-grid so they line up on desktop and stack on mobile with no extra work.
  • Control width - add one f-* class (f-xsf-2xl) to an input/select/textarea to suggest a width; on small screens it goes full-width automatically.
  • Interactive tables - add data-table to a <table> for sorting, filtering, and pagination (Tablekit.js). See Tables (Tablekit).
  • Intent colours - ok / info / warn / bad on a <button> or <div class="box">.
  • Status pills - badge (give it your own colour class for the palette).
  1. Don't edit missing.min.css or style.css. They are framework files and are overwritten when the server is upgraded. Put every app-specific rule in custom.css.
  2. 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; }
    
  3. Style elements and existing primitives before inventing classes. A bare <dialog> is already a modal; a field-grid already lays out a form. Reach for a new class only when there is no primitive for the job.
  4. 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.
  5. Promote, don't fork. When a custom pattern proves genuinely reusable across apps, move it from custom.css up into style.css so 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:0flex-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 in system/header.html; your page content is injected into #page inside it. Don't re-create the shell in a page.
  • titlebar - style.css replaces Missing's embossed grey bar with a subtle accent-tinted band. Use it the same way; it just looks different.
  • <dialog> - style.css styles 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-min sets the smallest a cell gets before the row wraps (default 12ch).
  • Mark every caption with field-label.
  • span-2 / span-3 widen a cell when the grid is wide enough; span-all always 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.css already 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 with querySelector("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). Use aria-label or aria-hidden on 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:

  1. Lay out form headers with field-grid (not colspan tables) so fields reflow.
  2. Size controls with f-* classes rather than fixed pixel widths - they go full-width on phones automatically.
  3. Let tables be tables - data-table and data-lines already 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-xstxt-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.