Skip to content
Customizing the UI

Customizing the UI

The whole UI — templates, compiled CSS/JS, icons — ships inside your binary via go:embed. Two overlay filesystems on Config let you replace any piece without forking:

steward.New(steward.Config{
    // ...
    Brand:       "Acme Ops",
    TemplatesFS: os.DirFS("admin/templates"), // your files win over embedded ones
    AssetsFS:    os.DirFS("admin/assets"),    // extra icons, overridden CSS, …
    Dev:         true,                        // re-parse templates per request
})

Overriding templates

Copy the built-in views into your project and edit:

steward publish views --dir admin/templates

Template names are full relative paths (grid/table.html, layout/sidebar.html, auth/login.html, …). A file at the same path in TemplatesFS replaces the embedded one — override a single partial or the whole layout. With Dev: true changes apply on refresh; in production templates parse once at build.

Useful template funcs available everywhere: {{icon "name"}} (inline SVG), {{url "posts" "create"}} (prefix-aware URLs), {{asset "dist/app.css"}} (content-hashed asset paths), and {{dict}} for passing values into partials.

Utility classes an override can use

The stylesheet is compiled from the class names the framework’s own templates use, so a Tailwind utility none of them happens to use is not in the bundle. Writing one in an override fails silently: the class is applied, no rule matches it, and the page is subtly wrong — a row that does not centre, a gap that is not there. It reads correctly and renders incorrectly.

With Dev: true the panel reads the overrides at boot and names what it finds:

WARN steward: template uses a class the stylesheet has no rule for
     where="layout/base.html: gap-5, h-16, justify-items-center, mx-auto"

A class your own ThemeCSS defines counts as known, so the warning is about rules that exist nowhere rather than rules the framework did not write. The fix is usually to say it in CSS rather than in class names — most overrides need a handful of declarations, not a utility framework.

The collapsed sidebar

The header’s toggle collapses the sidebar to a rail: the labels go, the icons stay, and the page moves over to meet it. Nothing is configured to get that — it is what collapsing does.

Two things make a rail readable, and both are worth checking in a panel of your own:

  • Every menu entry wants an icon. Register[T](app).Icon("news") sets one for a resource; a menu entry that has none falls back to the first letter of its title, which is legible but says less than a glyph.
  • The panel wants a mark. Config.BrandIcon is what stands for the panel when its name is hidden. Without one the brand’s first letter is used.

The entry you are on is filled a shade past the colour hover uses, in the rail as well as the open sidebar, so the two are not mistaken for each other. It is mixed from --sidebar-accent and --primary, so a theme that changes those changes this with them.

The rail is a working menu, not a picture of one: its entries are clickable and focusable, and hovering or tabbing to an icon shows the label beside it. The label is never removed from the markup, only taken out of the flow, so it still names its link for a screen reader.

Below 48rem the sidebar is an overlay that slides off entirely rather than a rail — a rail on a phone spends a tenth of the screen on icons. The rail’s width is --sidebar-rail-width (3.25rem), which a theme can change like any other token.

Icons

Every Lucide icon is available — about 1,600 — from one vendored sprite embedded in the binary. {{icon "name"}} and Icon(name) take any of them, and the glyph is inlined as a complete <svg>, so there is no per-page weight and nothing for a strict CSP to object to.

steward.Register[Invoice](app).Icon("receipt")
app.Icons() // every name available to this panel, sorted

Use Lucide’s own names, as listed on their site. Steward also resolves the few names it shipped before the sprite, which Lucide has since renamed:

Steward’s old nameLucide today
homehouse
newsnewspaper
filterfunnel
menu-2menu
columnscolumns-3
circle-helpcircle-question-mark
info-circleinfo

These still work, so an existing panel does not go blank on upgrade, but they are not offered in the picker — new code should use the canonical name.

An unknown name renders as blank space rather than failing, which is easy to miss. Verify() reports it instead, naming the icon and listing what is available — so assert Verify() in a test and a typo fails in CI instead of showing up as a gap in the sidebar. It does not fail Build: a missing icon is cosmetic and should not stop a panel from serving.

Anywhere a user picks one, use the Icon form field rather than a text input.

Your own icons

Drop an SVG into AssetsFS under icons/ — no framework change, and it appears in the picker beside Lucide’s. A file also overrides a Lucide glyph of the same name, which is the way to restyle one:

steward.Register[Invoice](app).Icon("receipt") // AssetsFS: icons/receipt.svg

Upgrading Lucide

The sprite is committed so a clone builds a working panel. To move version, bump LUCIDE_VERSION in the Makefile and re-vendor:

make vendor-lucide

Confirming a form submission

Grid actions confirm through Action.Confirm, but a plain form in a template can ask too — add data-steward-confirm-submit and the submit routes through the same alert dialog:

