UI components index

This is a one-page index of every UI component the framework ships. The canonical reference for any given component is its live demo page at /components/<slug> plus the Go doc comments on the component's Config / constructor. Those stay current automatically because each release lands the demo + the code together.


Where to look for what

You want…Look at
Live behavior (click it, drag it)Run the website: ./scripts/dev-watch.sh → /components/<slug>
Constructor signature + every fieldgo doc github.com/DonaldMurillo/gofastr/framework/ui.<Name>
Full-page composition choicegofastr docs ui-composition-recipes
Widget presets (Modal, Drawer, …)go doc github.com/DonaldMurillo/gofastr/core-ui/widget/preset
Runtime data-cui-* attributesruntime-contract
What's coming / deferredROADMAP.md §2

The website's components index page lists every component with a one-line description (examples/site/components.go is the source of truth). The examples/site test suite keeps every registered /components/<slug> paired with:

  • at least one chromedp e2e test
  • zero axe-core violations against the default WCAG 2.0/2.1 A/AA rule set
  • at least one *_test.go for the package that defines it

Catalog

Live demos at http://localhost:8082/components/<slug> once the dev server is up.

Variant-taking components panic on unknown variants; apps extend the sets with ui.RegisterButtonVariant / RegisterButtonSize / RegisterCardVariant / RegisterStatusVariant (one status registration covers StatusBadge, Tag, Callout, and Notification). See "Custom variants on framework components" in ui-getting-started. A host that receives the variant as a string (kiln's node renderer, the generated resource screens, uihost's trusted node renderer) resolves it with ui.ParseButtonVariant, which knows the four built-in variants and every one the app registered through ui.RegisterButtonVariant, and answers ok=false for any other spelling so the host applies its own default instead of panicking.

Every component Config carries ExtraAttrs html.Attrs, forwarded to the component's root element for data-* test hooks, analytics markers, and ARIA overrides. On components following the sanitized contract, component-owned keys (class, id, data-cui-*, behavior-critical attributes) are dropped; a legacy set still forwards raw (enumerated in framework/ui/extraattrs_contract_test.go). See "Attribute pass-through" in ui-getting-started.

Primitives & semantic markup

  • kbd: core-ui/html.Kbd, semantic <kbd> for keyboard input
  • shortcuthint: framework/ui.ShortcutHint, OS-aware chord chips (⌘ on Mac / Ctrl elsewhere)
  • avatar: framework/ui.Avatar, circular avatar with image → initials fallback (sm/md/lg/xl); Square rounds it to the medium radius for a logo tile, and --ui-avatar-bg / --ui-avatar-fg recolour the initials surface
  • avatargroup: framework/ui.AvatarGroup, readable 10% overlap, compact corner presence dots, and an adaptive-surface overflow chip
  • thumbnail: framework/ui.Thumbnail, a square lazily loaded image preview (sm 2rem / md 4rem / lg 8rem, object-fit: cover); a Src the image URL policy refuses draws nothing, so the caller draws its own empty mark
  • icon: framework/ui.Icon, 291 vendored Feather and Lucide drawings and 13 name aliases from framework/ui/icons. Existing site, navigation, and toolbar names resolve through the same catalog. currentColor stroke; AriaLabel opts into role="img". See Icons.
  • languagemenu: framework/ui.LanguageMenu, the language switcher for an app built with framework.WithLocalePrefixes: a Menu with a globe trigger naming the current language, and one radio row per catalog locale linking this page in that language (rel="alternate" hreflang). Each language is named in itself (i18n.LanguageName; Names overrides). The runtime retargets the rows after each client-side navigation, so it can sit in persistent chrome. Renders nothing without path locales on Ctx
  • link: framework/ui.Link, typed anchor with external-link affordances + unsafe-scheme href sanitizing
  • muted: framework/ui.Muted, subdued inline <span> for secondary text
  • inline code: framework/ui.InlineCode, a short escaped <code> in the mono font on the soft surface, for an operator, a field name or an example inside text; InlineCodeDanger draws it in the danger tone, for an error a program returned (a job's last error); blocks of code use CodeBlock

Buttons & form controls

  • button: framework/ui.Button, semantic button with typed variants (primary / secondary / danger / ghost) + sizes, rendered through the headless structure with the fui-button class map. Disabled renders the disabled state (a disabled key in ExtraAttrs panics pointing at the field). Every data-cui-* key in ExtraAttrs is runtime wiring and travels the typed Action seam; a key outside the vocabulary panics naming it — under the old carrier contract it rendered as a dead attribute. The admitted vocabulary: data-cui-rpc, -rpc-method, -rpc-body, -rpc-signal, -rpc-navigate, -rpc-open, -rpc-close, -rpc-reset, -rpc-after-text, -rpc-after-disable, -rpc-scroll-to, -rpc-refresh (re-poll a widget after success), -confirm, -signal-set, -signal-inc, -signal-toggle, -push-state, -open, -deeplink, -toast, -pane-open, -pane-key (a keyed pane's deep-link identity), -pane-close, -prefetch, -intercept-close — the shapes interactive.Action.Attrs() / OpenOnClick / PaneKey and friends produce; every other data-cui-* key belongs to the component that renders its own markup for it Icon draws a registered icon before the label, and IconOnly the icon alone in a square button with the label as its accessible name. Shortcut ("Mod+S", needs ID) binds a chord that clicks the button, drawn after the label as ShortcutHint chips and announced through aria-keyshortcuts. QuietUntilDirty draws the button in the secondary look until the form it submits has unsaved edits (the leave guard's data-hui-dirty)
  • linkbutton: framework/ui.LinkButton, anchor styled as a Button, for CTAs that navigate. IconOnly draws the Icon alone, as Button does. External owns target/rel (noopener). Carries the link-legal wiring keys (data-cui-push-state, -prefetch, -open, -deeplink, the intercept marks, and data-cui-nav="off" for a whole document load); every other data-cui-* key is refused, as always — a link navigates, a button acts
  • toggle: framework/ui.Checkbox / Radio / Switch, labelled native inputs rendered through headless.Choice / headless.Switch — one inline run, the label wrapping the control; FieldErrors-aware, an error wrapping the run and its message in one unit
  • checkboxgroup: framework/ui.CheckboxGroup / RadioGroup, headless.Group fieldset of checkboxes / radios; the group's message (error, else hint) belongs to the group, never to each leaf
  • changelist: framework/ui.ChangeList, what an edit changed, one row per field: the field's label, the old value struck in a <del>, an arrow, the new value in an <ins>; screen readers hear "from" and "to" (i18nui.ChangeFrom / ChangeTo) in place of the arrow, and an empty side reads as EmptyValue. Values are render.HTML, so a caller passes the same cell markup its table draws. ui.Ago(ctx, now, t) beside it says a past time the way an activity feed does ("just now", "5m ago", "2h ago", "3d ago"), through i18nui.AgoNow, AgoMinutes, AgoHours and AgoDays
  • colorfield: framework/ui.ColorField, colour swatch beside a text input holding the same value, as one control (headless.Color's affix shell); the text input is the source of truth and requires a Name, so values the native picker cannot represent (transparent, var(--x)) survive and mark the shell instead of degrading — the swatch falls back to black; the headless module keeps the swatch and the text one value for every value the picker can show (the short #abc form expands for the swatch and is pickable, not an error). Use ColorPicker when a swatch + label is enough
  • segmented: framework/ui.SegmentedControl, radio-group styled as a sliding pill bar; an empty Selected checks the first segment, and a Selected no option lists checks none (the pill hides), so the group submits nothing rather than a value nobody chose. Labels target option-indexed input IDs, so values such as C++ and C# remain independently selectable.
  • counter: framework/ui.Counter, signal-driven numeric counter with +/− buttons
  • signaltoggle: framework/ui.SignalToggle, role="switch" button bound to a boolean signal
  • toggleaction: framework/ui.ToggleAction, three-state commit/untoggle button (idle → pending → committed) with optional mutex groups; a toggle button whose accessible name is IdleLabel in both states (aria-pressed carries the state, only the visible label flips), so keep the idle label's words in CommittedLabel
  • passwordinput: framework/ui.PasswordInput, password field with a reveal button rendered through headless.Password — a toggle button: its name ("Show password") and word ("Show") never change, aria-pressed says whether the password shows and the kit paints the pressed state; the headless behaviour module binds the reveal and its words come from ui.StringsFor; a control, not a field: the error belongs to the FormField around it
  • searchinput: framework/ui.SearchInput, search field with icon prefix + clear button; the clear button tracks the value after input and native form reset, including a server-prefilled value
  • searchresults: framework/ui.SearchResults, a full search results page (GET form, match count, results with section links, pagination) that works without script
  • inputgroup: framework/ui.InputGroup, input with prepend / append addons
  • rating: framework/ui.RatingInput, 1-N star/heart with Size / Gap / Shape / Icon knobs; the radios are in reading order (1 to Max in the DOM), so arrows move the way the stars read, and the checked and hover fill is :has() in the kit sheet
  • rating-display: framework/ui.Rating (ui.RatingDisplayConfig), a read-only score drawn as glyphs sized to themselves, one role="img" with a localized "4 out of 5" name. Shares Shape / Size and the --ui-rating-color knob with RatingInput. Use it for a testimonial or a product average, where a disabled RatingInput would announce a form control and space its stars on 44px tap targets.
  • slider: framework/ui.Slider, <input type=range> with optional live value mirror
  • rangeslider: framework/ui.RangeSlider, dual-thumb range with cross-clamp; ValueLow / ValueHigh are *int, nil the unset end (low on Min, high on Max), so a set pair of new(0), new(0) renders as 0 to 0 rather than the whole range
  • numberinput: framework/ui.NumberInput, number field with explicit +/- buttons
  • textarea: framework/ui.TextArea, multi-line input with typed Autogrow and Monospace (the mono font token, for JSON and code) and JSON (checks the text as JSON as it is typed: an invalid value reads aria-invalid and stops the form's submit with "Enter valid JSON", through headless.Textarea's JSON sentence and the data-hui-json hook of the headless-controls module; empty text is left to Required, and the server still validates)
  • colorpicker: framework/ui.ColorPicker, styled native <input type=color>
  • timepicker: framework/ui.TimePicker, styled native <input type=time>
  • datepicker: framework/ui.DatePicker, a calendar that stays on the page beside the native <input type=date> it fills, for a range in one control, refused days, or notes on days; the native input is the value and posts without script, the headless-datepicker module owns the date-grid keyboard, and a month change is an island the page serves (ui.DatePickerMonth). An ordinary date stays DateField
  • select: framework/ui.Select, labelled native <select> with help, error, placeholder, and required marker; SelectConfig.Selected supplies a stored value and, when nonempty, overrides option-level Selected flags. A stored value absent from Options remains selected as an extra option labelled with that value, so saving does not silently replace retired choices. When Selected is empty, the last flagged option wins, except that a flagged empty value selects the placeholder when Placeholder is set. With no flagged option, the placeholder or browser's first option applies. Action draws a control after the select on its row (an open-record link); LabelHidden keeps the label for assistive tech and hides it from view
  • taginput: framework/ui.TagInput, free-form chips, Enter/comma to commit, Backspace to remove
  • multiselect: framework/ui.MultiSelect, checkbox group inside a disclosure with a chips strip above; submits as a plain form (the field name repeats per checked option, no script needed), the chips are the enhancement the headless-multiselect module rebuilds from the checkboxes' own state
  • form: framework/ui.Form, opinionated <form> wrapper with submit + error summary; Wide lifts the readable-measure cap so a FormFrame body can split into its side column
  • formfield: framework/ui.FormField, labelled input with required + help + error states
  • control: framework/ui.Control, styled native input (email, password, datetime-local, file, tel, url, search…) for a FormField builder
  • textfield: framework/ui.TextField, typed labelled native text field with required, help, error, autocomplete, and length attributes; LabelHidden (also on NumberField, DateField and DateTimeField) keeps the label for assistive tech and hides it from view
  • numberfield: framework/ui.NumberField, typed labelled native number field with explicit min/max/step bounds
  • datefield: framework/ui.DateField, typed labelled native date field with HTML-date min/max bounds
  • datetimefield: framework/ui.DateTimeField, typed labelled native date-and-time field (datetime-local) with min/max bounds and a step in seconds
  • formsection: framework/ui.FormSection, grouped fields with a shared heading + description
  • formframe: framework/ui.FormFrame, record form in two columns — a wide main column beside a narrow side rail (one of three named SideWidth sizes, each a modifier class reading a --ui-form-frame-side-* token) that drops under the main one below 48rem of the form's own width, a container query, so a drawer on a wide screen stacks and a full page sits side by side; the main column stops at 45rem, and SidePanel makes the rail a bordered panel that sticks beside the fields (under a ContentRow{Sticky: true} toolbar it stops below the bar, offset by the --ui-sticky-top the row publishes, which already counts a StickyHeader page's header)
  • validationsummary: framework/ui.ValidationSummary, inline summary of form validation errors
  • conditionalfield: framework/ui.ConditionalField, form region visible on first paint and hidden by the runtime until the watched field matches — one value (WhenValue) or any of a list (WhenValues)
  • formrepeater: framework/ui.FormRepeater, dynamic list of repeating field groups (add / remove rows); each row's Remove is a danger kit button and Add a secondary one (the fui-button classes, so the theme's danger treatment and the button density apply), and the items list carries no list bullets or indent
  • repeater: framework/ui.Repeater, dynamic add / remove item list with min / max limits; each row's Remove is a ghost kit button and Add a secondary one (the fui-button classes, so they match every other button on the page), and the items list carries no list bullets or indent
  • stepwizard: framework/ui.StepWizard, multi-step form with a progress indicator bar

