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/templatesTemplate 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.BrandIconis 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, sortedUse 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 name | Lucide today |
|---|---|
home | house |
news | newspaper |
filter | funnel |
menu-2 | menu |
columns | columns-3 |
circle-help | circle-question-mark |
info-circle | info |
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.svgUpgrading 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-lucideConfirming 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.Brandnames the panel in the sidebar and titles.Config.ThemeCSSsets the colours, corner rounding and fonts — see below.Config.Prefixmoves the whole panel, which is at the root by default.Config.CurrencySymbolprefixes everyCurrencyfield ($by default); one field overrides it withField.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 / --foreground | white / near-black | the page itself |
--card / --card-foreground | white / near-black | cards, which most pages are made of |
--popover / --popover-foreground | white / near-black | menus, comboboxes, the palette |
--primary / --primary-foreground | near-black / white | primary buttons, the active sidebar item, links |
--secondary / --secondary-foreground | near-white / near-black | secondary buttons |
--accent / --accent-foreground | near-white / near-black | hover states, highlighted menu items |
--muted / --muted-foreground | near-white / grey | hints, empty states, table chrome |
--destructive | red | delete buttons, error alerts. Its text colour is not a token — the components set white |
--border / --input | light grey | every border; every field outline |
--ring | grey | the focus ring |
--radius | 0.625rem | corner 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-foreground | near-white / near-black | the panel behind the navigation |
--sidebar-primary / --sidebar-primary-foreground | near-black / white | the active item |
--sidebar-accent / --sidebar-accent-foreground | near-white / near-black | hover |
--sidebar-border | light grey | its edge and separators |
--sidebar-ring | grey | focus inside it |
--sidebar-width | 16rem | width on a desktop |
--sidebar-mobile-width | 18rem | width of the drawer it becomes on a phone |
Charts, scrollbars, glyphs and type
| Default | ||
|---|---|---|
--chart-1 … --chart-5 | a blue ramp | chart series, in order. A series can override its own with Color |
--scrollbar-width / --scrollbar-sm-width | 0.625rem / 0.375rem | the panel styles its own scrollbars |
--scrollbar-thumb / --scrollbar-track | var(--border) / transparent | and colours them |
--scrollbar-radius | 9999px | rounding of the thumb |
--check-icon, --chevron-down-icon, --chevron-down-icon-50 | inline 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-mono | Geist, then system stacks | typefaces |
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.AssetsFSif you set one. The embedded assets are out, but files you hand it are still yours to serve. - Keeps
Verifyuseful. 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.