<form method="post" action="{{url "danger"}}"
      data-steward-confirm-submit
      data-confirm-title="Turn off two-factor authentication?"
      data-confirm-description="Your account will be protected by its password alone."
      data-confirm-action="Turn it off"
      data-confirm-danger="1">

Only data-steward-confirm-submit is required. data-confirm-danger="1" styles the confirming button destructively. With scripting off the form submits normally — the dialog is about intent, so the server must still enforce whatever the action requires.

Buttons follow Basecoat’s convention: class="btn" plus data-variant (primary, secondary, outline, ghost, destructive, link) and data-size (xs, sm, lg, icon, …). There are no btn-outline-style classes — a name like that silently renders as unstyled text.

Overriding a page template

Every page’s outer wrapper caps its grid track:

<div class="p-6 grid grid-cols-1 gap-4">

Keep grid-cols-1 (or an explicit min-w-0 on the items). A bare grid gives its implicit track auto sizing, and a grid item’s automatic minimum is its min-content width — so one wide table of nowrap cells widens the whole page instead of scrolling inside its own container. grid-cols-1 is repeat(1, minmax(0, 1fr)), which caps that minimum at zero.

Branding & chrome

  • Config.Brand names the panel in the sidebar and titles.
  • Config.ThemeCSS sets the colours, corner rounding and fonts — see below.
  • Config.Prefix moves the whole panel, which is at the root by default.
  • Config.CurrencySymbol prefixes every Currency field ($ by default); one field overrides it with Field.Symbol.
  • A command palette opens on ⌘K / Ctrl+K, or from the search button in the header — see below.
  • Dark mode is built in — the toggle stores a cookie and the server renders the right theme with no flash of the wrong mode.

Theme: colours, corners, fonts

Config.ThemeCSS is inlined in every page’s head, after the stylesheet. The panel is built on design tokens, so redefining them is the whole job:

steward.Config{
    ThemeCSS: `
        :root {
            --primary: oklch(55% 0.20 265);
            --primary-foreground: oklch(99% 0 0);
            --ring: oklch(55% 0.20 265);
            --radius: 0.25rem;
        }
        .dark {
            --primary: oklch(70% 0.18 265);
        }
    `,
}

That reaches the login page and the password-reset pages too, not only the panel — they render as documents of their own, and a panel branded everywhere but its first screen is the usual way this goes wrong.

The tokens

Colours are oklch() by default, but any CSS colour works. Every one of these has a .dark counterpart; redefine both, or only the one you mean to change.

Forty-four in all. Every surface comes as a pair — the surface and the text on it — so changing one without the other is how contrast gets lost.

The page

Default
--background / --foregroundwhite / near-blackthe page itself
--card / --card-foregroundwhite / near-blackcards, which most pages are made of
--popover / --popover-foregroundwhite / near-blackmenus, comboboxes, the palette
--primary / --primary-foregroundnear-black / whiteprimary buttons, the active sidebar item, links
--secondary / --secondary-foregroundnear-white / near-blacksecondary buttons
--accent / --accent-foregroundnear-white / near-blackhover states, highlighted menu items
--muted / --muted-foregroundnear-white / greyhints, empty states, table chrome
--destructivereddelete buttons, error alerts. Its text colour is not a token — the components set white
--border / --inputlight greyevery border; every field outline
--ringgreythe focus ring
--radius0.625remcorner rounding. Components derive theirs from it, so one value moves buttons, fields, cards and menus together

The sidebar, themed apart from the page so it can be darker than the content beside it:

Default
--sidebar / --sidebar-foregroundnear-white / near-blackthe panel behind the navigation
--sidebar-primary / --sidebar-primary-foregroundnear-black / whitethe active item
--sidebar-accent / --sidebar-accent-foregroundnear-white / near-blackhover
--sidebar-borderlight greyits edge and separators
--sidebar-ringgreyfocus inside it
--sidebar-width16remwidth on a desktop
--sidebar-mobile-width18remwidth of the drawer it becomes on a phone

Charts, scrollbars, glyphs and type

Default
--chart-1--chart-5a blue rampchart series, in order. A series can override its own with Color
--scrollbar-width / --scrollbar-sm-width0.625rem / 0.375remthe panel styles its own scrollbars
--scrollbar-thumb / --scrollbar-trackvar(--border) / transparentand colours them
--scrollbar-radius9999pxrounding of the thumb
--check-icon, --chevron-down-icon, --chevron-down-icon-50inline SVG url()the tick in a checkbox and the chevron in a select. Replace with your own url("data:image/svg+xml,…")
--font-sans, --font-monoGeist, then system stackstypefaces