Selection & input composition

Pass the page or island render context as Ctx to TextField, NumberField, DateField, DateTimeField, Select, and FormRepeater. Their derived IDs then share the render ID scope, so same-named controls keep distinct labels and hint/error targets. Explicit ID values remain caller-owned and reserve their dependent IDs; callers must keep explicit IDs unique. Without an ID scope, the original name-derived spelling stays.

ToggleConfig.Ctx, RadioGroupConfig.Ctx, and CheckboxGroupConfig.Ctx claim the same scoped ID families. Group leaf IDs use the option index, not the value's slug, so C, C++, and C# remain distinct. Submit values are unchanged. Explicit group IDs reserve both descriptions and all indexed leaf IDs; standalone radio IDs disambiguate within Ctx. Generated entity-form checkboxes forward the same render context, including when an explicit control ID is supplied.

  • combobox: framework/ui.Combobox, typed combobox over the headless primitive: a labelled input with role=combobox wiring, a GET-form no-script fallback to the same endpoint, an RPC-driven listbox whose options are real links, and a status region the module announces results through; core-ui/patterns/combobox is retired
  • inline edit: framework/ui.InlineEdit, a value edited where it is shown (a table cell): the value is the trigger of a dismissable <details> editor laid over it: the caller's Control (give it a hidden label, LabelHidden, since the cell names it) and Save on one line of a ui.Form. Saving sends it to Action over the runtime's form RPC (PUT, or PATCH with Method), toasts Saved and navigates to Return, so the server redraws the page with the stored value; Escape or a press outside closes it unsaved. Label is the trigger's accessible name. The value tints on hover and shows a focus ring.
  • segmented links: framework/ui.SegmentedLinks, a strip of links drawn as a segmented control, one Current (aria-current="true") inside a labelled role="group": a list's Table / Cards switch. Every segment is a plain link, so the choice lives in the URL; IconOnly keeps each segment's text as its accessible name and title. Count draws a muted figure after a segment's text (the Jobs page's "Failed 3"). The strip keeps its own width in a flex column and scrolls inside itself when wider than its box.
  • shortcut sheet: framework/ui.ShortcutSheet, the keyboard help sheet: returns a trigger snapshot and a Modal preset like CommandPalette. Mount the preset once, then render ui.ShortcutSheetTrigger(ctx, cfg) in each request's chrome so its default visible text and accessible label follow that reader's locale. ctx takes precedence over cfg.Ctx; nil uses English fallbacks, and an explicit TriggerLabel still wins. Keep Name and Shortcut consistent with the mount. The trigger opens it on its own chord (? by default) and is a plain link to FallbackHref without script. The modal lists Items (a chord and what it does) as keycaps beside each label under a heading that names the dialog, with a close button. Mount ShortcutSheetFunc for translated rows and render ShortcutSheetTrigger(ctx, build(ctx)) beside it. ui.ShortcutList(ctx, items) draws the same rows for the fallback page. The list search in ui.FilterToolbar takes a chord too: FilterSearch.Shortcut (entity lists use /) focuses it from anywhere outside a text field, with a keycap at the input's end while it is empty.
  • column picker: framework/ui.ColumnPicker, a list's Columns dropdown: one row per column in display order, each a link that shows or hides it (ToggleHref; a Locked column has none), and beside a shown one, move-earlier and move-later links (UpHref, DownHref; empty draws the control disabled), then a Reset link. Every control is a navigation the caller builds, so the order lives in the list's URL and works without script.
  • filter rows: framework/ui.FilterRows, structured filter rows inside a list's GET filter form: each row a field select, an operator select and a value, submitted as three repeated params (FieldName, OpName, ValueName) in row order, with visually hidden numbered labels ("Field 1"). The rows a filter holds come first, then one blank row to add another; a row with an empty value is ignored, which removes it without script. On a narrow container the value drops below the two selects.
  • picker: framework/ui.Picker, a form field that picks one record out of many: a search input over a server-searched listbox (headless.Combobox in Pick mode) and a hidden input that submits the picked Value. The search input is detached from the host form (its form attribute names no form), so only the value submits. Endpoint answers each search, a POST of {"q": …}, with ui.PickerRows(id, options, more); Options are the first rows shown on focus and More a note row after them ("Showing 20 of 312"). Focus selects the shown label so typing searches afresh; typed text that was never picked goes back to the picked label on blur, and an emptied input clears the value. Action sits after the input (an open or a New button). Form names

