Reference
ui
The kit's components, variants, assets and the theme contract.
import "github.com/emersonjoe/trilha/ui" — stdlib only. Components return h.Node with ui-* classes from public/ui.css; behaviors live in public/ui.js.
Assets#
| Symbol | Role |
|---|---|
ui.Head(c) h.Node | <link> for ui.theme.css and ui.css, inline script (with nonce) that applies the saved theme, <script defer src=ui.js>; honors c.Base() |
ui.Body() h.Node | ui-body class for the <body> |
ui.Asset(name) []byte | embedded content of ui.css, ui.theme.css, ui.js, ui.nav.js, ui.upload.js, ui.recorder.js, ui.live.js, ui.chat.js or ui.island.js |
ui.Files | the six names, in the order trilha ui writes them |
Variants and sizes#
ui.Secondary(), ui.Outline(), ui.Ghost(), ui.Destructive(), ui.LinkStyle(), ui.Sm(), ui.Lg(), ui.IconSize(). They are class attributes: valid on Button, Submit, ButtonLink, Badge and Alert (each one translates to its own class, e.g. ui-btn-outline, ui-badge-outline).
Components#
| Function | Renders |
|---|---|
Container, Stack, Row, Grid, Spacer | layout: max width, column, row, responsive grid — see the layout boxes |
Cols(n...) | how many columns a Grid has, 1 to 6: Cols(2, 4) is two on a phone and four from 1024px; without it the grid fits as many 16rem columns as the width takes |
Header(children...), Brand(href, name), Nav(...), NavLink(href, label, current), Sidebar(...) | sticky top bar, brand, navigation (with aria-current), side column |
H1, H2, H3, Lead, Muted, Code(s), Kbd(s) | typography — see a key and a snippet |
Button, Submit, ButtonLink(href, ...) | <button type=button>, <button type=submit>, <a> styled as a button |
Card, CardHeader, CardTitle(s), CardDescription(s), CardContent, CardFooter | card |
Input, Textarea, Select, Checkbox, Radio, Switch, Label | controls (Switch has role=switch) |
Field(id, label, control, opts ...FieldOpt) | label + control + Help(s) + Error(s); With(nodes...) puts attributes on the group. The options are ui.FieldOpt values, so a form that builds its fields in a loop passes them around as data |
FormError(...) | accessible error summary for asynchronous forms; the runtime also creates it when omitted |
CheckRow(control, label, id) | checkbox/switch next to its label |
Invalid() | aria-invalid="true" (red ring) |
Errors(errs, field) | Field option: shows the message from errs[field] (a trilha.FieldErrors) if any — or of its lowest field[N], the name c.Files gives a refused file |
InvalidIf(errs, field) | Invalid() only when there is an error for the field, or for one of its field[N] |
SelectOptions([]Option{{Value, Label}}, selected) | <option>s marking the selected one; Value: "" is a placeholder (disabled) and is selected when nothing matches |
Checked(bool) | conditional checked (round trip of checkbox/switch/radio) |
ShowWhen(field, values...) | data-ui-show-when: shows the element only with the value (or any non-empty value); hidden controls are disabled |
Combobox(ComboboxOpts{...}, attrs...), ComboboxOptions(items, of) | a text field that searches a list — see Combobox and demo |
Dropzone(DropzoneOpts{...}, children...) | drag-and-drop area over a file input — see Upload with progress |
SchemaForm(schema, values, errs, ...) | a form defined by data: one field per trilha.SchemaField — see Validation |
Badge, Alert(title, ...), AlertDescription(...) | badge and alert (role=alert) |
Toaster(...), Toast(kind, text, fadeMs) | toast stack; kind = "", success, error; fadeMs > 0 disappears on its own |
Flashes(c) | the toaster with the messages of c.Flash — put it in the layout; FlashInfo, FlashSuccess and FlashError are the kinds, and FlashFadeMs is how long one stays before it fades — see demo |
Table(...), Cards(), Num(), Depth(n) | scrollable table; row-as-card below 640px — see Listings; numeric cell; row indentation (tree) |
Tabs(id, Tab{Label, Content}...), TabsWithOptions(id, TabsOpts{Selected}, ...) | accessible tabs; Tab.Href gives each view a real address and Selected chooses the server-rendered panel — see demo |
Dialog(id, title, ...), DialogDescription(s), DialogFooter(...), DialogTrigger(id, ...), DialogClose(...) | native <dialog> with showModal; DialogTrigger given h.Href is a link, and with Swap on the dialog's body it loads the body on demand |
Confirm(title, description) | attributes for a <form>: ui.js asks in a dialog before submitting, fragment forms included. The confirming button repeats the pressed button's label; the other says Cancel, or what h.Data("ui-confirm-cancel", "…") says. Without JavaScript the form submits straight away — see demo |
Menu(id, ...), MenuItem(...), MenuLink(href, ...), MenuTrigger(id, ...) | menu with the native popover attribute — see demo |
Pagination(Pages{Page, Total, Href, Prev, Next, Label, Attrs}) | page navigation as links; the current page is a <span> with aria-current, the edges are absent instead of disabled, and a window of seven slots keeps the first and last page with … over each gap; one page renders nothing |
Tooltip(text, ...) | hint on what it wraps: title plus data-ui-tooltip, upgraded by ui.js into a bubble with role=tooltip and aria-describedby |
Separator | rule between sections — see the layout boxes |
Skeleton, Progress(value, max) | loading placeholders — see demo |
Breadcrumb(Crumb{Label, Href}...), Avatar(initials, src) | navigation trail and the picture (or initials) of who is signed in — see demo |
Collapsible(summary, ...) | a styled <details> — see demo |
ThemeToggle() | button that switches light/dark (localStorage["ui-theme"]) |
CSVErrors(c, res, CSVErrorsOpts{...}) | what trilha.BindCSV rejected, by line and column — see CSV and demo |
DataTable(c, Columns[T], rows, ListState) | the listing: filter form, sortable headers, pagination and empty state, all in the URL — see Listings and demo |
Swap(id) | data-trilha-target: the <a> or <form> asks for element #id only and swaps it (fragments) |
SecretOnce(c, secret), APIKeysTable(c, rows, opts) | a key shown once, and the list of them — see Auth and demo |
SettingsForm(c, section, errs) | the administration screen of a trilha.Settings section, drawn from the struct — see App and demo |
Tree(TreeOpts{...}), TreePicker(TreePickerOpts{...}), TreeItems, TreeScript(c) | a hierarchy that opens node by node, and the field that picks one — see Trees and demo |
AuditTable(c, records, AuditOpts{...}) | the trail c.Audit writes, with filter, pagination and CSV export — see Observability and demo |
Steps([]Step{Label, Href}, current) | the indicator of a form in several screens — see A form in steps and demo |
Sheet(c, id, SheetOpts{Side, Width, Title, Modal, Open, Push}, children...), SheetBody(id, children...), SheetOpen(id), SheetClose(children...), SheetScript(c) | a side panel whose body (#<id>-body) loads from the link's address as a fragment and stays open while a Navigate region beside it navigates; <aside role=complementary>, or <dialog> when modal — see A panel beside the page |
Preview(c, src, PreviewOpts{...}) | a file shown beside its metadata: bar, frame, image or "cannot be previewed" — see Ctx, Uploads and demo |
Audio(c, src, AudioOpts{Preload AudioPreload, ...}) | native accessible audio player with duration, open/download actions and an unsupported-format fallback |
InstallApp(c, InstallAppOpts{...}) | progressive PWA install invitation; use it with trilha add pwa |
Outbox(c), OfflineScript(c) | what is waiting to be sent — the count, the last error and the "Send now" button — and the script that queues a form marked with trilha.OfflineForm when there is no network; use them with trilha add pwa-offline |
Defer(c, id, src, DeferOpts{...}) | serves the page now and fills this part a moment later — see Live fragments and demo |
Poll(every, src), Live(src), On(event, src), LiveScript(c) | a fragment that refreshes on a clock or on an event from the server — see Live fragments and demo |
NoPush() | data-trilha-push="false": the swap leaves history alone |
PushHistory() | data-trilha-push="push": a GET form with Swap adds an entry per search instead of replacing its own |
Follow(), NoFollow() | a trigger follows its route's redirect in place (the default on a page with a Navigate region), or does not — see After the POST |
Markdown(text, MarkdownOpts{...}) | model or visitor text as HTML, escaped by construction — see Markdown |
Chat(c, ChatOpts{...}), ChatScript(c), ChatHTML(text) | a conversation with an agent — see Chat |
Icon(name, attrs...), Icons() | inline Lucide SVG; unknown name → panic (programming error). NavItem.IconNode/EmptyOpts.IconNode draw the app's own node for an icon outside the set — see Shell |
Patterns() []Pattern | the kit's screen patterns as data — name, summary, components, a complete page.go of at most 60 lines that compiles, the data contract and the accessibility notes; see Patterns |
APIUsage(c, data, opts) | how much a key was used, where, and when it stopped — see Auth and demo |
SearchBox(c, action, SearchBoxOpts{...}), SearchResults(c, res, SearchResultsOpts{...}) | the box in the top bar (Submit adds a button with that label) and the grouped result of a trilha.Search — see Search and demo |
DeadlineCards(c, summary), DeadlineList(c, items, opts), DeadlineBadge(c, overdue) | what expires and when, from a trilha.Deadlines summary — see DeadlineCards |
ConnectionsPanel(c, conns, opts), ConnectionStatus(c, test), ParseConnectionForm(c) | the external services and their secrets, with the Test button — see ConnectionsPanel |
Shell(c, ShellOpts{...}, children...), PageHeader(title, actions...) | the frame of an application with sections: side navigation, top bar, and the title of the screen with its buttons — Back{} above it, Subtitle("…") below — see Shell and demo |
Stat(label, value, ...), StatHint(text, ...), Sparkline(values, SparkOpts{...}), SparklineTitle(values, SparkOpts{...}, ...), Bars([]Datum, ...), Donut([]Datum, ...), ChartTitle(name) | a number on a panel and the drawing next to it, in SVG written by the server; ChartTitle is what makes the drawing an image with a name instead of decoration — see Charts and demo |
Inbox(c, []InboxRow, InboxOpts{...}), InboxBadge(n) | what is waiting for whoever is reading, and the count beside the menu item (zero draws nothing) — see Approval and demo |
PolicyGrid(policy, PolicyGridOpts{...}) | the role × module grid of an auth.Policy, as a form; a ScopedPolicy (which auth.Policy is, through ScopeNameOf and ScopeNames) also gets a scope select per cell — organisation, unit, unit and below — renamed with PolicyGridOpts.ScopeLabels — see Auth and demo |
TaskTable(c, tasks, TaskTableOpts{...}), TaskProgress(c, tasks, id) | the background work: the list with its states and the progress of one run — see Tasks and demo |
WebhooksPanel(c, hooks, deliveries, WebhooksOpts{...}) | the subscriptions, the secret and the delivery log of a webhook.Hooks — see Webhook and demo |
Empty(EmptyOpts{...}), EmptyError(c, title, err, action) | the screen with nothing to show, and the one that could not load: the message is what the person reads, and err appears only in development — see demo |
Status(enum, value) | one value of a trilha.Enum as a badge with its label and its tone; a value the enum no longer knows renders muted instead of taking the screen down — see demo |
Trees#
A hierarchy with thousands of nodes is the component people go to npm for: expanding, searching and the keyboard are each easy and together are three hundred lines. ui.Tree is the server's version of it — the server already knows the tree, so the browser never has to.
ui.Tree(ui.TreeOpts{
Nodes: roots, // with the path down to Current already inside
Source: "/classification/nodes", // GET ?parent=100.1 answers the children
Current: doc.Code,
Label: "Classification plan",
})| Symbol | Role |
|---|---|
Tree(TreeOpts{...}) | the hierarchy; each node is a <details>, so it opens with no script at all |
TreeNode{Value, Label, Leaf, Href, Children, Open, Path, Unpickable} | one node; Children travel with it when known, and Unpickable keeps a branch browsable without rendering a radio |
TreeItems(nodes, TreeOpts{...}) / TreeNodes(items, of) | what a source route answers: the children of one node, as HTML |
TreePicker(TreePickerOpts{...}) | the same tree as a form field: a radio per node |
TreeScript(c) | loads ui.tree.js; a page with no tree does not download it |
A node is <details>, and that is the whole no-JavaScript story. What the script adds is fetching the children the first time a branch opens, instead of asking the server for a whole page. A node whose Children are already in Nodes never asks for anything — which is how the path down to the current node arrives open and complete on the first render, including after a 422 brought the form back.
TreeOpts.Pending and TreeOpts.Fail replace the default lazy-loading messages (Loading… and Could not load.); TreePickerOpts forwards the same fields. The pending sentence is server-rendered, so a lazy branch never opens into a silent empty box without JavaScript.
The roles are the real ones (tree, treeitem, group, aria-expanded), the arrows move through what is visible, Home and End jump to the ends, and * expands everything. Only the first node is in the tab order: the tree is one stop, and the arrows move inside it.
The picker#
ui.Field("code", "Classification", ui.TreePicker(ui.TreePickerOpts{
Name: "code",
Value: form.Code,
Nodes: plan.Roots(form.Code),
Source: "/classification/nodes",
Search: "/classification/search", // GET ?q= answers flattened nodes, each with its Path
}))What posts is a radio, which is the whole reason this works with no script: somebody browses the same <details> and picks the same radio, and the form posts the same field. There is no hidden input to keep in sync and nothing to resolve on the server.
With the script, typing asks Search and puts the matches where the tree was, each with the ancestry it came from — a code found out of context does not say where it lives. Clearing the box brings the tree back from memory, without asking again.
A tree of radios announces itself as a group of choices, not as a navigation: it is a form field, and that is one role and not two.
ui.js#
Everything by attribute, no initialization: [data-ui-tabs], [data-ui-dialog-open=id], [data-ui-dialog-close], [data-ui-fade=ms], [data-ui-show-when], [data-ui-toast=text] (data-ui-toast-kind), [data-ui-theme-toggle], [data-ui-tooltip=text], [popover].ui-menu. It also exposes window.ui.toast(text, {kind, ms}), ui.formError(form, message, options), ui.clearFormErrors(form), ui.formPending(form), ui.fade(el), ui.evalShowWhen(root) and ui.applyTheme("dark"|"light"). Elements inserted later (HTMX, fetch) need ui.evalShowWhen(el)/ui.fade(el)/ui.initTooltips(el) if they use those attributes — ui.hydrate(el) does the three at once.
Async forms#
Keep event.currentTarget before the first await: after the listener yields back to the browser, event.currentTarget becomes null. ui.formPending marks the form with aria-busy, disables submit controls, and returns the function that restores their original state. ui.formError keeps the failure inside the form — including a form inside a dialog — and can mark and focus the field responsible for it.
form.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
ui.clearFormErrors(form);
const settled = ui.formPending(form);
try {
await save(new FormData(form));
} catch (error) {
ui.formError(form, error.message, {
field: form.elements.email,
action: { href: "/help", label: "How to fix this" },
});
} finally {
settled();
}
});The message is assigned through textContent, never innerHTML. The action link only appears when the caller explicitly provides href and label. Put ui.FormError() before the fields to reserve the feedback location; when omitted, the runtime creates the element.
Fragments#
[data-trilha-target=id] on an <a> or <form> (see ui.Swap) makes the kit request the same URL with the Trilha-Fragment header and swap element #id for the HTML that comes back. Details: the target gets aria-busy while it waits; 204 with Trilha-Location becomes a real navigation; 422 focuses the first [aria-invalid=true] (a form refused with a whole page, without a swap, gets the same on load), otherwise focus (and the caret) return to the field in use; what came in is hydrated (fade, show-when) and fires trilha:swap (detail.target, detail.status). On 5xx, a network error or a fragment without the id, the kit gives up and navigates/submits normally. ui.swap(id, html, status) and ui.hydrate(el) do the swap by hand (ui.swap returns a promise: the replacement may be running inside a view transition). See Interactivity.
Waiting#
| Symbol | What it does |
|---|---|
ui.Indicator(id) | this element appears only while target id is waiting past the threshold |
ui.PendingAfter(ms) | the threshold on the trigger; default 120 ms, zero or less means the default |
ui.NoTransition() | no crossfade on this trigger |
ui.Spinner(attrs…) | a turning ring sized by the font it sits in, hidden from assistive technology |
See it live: demo.
While a target waits, data-trilha-pending is on the target, the trigger and every indicator of that target, and aria-busy is on the target; trilha:pending and trilha:settled fire on document with detail.target and detail.id. A second trigger for a target already in flight is ignored. The replacement runs inside document.startViewTransition where it exists and where the system does not ask for less motion.
Navigation#
Client navigation is off until you ask for it, in two places:
| Symbol | Role |
|---|---|
ui.Navigate(id) h.Node | marks a region: a click on a same-origin link inside it replaces element #id with the same element from the next page. An empty id means the marked element itself |
ui.NoNavigate() h.Node | keeps one link out of it (a download, another app, a route that must reload) |
ui.Prefetch(), ui.NoPrefetch(), ui.PrefetchTTL(ms) | prefetch on intent (pointer resting 80 ms, keyboard focus, touch) inside a Navigate region, a link kept out, and how long an answer waits for its click (default 10 s) — see Asking before the click |
ui.NavigateFocus(mode) h.Node | where the focus goes after a client navigation: "h1" (default, the region's first heading), "region" or "none"; the new title is announced either way |
ui.NavigateScript(c) h.Node | <script defer src=ui.nav.js>; put it once, in the layout of the area that uses it |
What the browser keeps doing: the address in the bar is the one a normal navigation would use, Back and Forward work (and restore the scroll position of the entry they return to), Cmd/Ctrl-click and middle click open a tab, target, download and links to another origin are untouched. What the kit adds: aria-busy on the region while it waits, focus moved to what came in, ui.hydrate and the trilha:swap event, and one request at a time — a second click aborts the first. On 5xx, a network error, a redirect or a page that does not contain the id, it gives up and navigates for real.
The behavior is a separate file so an app that does not use it does not download it, and ui.Head does not load it. A link marked with ui.Swap stays with fragments: it asks for a piece of the page, not for the next page.
Upload with progress#
A form that sends a file is a form: method="post", enctype="multipart/form-data", the CSRF field. Three symbols add the progress bar on top of it, and it is off until you ask:
| Symbol | Role |
|---|---|
ui.UploadTo(id) h.Node | on the <form>: send it with XHR and swap #id with what comes back |
ui.UploadBar(attrs…) h.Node | the <progress> the kit fills in; hidden until the send starts |
ui.UploadScript(c) h.Node | <script defer src=ui.upload.js>, once per page that uploads |
The request carries Trilha-Fragment: id, so the handler answers the piece with the same c.Fragment() it already uses. While it uploads, the bar gets value/max from the browser's own progress event (and loses value — an indeterminate bar — when the total is not known), and a trilha:upload event bubbles with detail: {loaded, total, form}. On a 5xx, a network error or a piece without the id, the form submits for real: the user sees the page reload, not a button that did nothing.
The attribute is data-trilha-upload, not data-trilha-target, so the fragment handler in ui.js does not submit the same form a second time. The body limit is the server's business — see AllowBody.
ui.Dropzone(ui.DropzoneOpts{Name, Accept, MaxSize, Single, Attrs}) puts a drop area over the file input: a <label> that takes the drop and a <ul class="ui-queue"> with one line per file. With ui.UploadTo on the form the queue sends one file per request, so each line gets its own progress and its own answer — a message that names files[2] has nowhere to go when three files travel in one body. The swapped element must be outside the dropzone, or the queue is destroyed halfway through.
Accept and MaxSize in the options only spare the user a round trip: the browser can be told anything. What decides is c.Files with its FileRules — the same rules, applied to bytes that already arrived. Without JavaScript the input is a plain multiple field and the form posts every file at once, into the same handler.
Recorder#
ui.Recorder(c, ui.RecorderOpts{Action, Name, MaxSeconds, Mime, Label, Target, Attrs}) is the microphone half of Audio: a form that records at the counter and posts the recording to a route, with the progress bar of the upload above.
ui.Recorder(c, ui.RecorderOpts{Action: "/api/voice", Target: "answer", MaxSeconds: 60})
ui.RecorderScript(c)
ui.UploadScript(c)ui.Recorder(c, ui.RecorderOpts{
Action: "/api/voice", Target: "answer", MaxSeconds: 60,
})
ui.RecorderScript(c)
ui.UploadScript(c)Use this pattern — upload-progress
Files sent with a progress bar and checked by content on the server, working without JavaScript too.
Components
ui.UploadTo ui.UploadBar ui.UploadScript ui.Field ui.Input ui.InvalidIf ui.Errors ui.Submit ui.PageHeader ui.Stack
Data it needs
trilha.FileRules (size, count, accepted types, checked against the bytes); Save(c, up) keeps one *trilha.Upload in your blob store.
Accessibility
- The progress bar has an aria-label and is a real <progress>, read as a percentage.
- The file input has a visible label that says the limits before anybody tries.
- A refused file comes back as a 422 with the message on the field and the focus on it.
The whole page.go (compiles, at most 60 lines)
// Package uploadprogress is the upload-progress pattern: files sent with a
// progress bar, checked by content on the server, and the same form working
// with JavaScript off.
package uploadprogress
import (
"errors"
"net/http"
"github.com/emersonjoe/trilha"
"github.com/emersonjoe/trilha/h"
"github.com/emersonjoe/trilha/ui"
)
// rules are checked against the bytes, not against what the browser claims.
var rules = trilha.FileRules{MaxSize: 10 << 20, MaxFiles: 5, Accept: []string{"image/*", "application/pdf"}}
// Save keeps one file — your blob store. The upload is closed after it.
var Save = func(c *trilha.Ctx, up *trilha.Upload) error { return nil }
// Page draws the form.
func Page(c *trilha.Ctx) (h.Node, error) { return screen(c, nil), nil }
// POST receives the files. A refusal answers the fragment the kit swaps in.
func POST(c *trilha.Ctx) error {
ups, err := c.Files("files", rules)
if err != nil {
var fe trilha.FieldErrors
if !errors.As(err, &fe) {
return err
}
if c.Fragment() == "upload" {
return c.Render(http.StatusUnprocessableEntity, panel(c, fe))
}
return c.Render(http.StatusUnprocessableEntity, screen(c, fe))
}
for _, up := range ups {
err := Save(c, up)
up.Close()
if err != nil {
return err
}
}
c.Flash(ui.FlashSuccess, "Sent.")
return c.Redirect(c.Request().URL.Path)
}
func screen(c *trilha.Ctx, errs trilha.FieldErrors) h.Node {
return ui.Stack(ui.PageHeader("Files"), panel(c, errs), ui.UploadScript(c))
}
func panel(c *trilha.Ctx, errs trilha.FieldErrors) h.Node {
return h.Form(h.ID("upload"), h.Method("post"), h.Enctype("multipart/form-data"), ui.UploadTo("upload"),
h.Class("ui-stack"), trilha.CSRFInput(c),
ui.Field("files", "Files (images or PDF, 10 MB each)", ui.Input(h.ID("files"), h.Name("files"),
h.Type("file"), h.Attr("multiple", ""), ui.InvalidIf(errs, "files")), ui.Errors(errs, "files")),
ui.UploadBar(h.Aria("label", "Upload progress")), ui.Submit(h.Text("Send")))
}trilha ui patterns upload-progress · get_pattern {"name":"upload-progress"}
Field of RecorderOpts | Role |
|---|---|
Action | the route that receives the recording, as any form's action |
Name | the field the route reads with c.File; default "audio" |
MaxSeconds | stops the recording on its own; zero leaves it to the person |
Mime | what MediaRecorder is asked for when the browser supports it; default "audio/webm" |
Label | the text of the record button; the default comes from the locale |
Target | the id the answer swaps, as in ui.UploadTo; empty leaves a plain form |
ui.RecorderScript(c) loads ui.recorder.js, which shows the record button when navigator.mediaDevices and MediaRecorder are there, counts the seconds, puts the recording into the file field and submits the form — from there on it is ui.UploadScript's upload, with the progress and the swap. What is left when the script is off, the browser has no MediaRecorder or the person denies the microphone is the <input type="file" accept="audio/*" capture> the markup always carries: on a phone it opens the recorder of the system.
The browser only hands over the microphone over HTTPS and under a permissions policy that allows it, and the framework's default denies it. An app that records sets it once:
a.Config().Security.PermissionsPolicy = "camera=(), microphone=(self), geolocation=(), payment=(), usb=()"The CSP does not change: the recorder is a file of the kit, loaded like any other script. The route reads one file with c.File and its FileRules, and what it does with it — ai.Transcribe, an answer, ai.Speak — is the voice section of the AI chapter.
Combobox#
A text field that searches a list is two inputs: the one the person types in, and the one the form sends. ui.Combobox renders both — a visible <input role=combobox name="<name>_q"> and an <input type=hidden name="<name>"> with the chosen value — plus the <ul role=listbox> of options.
Field of ComboboxOpts | Role |
|---|---|
Name | name of the hidden field; the visible one is Name + "_q" |
Value / Label | what was chosen and what is written for it (the round trip) |
Options []Option | a short list: filtered in the browser, no request |
Source string | the URL that searches; the answer is ui.ComboboxOptions(...) |
With []string | other fields of the same form to carry in the query (?uf=SP&q=camp) |
MinChars, Debounce | when to search (default 1 character, 200 ms) |
Placeholder, Required, Attrs | as in Input |
ComboboxOptions(items []T, of func(T) (value, label string)) is the answer of the search route: only the <li>s, no envelope. The request carries Trilha-Fragment, so the route answers with c.HTML and the page's layout stays out of it.
func GET(c *trilha.Ctx) error {
return c.HTML(200, ui.ComboboxOptions(
Search(c.Query("uf"), c.Query("q")),
func(city string) (string, string) { return city, city },
))
}Without JavaScript nothing breaks: the visible field is a normal text input, so the form still arrives with cidade_q filled in and cidade empty. Resolving the typed text against the list is the server's job — the same list the search route reads.
The behavior is ui.combo.js, written to public/ with the rest of the kit. ui.js loads it the first time a page — or a fragment swapped into it — has a combobox, from the versioned URL ui.Head puts on its own tag, so a page without one does not download it.
Markdown#
func Markdown(src string, opt MarkdownOpts) h.NodeA model writes Markdown, and so does whoever types in a form. Markdown turns it into nodes: paragraphs, emphasis, headings, lists, quotes, inline and fenced code, GFM tables, links and line breaks.
h.Div(ui.Markdown(doc.Summary, ui.MarkdownOpts{}))There is no raw HTML, and no way to turn it on. A <script> in the text is a <script> on the screen, as text — the return is a tree, not a string, so the escaping is not a rule anybody has to remember. That is the whole reason this exists instead of an h.Raw around a converter.
Field of MarkdownOpts | What it does |
|---|---|
HeadingBase | the level # becomes (default 3, so it does not compete with the page's <h1>); ###### never goes past <h6> |
Images |  renders an <img>; off by default, and the URL is validated either way |
Class | an extra class on the wrapper, which is always ui-md |
Links only survive as links when the address is http, https, mailto, or relative (/, #, ./); anything else — javascript:, data: — stays as the text it was. An external link gets rel="noopener nofollow ugc".
The site's own pages use a different renderer (site/internal/md): another dialect, over text from this repository. ui.Markdown is for text nobody in this repository wrote.
Chat#
func Chat(c *trilha.Ctx, o ChatOpts) h.Node
func ChatScript(c *trilha.Ctx) h.Node
func ChatHTML(text string) stringThe conversation with an agent: the bubbles, the field and the button. The route on the other side is ai.Serve.
ui.Chat(c, ui.ChatOpts{Action: "/api/chat", History: msgs, Greeting: "Ask me anything."})
ui.ChatScript(c) // once, in the layoutField of ChatOpts | What it does |
|---|---|
Action | the route that answers — the only one that has to be filled in |
History | what was said before, oldest first. It is the app's: the framework keeps no session |
Greeting | Markdown shown while the history is empty |
ID | the element id and the prefix of the ids inside (default chat) |
Placeholder, Submit, Label | the words on the screen |
MaxLength | caps the field (default 4000; negative lifts it) |
Steps | shows the tool the agent called and what came back |
Markdown | the MarkdownOpts for the answers |
ChatMessage{Role, Text, Sources} is one turn. Role: "assistant" is rendered as Markdown; Role: "user" is rendered as text — what somebody typed is never markup.
ChatSource{Title, URL, Snippet} attaches the pages or documents supporting an assistant answer. Title and snippet are escaped, the URL goes through the Markdown safe-link rule, and streaming accepts sources on a chunk or on the final done event. Sources are therefore part of the answer whether it arrived all at once or token by token.
With ChatScript the answer arrives word by word and the Markdown is rendered when the message ends: pass ui.ChatHTML to ai.ServeOpts.HTML and the finished bubble looks exactly like a reloaded page. Without the script the form still submits and the route answers the whole thing at once, so nothing on the screen depends on the script running.
Field of ChatOpts | What it does |
|---|---|
Context | what the page knows and the model does not: {"invoice": id} becomes hidden ctx.* fields, sent with the message and read by ai.ServeOpts.Context |
Assistant#
func Assistant(c *trilha.Ctx, o AssistantOpts) h.NodeThe other shape of a chat: a button fixed in a corner that opens a panel over the screen instead of replacing it. Mount it once, in the layout of the area that has it.
ui.Assistant(c, ui.AssistantOpts{
Action: "/api/assistant",
Page: "/panel/assistant", // the same conversation, as a page
Hint: "Asking about invoice " + id, // what it knows right now
Chat: ui.ChatOpts{Context: map[string]string{"invoice": id}},
})
ui.ChatScript(c)Field of AssistantOpts | What it does |
|---|---|
Action | the route that answers, the ai.Serve one |
Page | the conversation as a page: where the launcher points when the script is not there |
Label, Title, Hint | the word on the launcher, the heading of the panel, and the line under it |
Icon | a kit icon on the launcher; empty is the label alone |
ID | the prefix of every id inside (default assistant) — two assistants need two |
Chat | the conversation itself: history, greeting, context |
The launcher is a link before it is a button. Without JavaScript it goes to Page, which is the same conversation as a whole page; with JavaScript the kit's script opens the <dialog> instead, and the browser gives the focus trap and the Escape key. aria-expanded follows the panel, aria-controls names it, and it is server HTML — a page with an assistant is not an island.
It is composition, not a second chat: the panel holds a Chat, so the streaming, the Markdown and the errors are the ones that already exist.
See it running in the assistant demo: the real component over a small screen, answered in the browser in ai.Serve's contract — the launcher, the panel, the streaming and the context are the ones an application gets.
DeadlineCards#
func DeadlineCards(c *trilha.Ctx, s trilha.DeadlineSummary) h.Node
func DeadlineList(c *trilha.Ctx, items []trilha.Deadline, o DeadlineListOpts) h.Node
func DeadlineBadge(c *trilha.Ctx, items []trilha.Deadline) h.NodeThe panel of a trilha.Deadlines summary: the cards on top, the list under them, the number beside a menu item.
resumo := trilha.Deadlines(itens, trilha.DeadlineOpts{Now: time.Now().In(c.Location())})
ui.DeadlineCards(c, resumo)
ui.DeadlineList(c, itens, ui.DeadlineListOpts{Limit: 10, More: "/prazos"})
ui.DeadlineBadge(c, resumo.Overdue)The cards follow the order of the horizons, so they do not move between reloads, and the overdue one only turns red when there is something to be red about — a zero in red teaches people to look past the colour, and then the three is looked past too. The last card is Next: the count with no example beside it is a number somebody has to click to understand.
DeadlineListOpts takes Limit (with More for the "and 12 more" link), Owner for the column of who it is on, RowAction func(trilha.Deadline) h.Node for one application-owned action at the end of each row, Empty for the sentence with nothing to say, and Now — the clock late is measured against, so a test does not fail on its own the next morning. Dates are written by ui.Date with Relative, and a row whose day has ended carries ui-late, the same class the inbox uses: late looks the same everywhere in an application, or it looks like a bug.
DeadlineBadge draws nothing for an empty list, and says in aria-label what the colour says. No recipe writes this screen — trilha.Deadlines is a runtime primitive, used straight from an app's own page.
summary := trilha.Deadlines(items, trilha.DeadlineOpts{Now: time.Now().In(c.Location())})
ui.DeadlineCards(c, summary)
ui.DeadlineList(c, items, ui.DeadlineListOpts{Limit: 10, More: "/deadlines"})
ui.DeadlineBadge(c, summary.Overdue)| What | Kind | Due |
|---|---|---|
| SSL certificate — api.example.com | certificate | |
| Vendor contract — Acme | contract | |
| Vendor contract — Acme | contract |
VersionList#
func VersionList(c *trilha.Ctx, rows []VersionRow, o VersionOpts) h.Node
func VersionBadge(n int, published bool, words ...map[string]string) h.Node
func Changed(before, after map[string]string) []stringThe history of a trilha.Versioned[T]: who, when, what changed, and the buttons to publish or go back. Newest first, because a history is read from what a thing is now backwards.
There is no JavaScript and no diff library: what changed is a list of field names, and ui.Changed works it out from two maps of strings — the honest half of a diff, and the half somebody reads before opening a version. The published row carries no buttons, because it is already the one everybody reads. No recipe writes this screen either — trilha.Versioned[T] is a runtime primitive.
n, _ := Models.Save(c, id, m, "adjusted")
_ = Models.Publish(c, id, n)
ui.VersionList(c, rows, ui.VersionOpts{Restore: "/admin/models/1/restore", CSRF: trilha.CSRFInput(c)})
// row.Changed = ui.Changed(before, after)| Version | By | When | Changed | |
|---|---|---|---|---|
| v3 · published | Ana Souza | Title | ||
| v2 | Ana Souza | first draft |
ConnectionsPanel#
func ConnectionsPanel(c *trilha.Ctx, x *trilha.Connections, o ConnectionsOpts) h.Node
func ConnectionStatus(c *trilha.Ctx, t *trilha.ConnectionTest) h.Node
func ParseConnectionForm(c *trilha.Ctx) trilha.ConnectionThe screen of a trilha.Connections: the list grouped by kind — name, URL, auth, the badge of the last test — and the form of one connection, new or being edited (Editing, with Errors from a refused save). Every button is a form that posts to Path with a hidden _action (save, test, delete) and the id, plus the CSRF node you pass; nothing needs script. The user and header-name fields show with ShowWhen for the auth that uses them.
The secret is the one thing the screen never contains: the field is SecretField, which renders empty and says "leave blank to keep", and ParseConnectionForm reads it back as a Secret so an empty one keeps the previous value on Save. When the store keeps the credential in another system, set Connection.HasSecret and render SecretFieldWithPresence: the empty update still means keep, without reading the credential back into this process.
trilha add connections writes the package, this screen and the test.
ui.ConnectionsPanel(c, connections.Connections, ui.ConnectionsOpts{
Path: "/admin/connections", CSRF: trilha.CSRFInput(c), Editing: editing, Errors: errs,
})Audio#
ui.Audio(c, "/recordings/42", ui.AudioOpts{
Title: "Interview",
Type: "audio/mpeg",
Duration: 3*time.Minute + 12*time.Second,
Download: "/recordings/42?download=1",
Preload: ui.PreloadMetadata,
})Audio uses the browser's native controls and the same title bar and escape routes as Preview. PreloadNone is the default for lists; PreloadMetadata fetches duration metadata; PreloadAuto leaves the choice to the browser. A MIME type that is not audio renders an honest "cannot play" state and keeps the download action instead of leaving a broken player.
InstallApp#
InstallApp is the progressive invitation used by the pwa recipe. It keeps a useful browser-menu instruction without JavaScript, exposes the native Chromium prompt when available, explains the Safari steps on iOS and disappears when c.Standalone() says the app is already installed. InstallAppOpts lets the application replace the script, manifest, help address and every visible sentence.
Formatting#
A date, a size, a duration and a count are not domain: they are the same in every application, and every application writes them again — usually four times, slightly differently each time, and usually ignoring the time zone.
// app/setup.go
func Config(cfg *trilha.Config) {
cfg.Locale = "pt-BR" // "en" is the zero value
cfg.TimeZone = "America/Sao_Paulo" // empty means UTC
}ui.Date(c, doc.CreatedAt) // <time datetime="…">Sep 8, 2026 3:04 PM</time>
ui.Date(c, doc.CreatedAt, ui.Relative()) // 3min ago, absolute in the title
ui.Date(c, doc.CreatedAt, ui.DateOnly()) // Sep 8, 2026
ui.Date(c, doc.CreatedAt, ui.TimeOnly()) // 3:04 PM (the day is already in the heading)
ui.Bytes(c, doc.Size) // 1.4 MB (pt-BR: 1,4 MB)
ui.Duration(c, job.Elapsed) // 2 min 13 s
ui.Number(c, total) // 12,345 (pt-BR: 12.345)
ui.Number(c, price, ui.Decimals(2)) // 1,234.56The options are ui.FormatOpt values — Relative, DateOnly, TimeOnly, Decimals — which is what lets a column of a ui.DataTable carry the format it wants next to the field it reads.
See it live: demo.
The rules worth knowing#
A missing value is a dash. The zero time.Time, a nil *time.Time, a zero size: all render — in muted text. A zero date printing as 01/01/0001 is the bug this removes.
datetime is always the instant. The text is local and translated; the machine-readable attribute is RFC 3339 in UTC, so a copy-paste, a sort or a screen reader gets the fact and not the presentation.
Relative does not move. It writes "3min ago" and keeps the absolute time in the title. Nothing updates it: the kit has no clock and does not want one. A screen that needs the number to keep moving puts the piece in a ui.Poll — a decision the page makes and pays for, once.
Bytes is base 10. kB, MB, GB: what the file manager of whoever is reading already shows them. The exact byte count stays in the title.
Decimal halves round away from zero. Number, Bytes and Duration use the same half-expand rule as JavaScript and ICU for the short decimal they display; values such as 1.25 with one decimal become 1.3, not the banker's-rounding 1.2 from strconv.
An unknown time zone falls back to UTC and says so in the log. Falling back in silence would shift every timestamp on the screen and nothing would look broken.
They take a Ctx, and that is on purpose#
ui.Date(c, t) and not ui.Date(t). A package-level language would be shared by two applications running in one process — which is exactly what trilha.Provide and the embedded app exist to support — and the second one to boot would silently change the first. ui.Head, ui.Flashes and ui.DataTable take a Ctx for the same kind of reason.
Money is not here#
The currency, where the symbol goes, how a negative reads: those are the application's to decide, and a framework that guessed would be wrong in somebody's country. ui.Number(c, v, ui.Decimals(2)) with the symbol written beside it is the whole recipe.
trilha audit warns about a time.Format("02/01/2006") inside app/: a layout in the page ignores Config.TimeZone, which is how a date shown to somebody in another country ends up simply wrong.
Theme#
ui.theme.css defines, in :root and .dark, the shadcn/ui v4 variables: --background/--foreground, --card/--card-foreground, --popover/…, --primary/…, --secondary/…, --muted/…, --accent/…, --destructive, --border, --input, --ring, --chart-1…5, --sidebar…, --radius. Trilha extends that contract with semantic intent tokens (--success-*, --warning-*, --info-*, --destructive-*) and shadows (--ui-shadow-sm/md/lg, plus the --shadow alias). Use those tokens for operational states instead of primitive colors. ui.css derives --radius-sm/md/lg/xl. Dark mode is the dark class on <html> (the ui.Head script applies the saved or system preference before the first paint).
Hooks#
Every class the kit writes has a rule in ui.css, with a short list of exceptions: classes written so the app's own stylesheet has something to hang on, with no rule of their own because the kit has nothing to say there. A test in ui/ keeps the list this short — a class outside it with no rule is a bug, like the ui-inline-form the connections panel misspelled for two releases.
| Hook | On |
|---|---|
ui-assistant, ui-assistant-label | the Assistant widget and its label |
ui-audio | the Audio player |
ui-btn-primary | the default Button, beside the variants that do have a rule |
ui-connections, -actions, -buttons, -form, -group, -kind, -list | the parts of ConnectionsPanel |
ui-deadline-card | each card of DeadlineCards |
ui-install-app | the InstallApp button |
ui-list-form | the form around a DataTable with selection |
ui-md-img, ui-md-list | images and lists inside Markdown |
ui-quote | a blockquote inside Markdown |
ui-shell-user | the user corner of the Shell |
ui-task | the box of TaskProgress |
ui-tree-branch, ui-tree-radio | the nodes of a Tree |
ui-webhooks, -events, -form, -title | the parts of WebhooksPanel |
CLI#
trilha ui [--force] [--css-only|--js-only] writes the files in public/: ui.theme.css is only created (never overwritten); ui.css, ui.js, ui.nav.js, ui.upload.js, ui.recorder.js, ui.live.js, ui.chat.js and ui.island.js are updated when they equal a previous version and, if you edited them, only with --force.
Patterns#
A component answers "how do I draw a table"; a pattern answers "how do I build a listing here". ui.Patterns() returns six, each a complete page.go from examples/patterns/ — the repository compiles every one of them, and none is longer than 60 lines:
| Pattern | When |
|---|---|
list-with-filter | a listing somebody searches, filters, orders and pages, all in the address |
async-form | a form that posts without leaving the page, the 422 beside the wrong field |
approval-inbox | what waits for a person's decision, with the reason in the same form |
dashboard-chart | the numbers on top and server-rendered charts beside them |
master-detail | a list and the chosen row, the choice in the address |
upload-progress | files with a progress bar, checked by content, working without JavaScript |
trilha ui patterns lists them (--json for a machine, a name for one of them), the MCP tool get_pattern answers one, trilha ui describe <Component> says which patterns a component takes part in, and each pattern's demo on this site ends with its "Use this pattern" section.