Tailwind’s own scale — --spacing, --text-sm, --radius-lg, the generated colour palette — is also defined on :root and also overridable, but it is Tailwind’s contract rather than this framework’s: redefining --spacing re-proportions every utility class in the panel at once.

A web font needs the font itself as well as the token:

ThemeCSS: `
    @import url("https://fonts.example/inter.css");
    :root { --font-sans: Inter, ui-sans-serif, system-ui, sans-serif; }
`,

Note

ThemeCSS is inlined on every page rather than served as a file, so it is not cached separately and every byte is paid on each load. It is sized for a block of tokens. For a stylesheet of any real length, serve it from your own router and pull it in with one @import.

Anything beyond the tokens — a rule of your own, a component restyled — belongs here too; it lands after the panel’s stylesheet, so equal specificity wins. Reach for a template override only when the markup itself has to change.

Escape closes one layer

A control open inside a drawer — a select, a calendar — takes Escape for itself, and the drawer takes the next one. Menus close on it too. Nothing here needs configuring; it matters only if you add a control of your own to a drawer, in which case give its popover [data-popover] and aria-hidden, or close it yourself on Escape before the drawer sees the key.

Command palette

⌘K / Ctrl+K, or the search button in the header. It lists the sidebar’s pages, filtered as you type, and follows the chosen one through htmx rather than reloading. The entries come from the same menu the sidebar renders, already built against the reader’s permissions — a page they cannot see in the nav is not in the palette either.

Searching records

Resource.Command names what the palette searches:

posts.Command("Title")
users.Command("Name", "Email")

Results are grouped by resource and gated by the same ViewAny that gates the grid — a row the list would hide cannot arrive through search instead.

CommandDisplay names what each row reads. Without it the palette takes the row’s first two non-empty text columns, which cannot reach across a relation and cannot combine two values:

posts.Command("Title").
    CommandDisplay("Title", "Category.Name", "PostDate")

The first path is the line the reader reads; the rest join into the dimmer line beside it (Politics · 2026-07-31). A path may cross one relation and is loaded for you, dates render as YYYY-MM-DD, and an unknown path is a boot error rather than a blank line.

Important

This is opt-in on purpose. The palette queries on every keystroke, and a LIKE '%term%' cannot use an index: a query that matches nothing scans the whole table. On one panel, searching every resource that had a grid search box cost 1.5–4.2 seconds per keypress. Naming one column on one resource brought the same panel to 0.6–0.8s.

Name the fewest columns that make a row findable. A title, not a body.

Searches run concurrently and are cut off at 800ms, so a slow table drops out of that keystroke instead of holding up the rest. Queries shorter than two characters never reach the database, and each section returns at most five rows. A section that ran out of time says so — the palette reads “Search timed out” rather than “No matches”, which are otherwise the same empty box.

The palette asks for rows without a total. A grid’s COUNT scans every match to size its pager; the palette shows five rows and never pages, so on a large table that count was most of the time spent — enough to hit the deadline and return nothing on a table of 100k rows.

CommandSource adds a section that is not a resource:

app.CommandSource("Help", func(c *steward.Context, q string) []steward.CommandResult {
    return []steward.CommandResult{{Title: "Reset a password", URL: "/docs/reset"}}
})

It runs on the same deadline and under the same keystroke, so it wants a bounded lookup rather than a scan.

Custom pages

Resources can mount extra routes next to their CRUD ones, rendered with your own templates:

posts.Page("GET", "stats", func(c *steward.Context) error {
    return c.Render("pages/post_stats.html", "Post stats", data)
})

The template lives in your TemplatesFS (here at pages/post_stats.html) and renders inside the admin layout; the handler can also return c.JSON(...), c.Envelope(...), or c.Redirect(...).

Rebuilding the frontend bundle

You rarely need this — the compiled bundle is committed and embedded. If you fork the styles themselves: make tailwind-bin downloads the Tailwind standalone binary once, then make assets (esbuild via its Go API + that binary) rebuilds assets/dist. No Node at any point.

no_ui builds

go build -tags no_ui compiles the entire UI out — templates, assets and the renderer — for API-only deployments of an app that embeds Steward.

What a panel built that way does:

  • Answers the JSON API exactly as before. The same handlers run against the same data; only the rendering half is gone.
  • Refuses HTML with 503, naming the tag, rather than serving a blank page.
  • Serves Config.AssetsFS if you set one. The embedded assets are out, but files you hand it are still yours to serve.
  • Keeps Verify useful. Icon names pass rather than every one being reported missing — there is no sprite to check against, and a name cannot be wrong where nothing draws it.

The build is a separate compilation of the whole package, so go build alone is what proves it holds. make noui does that, plus vet and the tests written for it, and CI runs it on every push.