the form the hidden value submits with (the HTML form attribute), for a picker drawn outside its form; headless.ComboboxPick.Form is the same field on the primitive. headless.ComboboxRows renders the rows for any island combobox answer.

  • commandpalette: framework/ui.CommandPalette, ⌘K modal + combobox composition
  • globalsearch: framework/ui.GlobalSearch, sticky inline /-shortcut search bar
  • dropzone: framework/ui.FileDropzone, hero file-drop surface with image previews
  • fileupload: framework/ui.FileUpload, drag-drop file picker over native <input type=file>
  • recordsummary: framework/ui.RecordSummary, compact dominant record or event summary with status, next-decision, balanced phone metrics, a bounded support rail, ownership, and a lead-region natural-width action that stays early on phones
  • skiplink: framework/ui.SkipLink, focus-visible bypass link for jumping to main content
  • pageheader: framework/ui.PageHeader, top-of-page header with title / eyebrow / subtitle / actions
  • anchoredrail: framework/ui.AnchoredRail, sticky in-page nav rail with scrollspy-tracked active state
  • contentrow: framework/ui.ContentRow, the page's content row — a start column (the nav landmark ContentRowConfig.NavLabel names, wrapping a ui.Sidebar whose own inner nav names only the links list), the main column, an optional toolbar band above main beside the nav (a region landmark ToolbarLabel names), and an optional context aside that releases its width around an empty outlet. Breakpoint picks the stack width (below md by default, below lg to match SidebarConfig.DrawerBreakpoint); Viewport: true fills the rest of the viewport below the header and scrolls each column on its own — the row reads the header's height from --size-header-height and cannot style its parent, so recipes pair it with a page-tall ui.Stack{Screen: true} and the fixed header band; Sticky: true keeps the window as the scroller and pins the nav column at --ui-sticky-top, under the header when the page stack sets StickyHeader, so a long menu scrolls on its own beside the page.
  • tabs-signal: framework/ui.Tabs, signal-driven tab strip (click sets the signal; CSS shows the panel); StateAttrs adds data-state=active/inactive to the buttons, ID wires aria-controls/id pairs, VacateHidden ships hidden panels empty with their content in a JSON stash (restored on show by the headless-tabs module) so page-scoped test locators cannot match hidden text — the contract knobs a port needs, each off by default
  • tabnav: framework/ui.TabNav, a navigation strip of links where at most one is current (aria-current="page"; a second Current panics): the view tabs above a list, the sub-navigation of a record screen. Every tab is a plain <a>, so a tab click is a navigation the client router intercepts and the URL stays shareable — the query-param contract since list state moved off islands. Badge rides inside the link, aria-hidden, for a view's row count. End draws a control at the strip's trailing edge, outside the <nav> landmark, such as a "Save view" dropdown; the root is then a <div> holding both. Distinct from tabs-signal, which swaps panels in place under one URL
  • breadcrumbs: framework/ui.Breadcrumbs, <nav aria-label> landmark over an ordered trail; the last step (or the one carrying Current) is aria-current="page" text, never a link to itself, and separators are aria-hidden CompactMobile: true shows the final two steps below md and hides the leading separator; the full trail remains on desktop.
  • pagination: framework/ui.Pagination, numeric page pager with typed query props (Path, Query url.Values, PageParam default p) — every href is built through net/url with the page parameter replaced, never a %d pattern; Window sizes the page neighbourhood, OmitPrevNext drops the ends. The Island is optional, the Table posture: a list screen's page anchors are plain navigations the client router intercepts, and a pager inside an island region (a DataTable footer) carries the RPC contract beside its hrefs and the data-hui-page hook the table module restores focus through. core-ui/patterns/pagination is retired: this is the only pager
  • sidebar: framework/ui.Sidebar, responsive primary nav with persistent, collapsible (local-storage persisted), off-canvas, and auto-hide variants; Collapse moves the collapsed state to the server, GroupMarkup swaps <details> groups for button[aria-expanded][aria-controls] + hidden container, CollapseLabel/ExpandLabel rename the toggle and CollapseText its visible foot label (the toggle is a sticky row at the column's foot, icon-only in the rail), SectionLabels draws group headers as small uppercase section labels over flush links, RaisedCurrent draws the current page's link as a raised pill (surface, hairline ring, small shadow; the admin's nav), and --ui-sidebar-row-height sets a row's height (2.25rem under a fine pointer, the 44px touch target on a coarse one) (see Sidebar: server-owned collapse state); in a column of fixed height (a ContentRow{Sticky: true} nav) the inline sidebar fills it and its nav scrolls in its own region, so the brand stays put and the collapse row sits under the links instead of over the last one; set NavLabel when a page has multiple navigation landmarks and mount the matching drawer with MountSidebar
  • sidebarbrand: framework/ui.SidebarBrand, the product mark at a sidebar's head: a square logo tile (the Logo image, or the name's initial on the inverted surface), the name, and an optional muted Sub line; --ui-sidebar-brand-tile sizes the tile. A collapsed rail keeps a Prepend that is only a brand and draws its tile alone; any other Prepend hides there
  • sidebardrawertrigger: framework/ui.SidebarDrawerTrigger, the sidebar's drawer toggle rendered on its own — the relocated hamburger a header row carries while the sidebar itself lives in the body; pass the SAME SidebarConfig the ui.Sidebar render uses and the pair stays one logical control (the trigger hides itself at >= md exactly like the sidebar's inline copy, so SuppressDrawerTrigger and a relocated trigger never draw two)
  • menu: framework/ui.Menu, keyboard-driven dropdown built on <details>; MenuItem.ID gives a row an addressable id (caller-owned uniqueness, ignored on separators; ExtraAttrs still cannot set id); MenuConfig.TriggerElement swaps the framework <summary> for a caller-owned trigger — pass the inline HTML of your own <button> (or <a>) and the runtime makes it the controller: it wires aria-haspopup/aria-controls/aria-expanded at hydration, toggles on click/Enter/Space (activation is prevented — put navigation on menu items), focuses the first menuitem on open, and returns focus to your element on Escape. Use it whenever the page owns the trigger's markup or classes (avatar buttons, pill buttons): routing such an element through TriggerHTML nests it inside the summary, which axe reports as nested-interactive. TriggerElement overrides Label and TriggerHTML; give each trigger menu a distinct ID when two structurally identical ones share a page. MenuConfig.LazyPanel: true keeps the panel's rows out of the document tree until the menu is first opened: SSR ships them inside an inert <template data-cui-menu-lazy> as the panel's only child (the panel <div> stays, so aria-controls still resolves), and the runtime mounts them on first open — before its focus-on-open lookup, so the keyboard contract is unchanged. Use it when live-DOM queries must not see closed-menu rows: host Playwright contracts that pin getByText('Theme') to the first visible match or getByLabel to exactly one element. The rows are still in the HTML source, so this hides nothing from a crawler that parses the response. The cost: rows are not in the DOM until first open, so host JS that binds menu rows by id at page load must use delegated listeners instead, and with JavaScript disabled the menu opens empty (only the disclosure module mounts the rows); the zero value renders rows inline exactly as before. MenuConfig.IconOnly: true draws the trigger as the more icon with Label kept as its visually hidden accessible name, for a table row's action menu. MenuConfig.Avatar draws the trigger as that avatar (round, borderless, 32px, a 44px box on coarse pointers) with Label as its hidden name, for an account menu; it panics beside TriggerHTML, TriggerElement or IconOnly. MenuConfig.Icon draws a registered icon before Label on the default trigger (a list's Columns menu); it panics on an unregistered name and beside TriggerHTML, TriggerElement, IconOnly or Avatar. A row's action is one of Href, RPC, Action, Do (a built *interactive.Action, so the row carries its confirm, navigate and toasts) or Copy (&ui.MenuCopy{Target: "<element id>", Toast: "Copied"} copies that element's text through the headless copy module); a row naming two panics at render. MenuItem.Check: true makes a row a checkbox option (role="menuitemcheckbox", aria-checked from Checked): the state is the server's, so pair it with Href or RPC that toggles it, as a list's Columns menu does; it panics beside Radio or Children. A Disabled row is out of the keyboard order and inert to the pointer; a disabled Href row renders with no href, so neither a keyboard Enter, an assistive-tech activation nor a crawler follows it.
  • tree: framework/ui.Tree, WAI-ARIA treeview on the headless primitive: roving tabindex, arrows/Home/End/type-ahead bound by the registered headless-tree module; leaf hrefs are real anchors, a static branch is real markup, and a LazyPath branch keeps the kernel's rpc wiring on its toggle with a hidden signal-bound group (LazySignalPrefix names the signal); a lazy branch starts collapsed even with Expanded set, so its first toggle loads it
  • toc: framework/ui.TableOfContents, a server-rendered contents nav from the Items you pass, marking the section in view when Target is set; PhoneSheet folds it into a bottom sheet a floating pill opens on phones
  • steprail: framework/ui.StepRail, vertical numbered step rail with an active step + anchor links; it sticks --ui-step-rail-top (24px) below --ui-sticky-top, so it stops under a StickyHeader page's header
  • steps: framework/ui.ProgressSteps, linear step indicator (horizontal + vertical)

Disclosure / surface widgets

  • collapsible: framework/ui.Collapsible, styled <details> with clickable summary + Escape-to-close
  • actionlist: framework/ui.ActionList, a short list of rows drawn as a menu's rows (full width, a touch target tall, the soft surface on hover and focus), each a link (Href) or a button carrying a built RPC (Do), with an optional registered Icon and Danger. It is the rows of a panel that also holds other controls, such as the admin's account Dropdown with its theme switches: a Menu panel is role=menu and may hold only menu rows, so a mixed panel's rows are a plain list Tab walks.
  • columnpicker: framework/ui.ColumnPicker, a list's Columns dropdown: one row per column (ColumnChoice) with a show/hide link and up/down links, a Locked column that cannot be hidden, and an optional reset link. The choice lives in the list's URL. ColumnPicker, ActionList and SegmentedLinks apply the shared anchor URL policy to every destination; refused URLs keep their label or icon as a disabled span with no href.
  • filterrows: framework/ui.FilterRows, rows of field, operator and value selects/inputs (FieldName, OpName, ValueName name the repeated query parameters) that a list's filter form submits.
  • segmentedlinks: framework/ui.SegmentedLinks, a strip of links drawn as a segmented control, each with an optional Count and Icon; the Current one carries aria-current. A filter that is a link per value, with no Apply.
  • dropdown: framework/ui.Dropdown, a trigger button whose panel floats under it and holds any content (a list's filter fields, a "save view" form), where a Menu holds only command rows. A native <details> (headless Disclosure with Dismiss): it opens with no script and closes on a click outside, Escape, or a client-side navigation. Icon names a registered icon, Count draws a badge (the filters applied), Align: ui.DropdownEnd lines the panel up with the trigger's end edge, and Avatar draws the trigger as that avatar alone (the label kept as its accessible name; not beside Icon or Count), an account panel in an app bar. Fields inside a closed dropdown still submit with their form. Knobs: --ui-dropdown-min-width, --ui-dropdown-max-width; the viewport bounds both.
  • modal: core-ui/widget/preset.Modal, focus-trapped dialog with deeplink
  • drawer: core-ui/widget/preset.Drawer, edge-mounted sliding panel
  • bottomsheet: core-ui/widget/preset.BottomSheet, bottom-anchored Drawer variant
  • popover: core-ui/widget/preset.Popover, click-triggered floating surface
  • floatingpanel: core-ui/widget/preset.FloatingPanel, corner-anchored persistent panel
  • tooltip: framework/ui.Tooltip, CSS-only hover/focus reveal
  • toast: core-ui/widget/preset.ToastStack, client-side slide-in notifications (no SSE, no server queue)
  • notificationbell: framework/ui.NotificationBell, bell + unread badge + popover dropdown. The trigger is an anchor to Href; its accessible name keeps the required Label before the localized unread count, such as Notifications: 0 unread notifications. SignalUnread changes the count without dropping that label prefix. Mount the constructor's popover once, then render ui.NotificationBellTrigger(ctx, cfg) in each request's header. Its explicit context resolves both the initial spoken count and the live-update sentence template; cfg.Ctx only resolves the snapshot returned by the constructor. Nil uses English fallbacks. Keep Name consistent with the mount, and build any translated caller Label with that request's context before passing it to the trigger. The mounted body's default empty text uses its own request context; explicit Label and EmptyText remain as supplied.
  • confirmaction: framework/ui.ConfirmAction, trigger + alertdialog Modal
  • commandpalette: (also under Selection; same component)

Layout & display

  • layout: framework/ui.Stack / Cluster / Grid / Center / Spacer / Box; Cluster wraps by default and exposes the explicit NoWrap opt-out; Shrink lets a no-wrap row narrow, its last child (a trail that ellipsizes) taking the squeeze while the earlier children keep their size. StackConfig.Screen: true makes the stack the page column: at least one viewport tall (100dvh) with its last child pushed to the bottom — the option a recipe uses to keep a short page's footer at the bottom of the screen. StackConfig.StickyHeader: true pins the stack's <header> child and publishes its height (--size-header-height) as --ui-sticky-top, the offset every top-sticky part below reads (ContentRow's Sticky frame, StepRail, SectionMenu, TableOfContents, AnchoredRail, GlobalSearch, NetworkRetryBanner, a top-edge Sticky, FormFrame's side panel, an anchored Section); a region that scrolls on its own (a bounded Box, a pinned nav column, a ListDetail list) resets it to 0. BoxConfig.Bounded: true caps a Box at the --ui-box-bounded-size knob (20rem) and scrolls what overflows: a window for a long log or list in a panel, or for previewing a viewport-height component (Workbench) inside a page
  • listdetail: framework/ui.ListDetail, a labelled, keyboard-scrollable list beside a detail slot. Keep it in the group layout to preserve its node and scroll position while detail navigation swaps the primary slot. On phones the detail stacks above the list. ExtraAttrs accepts the two markers returned by LayoutTree.VTRegion() for whole-region transitions.
  • listdetailplaceholder: framework/ui.ListDetailPlaceholder, marks an unselected detail screen for ListDetailConfig.MobileSinglePane. This opt-in phone mode shows the list on an index and the detail on an issue, including a missing issue; desktop remains split. BackHref (with BackLabel, default "Back") adds a link to the list at the top of the detail pane, shown only while a phone shows the detail alone; it requires MobileSinglePane. The default ListDetail still stacks both regions on phones.
  • container: framework/ui.Container, max-width page wrapper with breakpoint padding; Width: ContainerPage caps at the page measure (--size-page-width, 66rem) — the editorial column a contained page's main and its header/footer bands share. Like the bands, its content box is the measure and the page gutter (--size-page-gutter, clamp(20px, 5vw, 32px)) sits outside it, so main's text starts on the header brand's edge at every width; Pad is the page's block rhythm: ContainerPadPage pads under the header (--ui-container-pad-start, clamp(40px, 6vw, 64px)) and above the footer (--ui-container-pad-end, clamp(48px, 7vw, 80px)), ContainerPadEnd only above the footer; a padded container is page-level, so it takes the page gutter inline at every Width (an unpadded one keeps the nested-container steps, 8/16/24px), except inside an app frame's content area (ui.ContentRow with a Sidebar), whose own padding is the gutter; an unknown value panics; Start: true pins the column to the inline start and drops its gutter, for a measure inside a frame that already pads its content (a settings page in an app shell's pane)
  • section: framework/ui.Section, labelled content section with heading + description; Overline draws the heading as a small upper-case muted group label over a run of cards (a dashboard's "Billing"), still the section's h2; Band gives a section with no eyebrow the page-band treatment (display-size heading). With no ID, the id is the heading's slug; set Ctx (the screen's render context) and a second section with the same heading on the page gets features-2 (its aria-labelledby follows), while an explicit ID is never changed. The page render's id scope comes from app.WithRequest; render.UniqueID hands ids out from it, and the derived ids of ui.Tabs and headless.Carousel use it too (a carousel slide's own ID is reserved like any explicit id). ui.Carousel, ui.Repeater and ui.Counter given no ID take theirs from the scope when their Ctx carries one (render.HasIDScope), so the id inside an island is the same on the page and in the RPC answer; with no scope they fall back to a process-wide counter. An id is taken with the ids built from it (a section's features-title heading id, a tab strip's -tab-0 and -panel-0), so a section titled "Features title" next to one titled "Features" gets features-title-2. The app renders each page answer as one id region (render.NewRegionBatch): every explicit id on the page, whether its component called render.ReserveID or only wrote it in an id attribute (RegionBatch.ReserveRendered reads the page for them), is reserved before a derived id is handed out, so ID: "features" keeps its id even below a section titled "Features", which becomes features-2. Layout outlet fills render concurrently but their ids are settled in outlet order afterwards, so the output never depends on which fill finished first. A part of the page that also renders alone takes an id namespace (render.WithIDNamespace): its derived ids read <namespace>--<id>, the same inside the page and alone, and apart from every other id on the page. A deferred outlet (OutletOptions.Deferred), which a part request fills, uses its address, marked so no island id reaches it (_l-shell-aside--features for l:shell#aside); an island rendered with island.NewIsland(id, c).RenderCtx(ctx) uses its id (cart--features), so render it with RenderCtx on the page and in its RPC handler. A namespace keeps letters, digits and single inner hyphens and spells any other byte as _ and two hex digits (island cart.v2 gives cart_2ev2--features; outlet aside-a is _l-l_3ashell_23aside-a--features), so no two namespaces give one id and no island id reaches an attribute unescaped. Inside its namespace an island or deferred outlet is one id region of its own, so an explicit id there wins over a derived one too. A widget's chrome renders without the page's scope on the page and on its /chrome fetch alike, so it keeps the ids it derives.
  • responsive: framework/ui.Responsive, viewport-swap pair (independent desktop / mobile variants). Below picks the breakpoint the mobile variant shows below: ui.StackBelowMD (the zero value, 48rem) or ui.StackBelowLG (64rem)
  • panehost: framework/ui.PaneHost, primary pane + openable secondary/tertiary side panes with a responsive overlay-drawer collapse
  • themed: framework/ui.Themed, wraps a subtree in a registered section-level theme override
  • workbench: framework/ui.Workbench, viewport-height inspector shell: a fixed-width rail that scrolls on its own beside a pane that fills the rest (an <iframe> in the pane fills it edge to edge); stacks below 720px
  • card: framework/ui.Card, a surface with header/body/footer over headless.Card; Href makes the whole card one link; Action puts a control at the header's end, level with the heading (a feed's "View all" link), and panics beside Href; a card with an ID names its heading <ID>-title, so a chart in the card can pass that as LabelledBy An interactive card with aria-current="page" receives the active surface treatment, including marks applied by the runtime's navigation sweep.
  • sticky: framework/ui.Sticky, theme-token sticky wrapper for top or bottom edge pinning; Offset measures from the pinned edge (a bottom bar sits that far above the viewport's bottom), and a top-edge wrapper draws its hairline only while stuck, in browsers that report the stuck state (container-type: scroll-state). An unknown Edge or Offset panics
  • aspectratio: framework/ui.AspectRatio, CLS-safe aspect-ratio wrapper for media and embeds; Ratio is required and an empty or unknown bucket panics, since a box with no ratio collapses its child to 0px
  • image: framework/ui.OptimizedImage, responsive <picture> with CLS-safe Width/Height
  • pipelineimage: framework/ui.PipelineImage, multi-format <picture> consuming framework/image VariantSet output (typed sources + LQIP/BlurHash)
  • divider: framework/ui.Divider, semantic separator (horizontal, vertical, labelled)
  • gallery: framework/ui.Gallery, Grid / Strip / Masonry thumbnail surface
  • lightbox: framework/ui.Lightbox, zoom-overlay modal; pairs with Gallery
  • carousel: framework/ui.Carousel, horizontal scroll-snap slider
  • sortablelist: framework/ui.SortableList, drag-and-drop + keyboard reorderable list on the headless primitive (Space grabs, arrows move, columns following the screen in RTL, Space drops, Esc, focus leaving the row or a new grab cancels; a drag released off the list puts the row back); the commit is the pattern's server round trip — 2xx confirms, non-2xx reverts, linked columns share Group, a versioned 409 refetches fresh rows (SortableListItems is that fragment)
  • optimisticaction: framework/ui.OptimisticAction, button that flips to its SSR-declared success state on click; the RPC fires underneath and rolls back with a shake on non-2xx
  • toggleaction: framework/ui.ToggleAction, OptimisticAction's three-state cousin: idle ↔ committed with optional untoggle endpoint and Group mutex (committing one reverts its siblings)
  • networkretrybanner: framework/ui.NetworkRetryBanner, persistent banner that shows on RPC-failure threshold or SSE silence; retry button pings a health endpoint to recover

Data display

  • metricband: framework/ui.MetricBand, flat semantic signal band (one row wide, two columns on phones) for related facts that should not become a wall of cards; Hint adds a trend or qualifier
  • datatable: framework/ui.DataTable, sortable / paginated / island-swappable rows; a sort header is an anchor in both postures — the URL is a list screen's state, and an embedded table's anchors carry the island contract beside their hrefs. Every sortable header shows a muted up-down glyph, and the sorted one an up or down arrow, drawn from its aria-sort; --ui-data-table-sort-icon-size sizes it. A cell holds its value on one line, so a table wider than its box scrolls inside it; Column.Wrap lets a prose column wrap, at least --ui-data-table-wrap-width (16rem) wide. Column.Fit shrinks a column to its content (a checkbox, a row menu) so the others take the spare width; a fitted column cannot also Wrap. Column.Truncate holds a long value (an error, a URL) to one line of at most --ui-data-table-truncate-width (20rem), cut with an ellipsis; it cannot combine with Wrap or Fit. Column.SelectAll names the row checkboxes a header checkbox governs: it checks and clears that table's boxes of the name and shows mixed when only some are checked; an empty table draws no box. A sort header's label sits level with the values under it. Responsive: ui.ResponsiveCards turns each row into a labelled card below 640px of container; Responsive: ui.ResponsiveRows turns it into a two-line phone row below 720px: the Column.Phone slots place cells (PhoneTitle over PhoneSubtitle, PhoneMeta over PhoneDetail at the end, PhoneLead before for a checkbox, PhoneEnd after for a row menu), a column with no slot is not drawn on the phone, and one column must be the PhoneTitle; a row with no PhoneLead cell keeps no empty lead column. In both modes the header row is clipped on a phone and drawn while one of its controls (a sort link, the select-all box) holds focus, so a keyboard user sees where focus is and can still sort. Flush drops the table's frame, for rows inside a card. Under the rows, Summary (the window sentence, "1–25 of 40", also appended to the sort announcement) sits on the left and FooterTools (a rows-per-page menu) before the pager on the right; Note is a line of small print below them.
  • selection: framework/ui.Selection, a Bar that acts on selected rows over the Body that holds them, a list's bulk-action form over its table. The bar shows only while a checkbox in the body is checked, through CSS :has() with no script; a browser without :has() shows it always. Body checkboxes join the bar's form with form=. Floating draws the bar after the rows as an inverse pill held to the bottom of the screen, with "{n} selected" (ui.selection.count, kept by the headless behaviour) and, with Form, a reset button that clears the rows (ui.selection.clear); a table's select-all box never counts, nor does a box in a form inside the body (a cell's inline editor). Copy (a same-origin URL that answers CSV for repeated _id parameters) adds a Copy CSV button: the headless behaviour fetches it with the checked rows' ids, writes the answer to the clipboard and toasts ui.selection.copied (or ui.selection.copyFailed); a URL on another origin is ignored. Built on headless.Selection. Knob: --ui-selection-gap
  • statcard: framework/ui.StatCard, metric card with label/value/trend; a value too long for its card wraps inside it instead of running out of it. Href links the label to what the number counts, Icon names a registered icon for the card's head, and Action puts a control (a "New" LinkButton) in the head beside it, outside the link, and wraps under a long label. Plain drops the card's frame for a figure inside a ui.StatStrip. Tile draws the Icon in a tinted square at the head's start, the Action at its end and the label on the line below (the admin dashboard's entity cards). A 4-card dashboard row lives in a ui.Grid; the Grid default Min: "16rem" wraps 3+1 inside a sidebar-narrowed content column (~900px). For a 4-up row that fits (and degrades to 2+2 on tablet), pass Grid(GridConfig{Min: "13rem"}, …); the Min knob is the intended control, not a Grid default change (16rem stays right for general content cards). A card fills its grid cell, so a row of cards shares one height even when each sits in a wrapper (a polled region); GridConfig.Fill keeps a lone card, or a short last row, at a column's width instead of stretching across the row.
  • animatedcounter: framework/ui.AnimatedCounter, an arrival animation driven by requestAnimationFrame, not visibility or an intersection observer. It renders the figure alone, with no step buttons (headless.CounterProps.Display): a number that animates in is read, not operated. The signal owns the number: a live value differing from the arrival target stops the walk before its first frame or between frames, even when it matches the number already painted. Reduced motion leaves the SSR figure untouched, and rescanning the same markup never restarts the animation
  • entrylist: framework/ui.EntryList, a dense list of links to read: each Entry a title link, its Source (a domain) muted beside it, and a Meta line of caller markup under it (points, author, age, a link to the discussion). Ranked draws an <ol> numbered from Start, so page two of a thirty-entry list reads 31, 32, …; the numbers are the browser's list markers, muted and tabular. A visited title turns muted. Label is required. Knobs: --ui-entry-list-gap, --ui-entry-list-rank-width
  • commentthread: framework/ui.CommentThread, a discussion with replies nested as deep as it goes: each Comment is an <article> (a Byline, then the Body, both caller markup) in a list item, its Replies a list inside that item, so assistive tech hears depth as list nesting. Each level is indented a step and ruled on its leading edge; in a container under 30rem the step shrinks so a deep thread keeps a readable column on a phone. Long words and URLs in a body wrap. Label is required. No script. Knobs: --ui-comment-thread-gap, --ui-comment-thread-indent, --ui-comment-thread-indent-compact
  • timeline: framework/ui.Timeline, vertical event rail; each event has a Title or, in its place, a Lead: headline markup the caller draws (a bold name, a link) from escaped parts; When plus an RFC 3339 Machine timestamp emits semantic <time datetime> (invalid pairs panic); When alone is a span, and Meta stays an independent caption; TimelineEvent.Icon draws a registered icon in the marker, a bordered circle (--ui-timeline-icon-size, 28px) with the icon tinted by the variant
  • sparkline: framework/ui.Sparkline, pure-SVG inline trend chart
  • piechart: framework/ui.PieChart, SVG ratio chart (donut variant via InnerRadius); a zero or non-finite total uses the localized shared chart empty state, retaining the chart's ID, class, accessible name and safe extra attributes
  • gauge: framework/ui.Gauge, one reading against a range as a semicircle SVG dial: CPU at 73%, latency against its budget, disk used. The root is an ARIA meter named by Label and carrying aria-valuemin, aria-valuemax, aria-valuenow and aria-valuetext (ValueText, the reading as people read it: "73%", "412 ms"), so a screen reader announces the reading. Min/Max default to 0–100. A reading outside the range pins the arc to the nearer end while ValueText still says what was read; NaN or Inf renders the empty state. Bands color the arc by where the reading falls: the band with the highest From at or below the value wins (GaugeSuccess from 0, GaugeWarning from 70, GaugeDanger from 90); no bands takes GaugePrimary. The reading's text scales with Size. An unknown tone, an inverted range, a span too wide to compute or a missing Label panics. No script. A fraction of a task that finishes is Progress, not a gauge.
  • scatter chart: framework/ui.ScatterChart, points on two numeric axes for the question a bar chart can't answer: do two measures move together (seats against MRR, latency against payload size)? Each ScatterSeries has a Name (required) and Points of {X, Y, Label}, drawn in one color: a palette token, a registered status variant or a CSS color, falling back to the palette for anything else. Both axes are always drawn, rounded out to clean steps of 1, 2, 2.5 or 5 times a power of ten, with optional XLabel / YLabel titles and a ShowLegend strip that wraps at the right edge. Tick labels carry as many decimals as the step needs (past 1e9, or under a step of 1e-9, as many significant digits), and the left gutter grows to fit them. Each point's <title> reads "Series: Label (x, y)". A NaN or Inf coordinate is skipped; with no finite point the chart renders the empty state. LabelledBy names it for assistive tech, as with the other charts. No script.
  • heatmap: framework/ui.Heatmap, a grid of values shaded by size: sign-ups by weekday and hour, errors by service and day. Rows and Cols label the sides and Values holds one row of len(Cols) values per row label; a mismatch panics. A row label past 17 characters is cut with an ellipsis, and a grid whose cells would fall under 4px wide widens past Width rather than clipping. Each cell takes one of four strengths of one palette Color (primary by default), from the lowest value to the highest, and its <title> reads "Row, Col: value", with Format writing the value. A NaN cell draws empty and says "no data"; a grid with no reading renders the empty state. ShowLegend adds a "Less … More" strip. Column labels thin out when they would overlap. No script.
  • barchart: framework/ui.BarChart, categorical SVG bar chart. Legible by default: value labels ride above every bar cap (opt out with HideValues), the y-scale rounds up to a clean maximum so uniform / near-equal data keeps visible headroom (no wall of full-height slabs), a hairline baseline grounds the bars, and long ShowLabels category labels wrap onto two lines (a single over-long word ellipsizes, full text preserved in the bar's <title>). FitHeight sizes the SVG to hug the tallest bar (a 96px cap) instead of the fixed Height, so no blank band pads the space above the caps — bar ratios stay identical, only the padding goes. ShowAxis adds left value-axis ticks + gridlines. Per-bar Color accepts a palette token (primary/info/success/warning/danger), a registered status variant name, or a hex/rgb/hsl/oklch/var() CSS color; any other value falls back to the theme primary. Axes and FitHeight remain finite for finite readings, including subnormal values and values near the float64 limit. When padded headroom cannot be represented, the axis uses the data maximum.
  • linechart: framework/ui.LineChart, multi-series time-series chart with area + legend. Edge x-axis labels anchor inward so the first/last tick don't clip against the SVG boundary. Small Height values expand to fit the label and legend gutters plus at least an 8px plot, rather than clipping the plot above the viewBox. Legend entries wrap at Width, count Unicode runes rather than UTF-8 bytes, and reserve a row of height for each line.
  • codeblock: framework/ui.CodeBlock, styled <pre><code> sample block; the HighlightLines func pre-tokenizes lines for syntax highlighting, and the HighlightLines []LineRange field (fence {1,3-5}), Diff, HighlightWords, and Wrap add line bands, diff marking, word marks, and soft wrapping — see CodeBlock: line highlighting, diffs, wrapping
  • codetabs: framework/ui.CodeTabs, the same snippet in several languages (Go / TypeScript / curl …) behind a zero-JS tab strip; pure composition of headless.Tabs + CodeBlock with copy buttons. Selection is per-tabset, not a page-wide language preference. The SDK docs site (framework/sdkdocs) is the flagship consumer.
  • counter: framework/ui.Counter, numeric counter with +/− buttons mutating a client-side signal
  • jsonviewer: framework/ui.JSONViewer, collapsible tree of arbitrary values
  • diffviewer: framework/ui.DiffViewer, unified or split diff renderer. File headers are recognized outside hunk ranges only, so removed -- comments and added ++ expressions remain changed code in either mode; consecutive files may carry their own header pairs. A final line terminator creates no extra row; explicit blank context lines remain visible. LF and CRLF patches render identically: one trailing carriage return per physical line is removed, never carriage returns inside its content. Pass Ctx for localized split-column defaults (i18nui.DiffOld / DiffNew, keys ui.diff.old / ui.diff.new); nil falls back to English, and explicit LeftLabel / RightLabel override translations.
  • markdown: framework/ui.Markdown, themed wrapper over core/markdown. Measure: true caps the line length at a reading width (--ui-markdown-measure, 72ch) for long-form pages in a wide column; generated markdown blocks set it. Fence options reach it through the data-meta attribute core/markdown emits and forward onto CodeBlockConfig: title=, showLineNumbers, scroll, {1,3-5} / highlight=, diff, words=, and wrap (see the CodeBlock section for the table). Unknown options are ignored, so an option added later degrades to a plain block rather than breaking one, and the raw info string stays on the block root in data-meta
  • factbox: framework/ui.FactBox, single labelled fact (compact label + value pair; label-first or value-first)
  • detaillist: framework/ui.DetailList, label/value description list for record detail views; stacks labels above values in narrow containers, including desktop context cards, and on phones; Stacked draws a read-only value in a form, label above and the value boxed like a control; Spread draws compact side-column facts, label at the start and value at the end, a long first value (an id) truncating ahead of a copy button
  • progress: framework/ui.Progress, native <progress> with theme styling; determinate (Value 0..Max, clamped at render) or indeterminate (Value < 0), labelled visibly (ShowLabel) or through aria-label. A named-stage walk is ProgressSteps, a different component
  • terminalblock: framework/ui.TerminalBlock, terminal transcript with a labelled header and TerminalOut / TerminalOK / TerminalErr lines
  • skeleton: framework/ui.SkeletonCard / SkeletonRow / SkeletonAvatar / SkeletonTimeline / SkeletonLine, loading placeholders over framework/headless.Skeleton: hidden shimmer bars, one polite "Loading…" announcement per preset; their own stylesheet visually hides that accessible announcement even without the host's app.css floor, so it occupies no grid cell; the timeline preset draws event-shaped rows (dot, name line, two text lines) matching what ui.Timeline arrivals look like, and SkeletonLine draws ONE capped bar — the loading twin of a breadcrumb trail or a one-line label (an area's Loading.Show, a crumbs skeleton)
  • spinner: framework/ui.Spinner, inline CSS loading indicator

Tags, badges, filters

  • tag: framework/ui.Tag, interactive pill (linked / removable / status-variant)
  • statstrip: framework/ui.StatStrip, one frame around one to six figures divided by hairlines, a dashboard's headline numbers as one band. Each Cells entry is the caller's markup (a poll region holding a StatCard with Plain, which drops the card's own frame); two columns on a narrow screen, an odd last figure spanning the row. Label names the group
  • statuspill: framework/ui.StatusPill, compact status pill with optional leading dot (neutral / accent tone)
  • statusbadge: framework/ui.StatusBadge, small inline pill conveying state (success / warning / danger / info / neutral); Dot draws a filled circle in the label's tone before the word, the status-column shape, sized by --ui-badge-dot-size (0.375rem)
  • filtertoolbar: framework/ui.FilterToolbar, the filter/sort control strip above a list (facet <select> or radio-pill groups + search + sort + Apply/Reset), a single URL-driven GET form; wraps → stacks responsively so nothing clips on mobile. Dropdown: true moves everything but the search into a Filters dropdown, Extra adds controls of the same form, Applied adds to the Filters badge, and Tools draws links and link menus at the row's end
  • filterchipbar: framework/ui.FilterChipBar, role=toolbar of removable filter chips; a chip's × POSTs its DismissBody to DismissPath and the bar swaps in the response (the × is a link to DismissPath without script). ClearAllPath and SegmentedControl's RPCPath must pass the anchor URL policy and start with a single /; unsafe or cross-origin paths panic at render instead of leaving a dead RPC or an executable fallback link.
  • shortid: framework/ui.ShortID, a long identifier (a UUID, a job id, a hash) in a table cell: its first Length characters (default 8) in monospace with an ellipsis, the full value as their title, and an inline copy button that copies all of it. Screen readers hear the full value once, from a visually hidden copy the button targets. A value no longer than Length shows whole
  • copybutton: framework/ui.CopyButton, clipboard button with SR-announced confirmation; IconOnly with Icon draws a registered icon ("copy", "link", "arrow-up-right") in place of the glyph, and a labelled button with Icon draws it before the label; Inline draws it quiet (no border or fill until hover, a 24px target) for a copy control inside a line of text; Ghost draws it like a small ghost icon Button, for a row of icon buttons
  • drawerbar: framework/ui.DrawerBar, the bar across the top of an intercepted drawer: a close button (data-cui-intercept-close), the layer's path in mono, a copy-link button when CopyURL is set, an open-as-page link (data-cui-intercept-page) when PageURL is set, then Actions. Prev and Next draw up and down icon links after the path, each marked data-cui-intercept-swap so the target renders in the same drawer; an empty one draws disabled, and both empty draw neither. It sticks to the layer's top edge while the layer scrolls. Draw it when app.OverlayFromContext reports a drawer
  • toolbar: framework/ui.Toolbar, role=toolbar wrapper for grouped actions

Status & banners

The OptimizedImage and PipelineImage showcase API links target framework/ui, where the components live; their image processing helpers remain in the separate framework/image package.

The OptimizedImage gallery uses an in-memory PNG dashboard mockup with matching intrinsic dimensions. It needs no separate static image asset.

The LanguageMenu gallery shows disabled language choices because the docs site does not register locale-prefixed pages. A live ui.LanguageMenu reads the host's real locale configuration from its request context.

The docs site mounts the sidebar drawer on both the Sidebar and SidebarDrawerTrigger showcase pages, including their mobile trigger previews.

The Form, FormRepeater, Repeater and ConditionalField gallery previews submit to the docs site's no-op POST endpoint. They demonstrate native form submission, not sign-in or persistent row mutations; use example values rather than real credentials.

The FilterChipBar gallery is a static preview: its dismiss and Clear all controls use the docs site's no-op POST endpoint rather than a GET-only page. Hosts supply their own state-changing endpoints and bound response region.

The gallery's Tag dismissal is a live island on /components/tag. gallery.TagDemo(dismissed) renders its seed or dismissed state; the docs site serves that fragment on POST and reads ?dismissed=beta for no-script GETs. Bind the initial region once with interactive.BindHTML; replacement fragments do not repeat the response-signal binding inside that region.

  • themetoggle: framework/ui.ThemeToggle, dark/light/auto toggle that persists color-scheme mode; fresh scaffolds mount the adaptive framework/ui/theme.Default() palette, while app-owned themes must keep DarkColors complete; Fill stretches the pill across its container, the options sharing the width
  • themepicker: framework/ui.ThemePicker, switches the whole page between the app theme and registered overrides (style.RegisterThemeOverride) by putting the override class on <html>; the choice persists per browser (see theming → "Page themes"); Fill stretches its pill as ThemeToggle's does
  • backtotop: framework/ui.BackToTop, fixed scroll affordance that appears after a threshold
  • banner: framework/ui.Banner, page-level persistent status strip
  • callout: framework/ui.Callout, persistent inline info / warning / danger / neutral block
  • notification: framework/ui.Notification, toast-styled inline notification (variant + dismiss)
  • emptystate: framework/ui.EmptyState, centered title + description + optional CTA for no-data screens; Compact draws one muted line in a small panel, for an empty list inside another screen
  • signout: framework/ui.SignOut, logout control: minimal form POSTing to the auth sign-out endpoint; compatible with auth.WithBFFPosture, whose logout handler enforces same-origin submission
  • pollingindicator: framework/ui.PollingIndicator, pulsing dot + label confirming a polling RPC is firing
  • seo: core-ui/seo + uihost.WithSitemap / WithRobots + ScreenCanonical / ScreenHreflangs / ScreenSchema, per-page SEO + sitewide sitemap.xml / robots.txt
  • seo-bundle: ScreenSEO(ctx) returning an SEO struct, per-screen bundle of description + canonical + hreflangs + robots + OG + Twitter Card + JSON-LD in one declaration; alternative to the per-method calls above

Marketing & page sections

  • hero: framework/ui.Hero, centered landing hero (eyebrow + title + subtitle + actions + optional media); Lede takes inline markup in place of Subtitle, Footer holds content under the actions (an install line, a byline, stats)
  • herosplit: framework/ui.HeroSplit, two-column hero (copy + media) with equal / copy-wide / media-wide ratios; name the section with AriaLabel, or with HeadingID (the id of the h1 in Copy, wired as aria-labelledby): a heading inside a section does not name it
  • pricingcard: framework/ui.PricingCard, plan tile (price + period + feature list + CTA), optional featured highlight; cards placed directly in a ui.Grid that holds only plans share their row lines, so prices and feature lists start level across a row; a card in a wrapper cell or a mixed grid keeps its own rows
  • authcard: framework/ui.AuthCard, centered card shell for login / register / reset forms (title + alert + notice + body + footer)

A sidebar group has Children and no Href: its label opens and closes the group. Put the section's overview page in the group's first child link instead. Setting both Children and Href panics with this fix.

SidebarConfig.CurrentPath already splits who decides the active item: set it and the server renders the highlight, leave it empty and the runtime stamps it after hydration. Collapse does the same for the collapsible rail.

The zero value (SidebarCollapseAuto) keeps the localStorage behaviour: the runtime restores the collapsed state after hydration and persists every toggle. SidebarCollapseCollapsed and SidebarCollapseExpanded make the server own the state: the collapsed rail (or expanded column) ships in the SSR bytes as data-collapsed on the root, the toggle button renders aria-expanded="false" plus the expand label when collapsed, and the runtime neither reads nor writes localStorage for that sidebar.

Use it when the collapse preference is per-user server data — a setting restored from the database that must survive first paint on a device whose localStorage says otherwise:

16 lines
// pref is the signed-in user's stored sidebar preference.
collapse := ui.SidebarCollapseAuto
switch pref.Sidebar {
case "collapsed":
    collapse = ui.SidebarCollapseCollapsed
case "expanded":
    collapse = ui.SidebarCollapseExpanded
}
sbCfg := ui.SidebarConfig{
    Title:         "myapp",
    Variant:       ui.SidebarCollapsible,
    Collapse:      collapse,
    CollapseLabel: "Collapse sidebar",
    ExpandLabel:   "Expand sidebar",
    Items:         items,
}

Three more knobs round out the contract surface:

  • CollapseLabel / ExpandLabel rename the toggle button. The button carries the expand label and aria-expanded="false" while collapsed, the collapse label and aria-expanded="true" while expanded; both names ride along as data attributes so client-side toggles keep the custom wording. Defaults: "Collapse navigation" / "Expand navigation".
  • GroupMarkup: ui.SidebarGroupButton renders groups (items with Children) as button[aria-expanded][aria-controls] plus a container that carries hidden when closed, instead of the default <details><summary>. The sidebar runtime module toggles both on click. For hosts whose specs pin that markup shape. Two <details> behaviours do not carry over: group open state is not persisted across navigation (every swap resets a group to its server-rendered state; the details dialect carries data-hui-disclosure-persist), and the dialect needs JavaScript — without the runtime module a closed group's links are unreachable, while <details> opens natively with JS off.
  • Variant: ui.SidebarAutoHide renders the persistent column as a 64px icon rail at >= md viewports and reveals the full column on hover or keyboard focus (:focus-within), straight from the component's stylesheet — no JavaScript, no host CSS.
  • Prepend is a component rendered between the title and the <nav> on every body path: the inline column, SidebarBody, and the MountSidebar drawer. A docs site whose phone header hides its section tabs puts the section <select> here, so the drawer (the only navigation at that width) carries it without forking the mount. It takes a component.Component rather than HTML because MountSidebar runs once at boot while the drawer body renders per request: a Prepend that implements component.ContextComponent sees the request on the inline and drawer paths alike, so the select can mark the current section. Wrap static markup in app.NewStaticComponent. It hides with the title in the collapsed rail and the auto-hide rest state; Footer stays below the nav.
  • ui.MountSidebarFunc(r, build) mounts the drawer from a builder instead of a value: the drawer body calls build(ctx) on every chrome request, so a sidebar the layout already builds per request (ui.Sidebar(build(ctx)), a footer with the signed-in user's ui.SignOut) shows the same footer and items in the phone drawer. MountSidebar(r, cfg) renders cfg as given, so a config built once at boot with context.Background() gives every phone user the anonymous footer. The drawer's name, variant and header brand come from build(context.Background()) at mount time.
  • The mounted drawer is a role="dialog" named "Navigation" in the request's locale (the ui.sidebar.drawer phrase, i18nui.SidebarDrawerLabel): aria-labelledby points at a hidden span with the id <DrawerName>-label in the drawer header, rendered whether or not the header shows a brand, so a screen reader never announces a bare "dialog".
  • Count ends a link's row in a small muted figure (render.Text("12")); the collapsed rail hides it. In a kept shell, hand in an inline route area (app.AreaSpec{Inline: true}, l.RouteArea) so the figure re-renders with each client navigation; a plain value keeps the figure the page loaded with.
  • MatchPath rides the rendered link as data-cui-match-prefix (the value, not the href, is the prefix): the server marks the item current on first paint, and the runtime's active-link sweep keeps it lit across client navigations into the section — without it the exact-href sweep clears the highlight the moment the URL grows a deeper segment inside a kept shell. Both sides compare the bare path (/customers/ and /customers are one section), and MatchPath: "/" lights only the root page, never every page.
  • SuppressDrawerTrigger: true plus ui.SidebarDrawerTrigger(cfg) moves the hamburger into your own chrome (the page header, left of the brand): the standalone button is the same trigger — same class, same data-cui-open widget contract, same >= md self-hiding from the component's stylesheet. MountSidebar still mounts the drawer itself, once, as always.
  • NativeMobile: true adds a native disclosure for browsers with scripting disabled. CSS selects it with (scripting: none); scripted browsers keep the mounted drawer and its focus and Escape behavior. Keep calling MountSidebar. The fallback uses the same filtered items and group markup.

Filter toolbars: the URL-driven pattern

Set a distinct FilterToolbarConfig.ID for each toolbar on the same page. That ID namespaces derived search, select, sort and pill IDs and their labels and shortcut targets, without changing submitted field names. Without an explicit toolbar ID, pass the page's shared Ctx ID scope to deduplicate controls across renders (render.WithIDScope for standalone hosts).

ui.FilterToolbar is the control strip that sits above a DataTable or card grid on a list screen. It renders one <form method="GET"> whose controls carry the current filter/sort/search state. Submitting it (Apply) navigates to <action>?facet=value&sort=…&q=…; the screen's Load(ctx) reads those params and renders the filtered list server-side. Refresh, share, and back-button all reduce to "same URL → same view" with no client state. This is the "URL params are the source of truth" contract from core-ui/ARCHITECTURE.md. Reset is a plain link back to the bare action, so it clears every param with zero JavaScript. It works with the runtime disabled. ClientNavigation: true opts submit into client navigation through data-cui-spa; its successful controls become the destination URL query. Use this when the filtered content belongs to the screen's replaced cell. Leave it false when a kept layout also renders query-dependent content, so the native GET reloads that layout too; Reset then carries data-cui-nav="off", so it reloads the layout as well instead of letting the client router swap only the screen cell. With ClientNavigation: true the router takes Reset too. Client navigation honors the clicked submit button's name/value and form overrides and asks the existing leave guard before discarding unsaved edits.

Facets render as a native <select> (default) or, per Kind: FacetPills, a wrapping radio-pill group (short, glanceable choices). The toolbar is responsive by construction: it declares itself a container and lays its controls out with flex-wrap, degrading row → wrapped rows → single-column stack as its own width shrinks (correct even inside a slim sidebar on a wide screen). Every control, including Apply/Reset, stays on-screen and tappable; nothing overflows a narrow ancestor, and pill labels never wrap mid-label ("Waiting On Customer" stays one line).

28 lines
// Screen.Render: the toolbar reflects the current URL state.
func (s *CustomersScreen) Render() render.HTML {
    return ui.Stack(ui.StackConfig{Gap: ui.GapLG},
        ui.FilterToolbar(ui.FilterToolbarConfig{
            Action: "/customers", // the list route (the form GETs here)
            Facets: []ui.Facet{
                {Name: "status", Label: "Status", Value: s.status, Options: []ui.FacetOption{
                    {Label: "Open", Value: "open"}, {Label: "Closed", Value: "closed"},
                }},
                {Name: "plan", Label: "Plan", Kind: ui.FacetPills, Value: s.plan, Options: []ui.FacetOption{
                    {Label: "Free", Value: "free"}, {Label: "Pro", Value: "pro"},
                }},
            },
            Search:    &ui.FilterSearch{Name: "q", Value: s.query, Placeholder: "Search customers…"},
            Sort:      []ui.SortOption{{Label: "Newest", Value: "created_desc"}, {Label: "Name A–Z", Value: "name_asc"}},
            SortValue: s.sort,
        }),
        ui.DataTable(/* … rows filtered per s.status / s.plan / s.query / s.sort … */),
    )
}

// Screen.Load: read the URL params the toolbar submits.
func (s *CustomersScreen) Load(ctx context.Context) error {
    q := app.QueryFromContext(ctx)
    s.status, s.plan = q.Get("status"), q.Get("plan")
    s.query, s.sort = q.Get("q"), q.Get("sort")
    return nil // fetch + filter rows from s.* here
}

An empty facet value (the auto-prepended "All" choice) submits status=; the server treats an empty param as "no filter". Pair with FilterChipBar below the toolbar to show the active filters as removable chips.

Dropdown: true is the compact layout a dense list wants: the row is the search field (--ui-filter-toolbar-search-width, 22.5rem), then a Filters button, then the Tools. The facets, the sort, the Extra controls and the one Apply/Reset pair move into the button's ui.Dropdown panel, and its badge counts the facets set plus Applied (the filters an Extra control holds, such as a typed filter expression). Everything stays in the one form, so Enter in the search submits the panel's fields whether the panel is open or not. With nothing for the panel there is no Filters button and no Apply; the search submits on Enter. Tools are links and link menus (a Columns menu, a layout switch) drawn at the row's end; they render inside the form, so a tool holding a <form> panics. --ui-filter-toolbar-panel-width sets the panel's minimum width. Set Compact: true with Dropdown: true to keep search, the filter trigger, and tools on one row, including on phones. Search takes the remaining width.

SearchFields integration. When the entity declares SearchFields, FilterSearch{Name: "q"} maps 1:1 onto the list endpoint: the ?q= param the toolbar emits is exactly what the auto-CRUD List handler free-text-searches. No manual wiring needed: the server-side search is automatic. Without SearchFields, the ?q= param is ignored by the CRUD layer and the screen must filter rows itself (the pre-existing "wired manually" behaviour).


CodeBlock: line highlighting, diffs, wrapping

ui.Markdown forwards every fence option it recognizes onto CodeBlockConfig, so a documentation fence can highlight lines, mark a diff, wrap long lines, and cap its height without leaving Markdown:

Fence optionMaps toEffect
title="main.go" / title=main.go (filename= too)Filenamechrome header with the filename
showLineNumbers (showLineNumbers=false off)LineNumbersleft line-number gutter
scrollScrollcaps body height at --ui-code-block-scroll-max, scrolls
{1,3-5} or highlight=1,3-5HighlightLines []LineRangebackground band on those lines (1-based, inclusive)
diffDifflines starting + (and +++) get an added band, - (and ---) a removed band; the marker stays in the text
words="err,nil" / words=err,nilHighlightWords []stringliteral matches wrapped in <mark class="fui-code-block__mark">
wrap (nowrap / wrap=false off)Wrapsoft-wrap long lines instead of horizontal scrolling

Unknown options are ignored, and so is an invalid highlight= or words= spec: a fence carrying options for another tool degrades to a plain block rather than a broken one. The raw info string stays addressable on the rendered block's data-meta attribute. A fence whose language is diff gets no diff marking; only the diff option does, because a highlighter may legitimately know the diff grammar.

All of these are also plain CodeBlockConfig fields for direct callers: ParseLineRanges("1,3-5") builds the HighlightLines value, and the same features work on the Lines (pre-highlighted) path, where diff classification skips a leading token span and word marks stay inside text nodes so they never split a caller's markup.

This exact fence renders live on the docs site:

checkout.go
7 lines
func total(items []Item) int {
	sum := 0
	for _, it := range items {
		sum += it.Price
	}
	return sum
}

And the diff form, header lines and all:

api.go
8 lines
--- a/api.go
+++ b/api.go
@@ handler @@
 func get(w, r) {
-	user := load(r)
+	user := mustAuth(r)
 	render(w, user)
 }

Line bands derive from the theme's status colours (--color-primary for highlight, --color-success/--color-danger for added/removed, --color-warning for word marks) and are tunable through --ui-code-block-highlight-bg, --ui-code-block-added-bg, --ui-code-block-removed-bg, and --ui-code-block-mark-bg. A word mark keeps its token's color, so its tint stays at 12% (every token color holds 4.5:1 on it) and a --color-warning underline carries the emphasis.

Syntax highlighting

ui.HighlightLines(code, lang) is the kit's highlighter: a small single-pass scanner that returns one render.HTML per source line, ready for CodeBlockConfig.Lines. ui.Markdown fences and ui.CodeTabs samples go through it. It emits the kit's token classes (tk-kw, tk-fn, tk-str, tk-num, tk-com, tk-type, tk-pn), which the CodeBlock sheet colors from the theme's Code slots (--tk-*, dark values in Theme.DarkCode; see theming).

Language keyWhat it classes
go, golangkeywords and builtins, predeclared types, call sites, and named types in type position: after type, an exported composite literal (Pong{), behind * or [], package-qualified or not (*http.Request)
js, ts, jsx, tsx, javascript, typescriptkeywords, a few built-in types, call sites, object keys ({ method: …}, not a ternary's a ? b : c)
sql, postgres, sqlitekeywords and column types, case-insensitive; -- and /* */ comments
sh, bash, shell, consolekeywords, the command word of each line or pipeline stage (after an optional $ prompt and its space), # comments at a word boundary
yaml, ymlmapping keys (at line start, after an optional - list marker), true/false/null, # comments; an apostrophe in an unquoted value opens no string
jsonkeys (tk-fn) apart from string values (tk-str), true/false/null
any other non-empty keycomments, strings and numbers
"", text, plainescaped text, no tokens

Hand-tokenized lines work too: pass <span class="tk-…"> spans you escaped yourself as Lines, and they draw in the same palette.

Go source is tab-indented, so the block renders a tab four columns wide (--ui-code-block-tab-size, default 4; the browser's 8 pushed nested code off a hero-width panel). The line-number gutter draws in a mix of the code text and code surface colors (--ui-code-block-gutter-color), so it recedes on a dark or a light code surface alike.

Common mistake: writing the line numbers as highlight={2}. The braces are the bare form; highlight= takes the bare list. Both {2,5-7} and highlight=2,5-7 work, {2,5-7} after highlight= does not.


Adding a new component: checklist

The framework's drift tests catch most of these; this list is a helpful pre-flight read for human reviewers.

  1. Implementation: framework/ui/<name>.go.
  2. Theme-token CSS only: register your own RegisterStyle; use var(--color-*, fallback) etc. Never style it from an app: the docs site ships no stylesheet of its own and composes the kit as is.
  3. Unit tests: <name>_test.go exercising panic paths + emitted markup + variant classes.
  4. /components/<slug> screen in examples/site/: register in main.go, add an entry to componentCatalog in components.go. The catalog drives the site's page-level test loops (axe, single-<main>, …), so an unregistered page is an untested page.
  5. Chromedp e2e in examples/site/e2e_new_components_test.go or e2e_new_components_interactions_test.go, ARIA shape for static components, real interaction (click / type / drag) for runtime-driven ones.
  6. core-ui/ARCHITECTURE.md: any new data-cui-* attribute the runtime reads must land in the table here OR in the drift-test whitelist (with a justification comment). The TestRuntimeAttrsAreDocumented gate in core-ui/runtime/internal/treegate/attrdoc_test.go enforces it.
  7. Axe: TestAxe_AllPagesAreClean runs axe-core against every catalog page and fails on any violation. The most common authoring mistakes it catches: missing tap target floor (44×44), role/aria-allowed-role mismatches, color-contrast on tinted backgrounds, scrollable regions without tabindex="0".
  8. Composition first: before writing a new runtime module, see if preset.Modal / preset.Popover / preset.Drawer + data-cui-open + data-cui-deeplink + signal-binding already covers the case. Lightbox and NotificationBell each ship without a runtime module by composing existing primitives.
  9. Behaviour registers like style: when a module is warranted, it lives in your package, embedded beside the Go, and registers with its markers: registry.RegisterBehavior("<name>", js, registry.Markers("[data-<prefix>-x]")). It may also registry.Requires("action") (or any embedded module or registered behaviour): the loader has the requirement registered before the module's script runs, on every load path. The host serves it at /__gofastr/runtime/<name>.js, the kernel scans the markers, and the module loads once when one appears. Keep the module contract (window.__gofastr.loadedModules[name] = true on attach, a scanner under window.__gofastr._moduleScanners[name]), bind by attribute only, and use your own data- prefix: a data-cui-* marker is admitted only when the attribute is already documented. The module is a runtime module and the runtime's source lints hold it: const and let, never var; no selector or storage key built from a raw value; rest parameters and spread, never arguments, .push.apply or Array.prototype.<m>.call; a class body, never an assignment to <Ctor>.prototype (core-ui/check, found through your //go:embed). See core-ui/ARCHITECTURE.md "Component behaviour".

Icons

framework/ui/icons exposes 291 vendored drawings as string constants: all 287 Feather icons plus Lucide's chevrons-up-down, receipt, flame, and diamond. ui.Icon renders them inline with no JavaScript or image request. Each drawing uses a 24 by 24 viewBox, a two-unit stroke, and rounded caps and joins. The stroke inherits the surrounding text color. The live /components/icon page shows each drawing as a centered 24px icon above its name. Click anywhere on a tile to copy the name. The copied confirmation appears in that tile. Its sticky header is a single row with search, a Filters dropdown for categories, and an icon submit button. The result count scrolls above it. Each drawing has one of 19 categories. Submit Search to combine the controls; Clear removes both. Search matches words in names and aliases, or category labels, ignoring case and treating hyphens and underscores as spaces. The q and category URL parameters survive refresh, sharing, and browser history.

ui.CopyButton with Tile: true draws this full-width layout, with its Icon above Label. It retains the usual Target element ID, accessible name, keyboard handling, and announced confirmation. Tile icons render at 24px; the native title exposes the full label when it is visually truncated. --ui-copy-btn-tile-min-height controls the tile's minimum height, defaulting to 6rem. Tile cannot be combined with IconOnly, Inline, or Ghost; those combinations panic. Supply the target text in a separate element, as with any other CopyButton.

11 lines
package example

import (
    "github.com/DonaldMurillo/gofastr/core/render"
    "github.com/DonaldMurillo/gofastr/framework/ui"
    "github.com/DonaldMurillo/gofastr/framework/ui/icons"
)

func SearchGlyph() render.HTML {
    return ui.Icon(icons.Search, ui.IconConfig{Size: "20"})
}

icons.Categories() returns a caller-owned slice of Category{ID, Label} values in display order. IDs are the filter values. icons.CategoryFor(name) returns (Category, bool), resolves aliases, and returns false for unknown names. Category assignments live in categories.go, independently of the unmodified upstream artwork.

To embed the same browser, call gallery.RenderIconCatalog(gallery.IconCatalogConfig{Action: "/icons", Search: q, Category: category}) from framework/gallery. Action is the required GET route. Read q and category from that route's request on each render. Empty or unknown category IDs select all categories. An empty result shows a message with the controls still available. The default gallery demo uses /components/icon.

icons.Names() lists every canonical name. The Go constants use PascalCase, such as icons.ArrowLeft, icons.Search, and icons.Receipt. Alias constants work the same way, including icons.Card and icons.PanelLeft. The gallery searches aliases and shows canonical names on the tiles. Existing string names remain valid.

The aliases are alert-octagon to danger, alert-triangle to warning, card to credit-card, chart to bar-chart, edit-2 to edit, fire to flame, more to more-horizontal, panel-left to sidebar, pencil to edit, sliders to settings, success to check, trash-2 to trash, and x to close. These are catalog aliases. An application override through ui.RegisterIcon applies only to the exact registered name, so overriding a canonical name does not change its aliases.

APIBehavior
ui.Icon(name, cfg)Renders one SVG. Unknown names return empty markup.
ui.IconRegistered(name)Reports whether a built-in, alias, or app registration exists.
ui.RegisterIcon(name, body)Adds or replaces an exact name with trusted inner SVG markup. Safe for concurrent use. Never pass user-supplied SVG.
icons.Names()Returns a caller-owned, sorted slice of canonical names; excludes aliases and app registrations.
icons.Aliases()Returns a caller-owned map of alternate names to canonical names. Alias targets are always canonical drawings.
icons.Lookup(name)Returns trusted built-in inner SVG markup and a boolean. Includes aliases; ignores app registrations. Custom renderers must supply the shared wrapper attributes.
ui.IconConfig fieldDefaultBehavior
Size"20"Width and height, as a numeric value or CSS length such as "1.25rem".
AriaLabelemptyEmpty makes the SVG decorative with aria-hidden="true". A label sets role="img" and aria-label.
IDemptyID on the SVG root.
ClassemptyAdditional classes beside fui-icon.
ExtraAttrsnilExtra root attributes. Owned drawing and accessibility attributes, class, id, style, and data-cui-* are filtered.

ui.LinkButtonConfig.Icon accepts the same names and renders the icon before the visible label at 18 pixels. Leave that icon decorative. A standalone meaningful icon needs AriaLabel; an icon-only control needs its accessible name on the control itself.

App registrations take precedence over built-ins for the exact name. Overriding check does not replace success. Registrations contain inner geometry only, without an outer <svg>. The shared renderer supplies the wrapper.

The SVG files are unmodified copies from Feather commit 3dc050d and Lucide commit 7faf876. The initial 20 Feather files were retrieved October 24, 2026 at 22:26:54 UTC; the remaining files were retrieved October 25, 2026 at 05:59:29 UTC. Feather's MIT license and Lucide's ISC license with its retained MIT notice are copied verbatim into LICENSE.feather and LICENSE.lucide in the icon package, the generated Go header, and THIRD_PARTY_NOTICES.md. Include the relevant upstream notices when redistributing bundled artwork or generated drawing data.

framework/ui/icons/sources.json records the upstream commit, original file path, pinned download URL, local path, SHA-256, and modifications for every SVG, plus each file's source ID and retrieval timestamp. Each source has its own repository, commit, retrieval time and license record. Aliases map to canonical names without duplicating drawing files. Local names map to upstream names: close uses x, danger uses alert-octagon, edit uses edit-2, settings uses sliders, trash uses trash-2, and warning uses alert-triangle. The upstream edit, settings, and trash variants are available as edit-square, settings-gear, and trash-simple, preserving the original 20 local names. Coordinates are unchanged. Generation extracts inner geometry and normalizes XML serialization for the shared SVG wrapper.

To update the set, vendor files from a full upstream commit SHA and update the manifest, license, and source documentation. Run go generate ./framework/ui/icons from the repository root. Generation is offline and verifies the recorded checksums and provenance before writing Go data. It rejects unsupported markup, scripts, links, styles, inconsistent drawing attributes, and Go identifier collisions with another icon or a handwritten declaration. Commit the sources, manifest, notices, and icons_gen.go together. Do not redraw vendored SVGs; register custom artwork through ui.RegisterIcon with its own provenance.

The site's header/search/GitHub glyphs and shared framework outline helpers render through the catalog. A source-level test checks literal icon references in the site, embedded app specs, gallery, and framework components. It also refuses private site registrations and inline site SVGs. Charts, app artwork, and filled rating shapes are separate component drawing code.

Common mistake: assuming any icon name exists. Check icons.Names() or ui.IconRegistered before accepting a dynamic name. A typo renders no icon.

Common mistakes

  • Writing a new runtime module when composition already covers it. Check preset.Modal / preset.Popover / preset.Drawer + data-cui-open + data-cui-deeplink + signal binding first. NotificationBell ships with zero new JS by composing them; the Lightbox composed the same primitives and then earned a module for the behaviour composition cannot give (gallery stepping, pinch-zoom). New modules are the expensive path (budget, tests, docs).
  • Styling a component from an app. The docs site (examples/site) ships no stylesheet beyond its owned header, footer and docs-page sheets, and those never reach into a kit component. A component owns its CSS via registry.RegisterStyle with theme tokens, so it works in every host, not just the demo site.
  • Hardcoding colors/spacing instead of theme tokens. Use {colors.*} / {spacing.*} / var(--color-*, fallback) so themed hosts and dark mode don't break your component.
  • Skipping the demo page + e2e pairing. Every /components/<slug> page ships with at least one chromedp test and a package unit test (suite convention), and TestAxe_AllPagesAreClean automatically fails the build on any axe-core violation for every catalog page. Register the page and you've signed up for all three.
  • Adding a data-cui-* attribute without documenting it. The runtime contract lives in core-ui/ARCHITECTURE.md; every attribute the runtime reads must be in its table (or an explicitly justified whitelist) before the change lands.

See also