howdy_ui

Pages, themes, live server components and browser islands for howdy, built on Lustre and Sketch.

gleam add howdy_ui

It is a separate package so that howdy itself stays free of Lustre and Sketch. Apps that only serve JSON never pull them in.

Pages

howdy/ui/page renders a Lustre element as a full HTML document. The head carries the theme variables, base styles and the CSS for every component in the body.

import howdy/cookie
import howdy/ui
import howdy/ui/page
import lustre/element.{text}

controller.new("/")
|> controller.get("/", fn(ctx) {
  use theme <- cookie.string_or(ctx, "theme", default: "system")

  page.new("Orders")
  |> page.theme(theme)
  |> page.body([
    ui.container([], [
      ui.h1("Orders"),
      ui.p([text("Nothing yet.")]),
    ]),
  ])
  |> page.respond(ctx)
})

page.theme sets the data-theme attribute on the root element. A name the page does not offer, such as the "system" default above, is ignored and the browser’s colour scheme preference decides. That means the first paint is already in the right theme; no flash.

Use page.render or page.to_string to get the document without a response, and page.head to add your own elements to the head.

Serving the CSS as a file

Embedding needs no extra route and is fine for development. To serve the CSS as one file instead, mount the development route and link it:

howdy.new()
|> howdy.controller(pages())
|> howdy.controller(ui.stylesheet(at: "/assets/ui.css", themes: theme.default_themes()))
page.new("Orders")
|> page.stylesheet(at: "/assets/ui.css")

The route serves every class registered so far with an ETag and cache-control: no-cache, so browsers revalidate once per page load and see new classes as soon as they exist. It is meant for development: it only knows classes that have been rendered on that node.

To publish, write a static file from a known list of classes. See Publishing.

Themes

A theme is a record in howdy/ui/theme. Every component reads its colours, fonts and radii through CSS variables defined by the theme, so changing a theme changes every element.

import howdy/ui/theme.{Colors}

pub fn brand() -> theme.Theme {
  theme.light()
  |> theme.named("brand")
  |> theme.colors(fn(c) { Colors(..c, primary: "#0f766e", on_primary: "#fff") })
}

page.new("Orders")
|> page.themes(theme.themes(default: brand(), alternatives: [theme.dark()]))

Adding a field to Colors, Font or Radius is a compile error until every theme supplies it. A test in this package checks every token in howdy/ui/theme/tokens is defined by every built-in theme.

Presets

howdy/ui/themes has five presets, each a light and a dark theme: zinc and stone (neutral and warm greys with a near-black primary), and rose, green and violet. Every text colour in every theme, built-in or preset, meets WCAG AA contrast against the surfaces it sits on, and focus rings 3:1 against the page; a test checks it. Chart colours are checked for colour-vision-deficiency separation against each theme’s surfaces.

import howdy/ui/themes/violet

page.new("Orders")
|> page.themes(violet.themes())

gleam run -m howdy/ui add violet copies one to adjust.

ui.theme_toggle renders a button that switches the document between two named themes in place and stores the choice in a theme cookie:

ui.theme_toggle([text("Toggle theme")], from: "light", to: "dark")

Components

Each component lives in its own module under howdy/ui, and howdy/ui re-exports them. Every one is a Sketch class built from tokens.

ModuleComponents
heading, typographyh1–h4, p, muted, link, prose (styles plain elements such as rendered Markdown, in three sizes)
buttonbutton in Primary, Secondary, Outline, Ghost, Link and Danger; sized_button in ExtraSmall, Small, Medium, Large, Icon, IconExtraSmall, IconSmall and IconLarge; submit_button for one that submits its form (don’t pass type="submit" to button: a page and a live view resolve two types differently); theme_toggle
inputinput, textarea, native_select, label
fieldfield, field_description, field_error, fieldset
checkboxcheckbox, radio, choice (a control with its label), radio_group
layoutcontainer, stack, row, separator
cardcard (or card.sized for Compact), card_header, card_title, card_description, card_action, card_content, card_footer
badgebadge in Primary, Secondary, Outline and Danger
alertalert in Info and Danger, alert_title, alert_description
tabletable, table_caption, table_header, table_body, table_footer, table_row, table_head, table_cell
loadingskeleton, spinner
dialogdialog, alert_dialog, sheet, dialog_trigger, dialog_close, dialog_header, dialog_title, dialog_description, dialog_footer
drawerdrawer, drawer_trigger: a bottom panel dragged by its handle, with drawer.snap_points
directiondirection for a right-to-left part of a page; page.direction for the whole page
popoverpopover, popover_trigger, popover_close
tooltiptooltip, tooltip_trigger
menumenu, menu_trigger, menu_item, menu_link, menu_checkbox_item, menu_radio_group, menu_radio_item, menu_submenu, menu_label, menu_separator
tabstabs, tab; tabs.styled for Vertical and the underlined Line look
accordionaccordion, accordion_item, collapsible
selectselect, select_item, select_group (a styled list; native_select is the browser’s own)
toasttoast_region, toast in Info, Success, Loading and Danger, toast_title, toast_description, toast_close; toast.duration, toast.persistent; toast.queue for a live view’s model: the browser reports when a toast fades or is held open, so cleanup never removes one being read; toast.flashed(words, messages) and toast.push_flashed for howdy/flash messages
bellbell.new(id:, source:) built up with unread, inbox, push(at:), without_toasts and words, shown with view: a notification bell whose count and panel follow howdy_notifications’ inbox live, showing new notifications as toasts
sidebarsidebar_layout, sidebar, sidebar_trigger, sidebar_header, sidebar_content, sidebar_footer, sidebar_group, sidebar_link, sidebar_button; sidebar.styled_layout for a Rail or Floating/Inset sidebar, sidebar.icon_link, sidebar.submenu
paginationpagination; pagination.live_pagination for live views
commandcommand, command_group, command_item, command_link, command_empty, command_dialog (with a ⌘K shortcut), combobox, multiple_combobox (chips, one form field per value; live.on_values in a live view), combobox_option
calendarcalendar.new(...) built up with selected, range, multiple, months, today, disabled, sunday_first, locale, navigation and name, shown with view or as a date picker
data_tabledata_table.new(columns, rows) with sort, selectable, hide, empty and caption; columns_menu
chartchart.bar, line, area, pie, donut, radial and radar, shown with view
avataravatar with initials behind the picture, avatar_initials
progressprogress, progress_indeterminate
effectsscroll_fade, shimmer
chatchat_conversation, chat_message, chat_bubble, chat_note; remember, start_at, on_older (load history as the reader scrolls back, keeping their place), styled_bubble, tinted_note, status, jump, reactions, reaction
attachmentattachment (uploading, processing, failed or done, with a thumbnail and actions), attachment_group; attachment.styled for Compact and Tile
kbdkbd, shortcut
button_groupbutton_group
toggletoggle, toggle_group (single or multiple)
switch, sliderswitch, slider (the browser’s own controls, themed); slider.range with two thumbs, slider.thumbs with any number, either way up; slider.vertical
input_groupinput_group, input_group_input, input_group_addon; input_group.textarea, block_start, block_end
input_otpinput_otp: one input drawn as a box per character; input_otp.grouped for 1234-5678
aspect_ratio, scroll_areaaspect_ratio, scroll_area
resizableresizable_group, resizable_panel, resizable_handle; resizable.minimum, collapsible, remember
context_menucontext_menu_area, context_menu, with menu_items
menubarmenubar, menubar_button, with menus
hover_cardhover_card, hover_card_trigger
breadcrumbbreadcrumb, breadcrumb_link, breadcrumb_page, breadcrumb_ellipsis
navigation_menunavigation_menu, navigation_link, navigation_panel, navigation_panel_link
carouselcarousel, carousel_slide; carousel.styled for Vertical and looping, carousel.autoplay (with a pause button, still for reduced motion), carousel.on_change
itemitem_group, item, item_link
emptyempty
questionnairequestionnaire.questions asked one at a time with back, skip and next, checked as they go; works as a plain form or in a live view
uploaderuploader.new(name:, start:) built up with multiple, accept, max_size, required, layout, stored, id and words, shown with view: drop or choose files, which the browser sends straight to storage with progress, posting each file’s key; file_size, describe
rich_textrich_text.new(name:) built up with value, placeholder, tools, mentions(trigger:, kind:, search:), attachments(start:, url:), accept, max_size, required, disabled, invalid, id and words, shown with view: a ProseMirror editor posting its document as JSON; field, not_empty, max_length and suggestions_to_json on the server. howdy/ui/rich_text/document decodes, stores and draws documents. Mount ui.rich_text()

calendar, data_table, chart, uploader, rich_text and bell are builders with several options, so use them from their own modules rather than through howdy/ui.

Controls follow their native state. attribute.disabled(True) dims them and attribute.aria_invalid("true") gives them the danger colour, so a form is wired for assistive technology and styled by the same attributes:

ui.field([], [
  ui.label([attribute.for("email")], [text("Email")]),
  ui.input([
    attribute.id("email"),
    attribute.aria_invalid("true"),
    attribute.aria_describedby("email-error"),
  ]),
  ui.field_error([attribute.id("email-error")], [text("Enter an email address.")]),
])

Words and languages

Everything the components say, from a pagination’s “Previous” to a toast’s close button, is in one howdy/ui/words.Words record. Components that are plain functions take it as their first argument. Those built step by step take it with a words step, and speak English without one:

let w = words.translated(i18n.new(locale.to_string(l)))  // or words.english()

ui.pagination(w, current: 3, total: 12, href: page_href)
data_table.new(columns, rows) |> data_table.words(w)
rich_text.new(name: "body") |> rich_text.words(w)
bell.new(id: "inbox", source: "/notifications") |> bell.words(w)
calendar.new("due", year: 2026, month: 10) |> calendar.locale(w.calendar)

The rich text editor’s words are a RichTextWords record in the rich_text field; the bell’s are fields of Words itself, such as unread and mark_all_read.

translated covers Arabic, Chinese, Dutch, English, French, German, Italian, Japanese, Polish, Portuguese and Spanish. Numbers, month and weekday names, and the first day of the week come from CLDR for 46 locales. The words outside English were machine translated and await review. Change any of them with a record update: Words(..words.english(), dismiss: "Close"). For right-to-left languages, set page.direction(direction.of(l)).

Interactive components

Dialogs, popovers, menus, tooltips, tabs, accordions and selects are built on what the browser already does: <dialog> opened by an invoker command, the popover API, CSS anchor positioning and <details>. The browser keeps focus inside a modal, closes things on Escape or an outside click, puts floating panels above everything else and returns focus afterwards.

A trigger and what it opens are tied by id. The trigger functions return attributes, so any button can open one:

ui.button(Outline, ui.menu_trigger("account"), [text("Account")]),
ui.menu("account", [], [
  ui.menu_item([event.on_click(OpenProfile)], [text("Profile")]),
  ui.menu_separator(),
  ui.menu_link("/sign-out", [], [text("Sign out")]),
])

howdy/ui/behaviour is a small script for what the browser does not do: arrow keys and typeahead in menus and selects, arrow keys between tabs, tooltips on hover and focus, and fallbacks for browsers without invoker commands or anchor positioning. Every page includes it. It listens on the document and follows events into live views’ shadow roots, so a widget inside a live view works without a round trip to the server.

The browser owns whether something is open and which tab or option is selected. A live view that wants to know listens for the native events: close on a dialog, toggle on a popover or accordion item, change on a select’s hidden input, or click on a tab or menu item. Keep a trigger and its target in the same tree: both in the page, or both in one live view.

A live view hears the value of a select, combobox or calendar with live.on_value, on any element around the control:

html.div([live.on_value("status", FilterStatus)], [
  ui.combobox(words: w, id: "status", name: "status", ...),
])

Charts

Charts are drawn on the server: lines, areas and gridlines as SVG, and text, dots, columns and readouts as HTML over it, so type stays the same size at any width. Series take the theme’s chart colours, chart_1 to chart_5, in order; the built-in themes’ five pass colour-vision-deficiency separation checks against their own surfaces. Hovering over or focusing a label shows every series’ value there, and “Show data” opens the numbers as a table, so no value depends on colour or a mouse. None of it needs a script.

Blocks

Blocks are whole screens and cards built from the components: howdy/ui/blocks/app_shell (a sidebar, a top bar and the page), stat_card, sign_in and sign_up. Use them from the package, or copy one with add to make it your own; see below.

examples/gallery builds an application from them: a dashboard with charts and a live orders table, a live chat whose replies stream in, and sign-in and sign-up screens with server-side validation.

Chat

howdy/ui/chat lays a conversation out from the bottom, so it stays on the newest message as messages arrive or a reply streams in, and keeps its place when you scroll back to read; the browser does this, with no script. It is a log for screen readers. Pair it with avatar, attachment and shimmer for a reply being written.

The gallery

howdy/ui/gallery is a browsable reference of everything here: each component, block and theme preset, its examples drawn beside the code that drew them, the command that copies it, and its full source. It is a development tool, not part of the package’s API: it is an internal module, left out of the documentation, and it reads package sources from disk. Mount it in development only:

howdy.new()
|> howdy.controller(gallery.controller(at: "/ui"))

A menu redraws the gallery in any theme preset, and a toggle switches light and dark.

Making a component your own

Copy a component into your project and edit it, the way shadcn does:

gleam run -m howdy/ui init --theme=zinc
gleam run -m howdy/ui list
gleam run -m howdy/ui add button layout

Options: --to=<dir> puts copies elsewhere, --force overwrites copies you have edited, --dry-run shows what would change without changing anything, and --install runs gleam add for any package the copies need that the project lacks.

add regenerates all.gleam, which lists the classes of every module in the directory that has a classes function. Hand that list to the export when publishing:

export.new(theme.default_themes())
|> export.classes(my_app/ui/all.classes())

Registries

The catalogue is data: gleam run -m howdy/ui registry writes it to registry/, as index.json, one <name>.json per entry holding its source and dependencies, and an llms.txt index for tools and coding assistants.

Publish entries of your own the same way, serve the directory, and install from it by URL or path:

gleam run -m howdy/ui add invoice_table --registry=https://ui.example.com/r
gleam run -m howdy/ui list --registry=./shared/ui-registry

An entry in another registry may depend on built-in ones, which are copied alongside it and its imports pointed at them.

A registry URL must be https://: what it serves is code that runs in your project, so it is not fetched over a connection that could be altered in transit. Pass --insecure to accept that for a plain http:// registry, such as one on your own machine.

Writing one from scratch

Keep the classes in a module with a classes function, build each from howdy/ui/theme/tokens, and attach it with ui.class. Never name a colour directly; use a token so the element follows the theme.

// src/my_app/ui/badge.gleam
import howdy/ui/style.{class}
import howdy/ui/theme/tokens
import lustre/element.{type Element, text}
import lustre/element/html
import sketch/css.{type Class}

pub fn badge(content: String) -> Element(msg) {
  html.span([class(badge_class())], [text(content)])
}

pub fn classes() -> List(Class) {
  [badge_class()]
}

pub fn badge_class() -> Class {
  css.class([
    css.background(tokens.primary),
    css.color(tokens.on_primary),
    css.property("border-radius", tokens.radius_small),
  ])
}

Put it in the same directory as your copied components and the next add includes it in all.gleam.

Class names are content hashes, computed in the calling process. The CSS for each distinct class is rendered once and kept in an ETS table owned by a supervised process in the howdy_ui application. Gleam starts it before running your entry point; direct Erlang callers also start it automatically on first cache access. After startup, rendering reads and writes ETS directly without waiting on the owner process. Pages and live views include the CSS for you. Anywhere else, put ui.styles() in the document after the elements it styles.

Stopping the application clears the cache. If the owner crashes, its supervisor restarts it with an empty cache and subsequent renders register their classes again. A render in progress during that crash can fail; recovery does not replay it automatically. Startup failures are reported rather than leaving callers waiting for an owner that never started.

Live components

A Lustre server component keeps its init, update and view on the server. The browser runs a thin client that sends events up a WebSocket and applies DOM patches that come back. howdy/ui/live turns one into a howdy route:

import howdy/ui/live

controller.new("/counter")
|> controller.get("/", fn(ctx) { live.serve(ctx, counter.app(), with: 0) })

and mounts it in a page:

page.new("Counter")
|> page.live
|> page.body([live.mount("/counter")])
|> page.respond(ctx)

page.live includes the Lustre client runtime. The socket route goes through guards and middleware like any other, so authentication works the same as for howdy/websocket: read a cookie or query parameter, since browsers cannot set handshake headers.

The component renders into a shadow root, so its view is wrapped to carry a <style> node with the CSS for exactly the classes it used. It is styled whether the page embeds, links the development route, or links a published file, and it needs no setup. Theme variables are inherited from the page, so a live view switches theme with the rest of the document.

Live links

A live link moves between live pages without reloading. Mount one live.outlet in place of a live.mount, and link with live.link:

fn layout(ctx, mount mount: String) {
  page.new("Shop")
  |> page.live
  |> page.body([
    live.link(to: "/", mount: "/live/home", children: [text("Home")]),
    live.link(to: "/orders", mount: "/live/orders", children: [text("Orders")]),
    live.outlet(mount),
  ])
  |> page.respond(ctx)
}

controller.new("/")
|> controller.get("/", fn(ctx) { layout(ctx, mount: "/live/home") })
|> controller.get("/orders", fn(ctx) { layout(ctx, mount: "/live/orders") })

A click connects the outlet to the link’s socket route and puts href in the address bar. The old view stays on screen until the new one arrives, and everything outside the outlet, including other live components, is left alone. Back and forward swap the outlet too.

page.live includes the script. If you render the document yourself, add live.script() after server_component.script().

In howdy/testing there is no socket to upgrade, so a live route answers 426. Test the app’s init, update and view directly, or start a runtime with live.start and register your own subject as the client, as test/live_test.gleam does.

See examples/live for a page with a private and a shared counter, and live links between two views.

Islands

An island is a Lustre app rendered on the server and then run in the browser, for interaction that needs no server: no socket, no round trip. Put it in a module in a directory called islands, with a function returning an Island:

// src/my_app/islands/counter.gleam
pub fn island() -> Island(Int, Int, Msg) {
  island.simple(init:, update:, view:, flags: decode.int, encode: json.int)
}

and mount it by passing that function and its flags:

page.body([island.mount(counter.island, with: 5)])

The server renders view(init(5)) into the page; the browser loads the module and takes over the same elements. Mount ui.islands() in the app to serve the browser code. In development it rebuilds the islands for JavaScript when a page loads them, under gleam run as well as gleam dev, and shows compile errors on the page. Island modules, and the app modules they import, must run in a browser.

See examples/islands.

Publishing

For a published site, write the CSS once as a static file, minified, from the built-in components and the classes in your own all.gleam. Add a script module and run it whenever the styles change:

// src/tasks/css.gleam
import howdy/ui/export
import howdy/ui/theme
import my_app/ui/all

pub fn main() {
  let assert Ok(Nil) =
    export.new(theme.default_themes())
    |> export.classes(all.classes())
    |> export.write(to: "priv/static/ui.css")
}
gleam run -m tasks/css

The output is deterministic for the same ordered class list, independent of which pages were rendered beforehand. Built-in classes come first, followed by classes in the order passed to export.classes; duplicate classes keep their first position. Choose this order deliberately because equally specific rules later in the stylesheet win. Export uses an isolated stylesheet and does not populate the runtime registry.

CSS a Sketch class cannot carry, such as the @keyframes behind spinners, skeletons and toasts, is registered by name with style.rule when the component is drawn and written once per document by pages, live views and the development stylesheet. The built-in rules are exported after the classes; a copied or custom component that registers its own passes them with export.rules(my_component.rules()).

Minification preserves descendant selectors such as div :hover. CSS containing escapes, comments or URLs is left unchanged rather than risking a tokenization change; such exports may retain whitespace.

Serve the file with howdy/static and a long cache lifetime, and link it with whatever cache-busting you use for other assets:

howdy.controller(
  static.new(from: "priv/static")
  |> static.at("/assets")
  |> static.max_age(seconds: 31_536_000)
  |> static.build,
)
page.stylesheet(page, at: "/assets/ui.css?v=" <> my_app.version)

Live components carry their own CSS, so they are unaffected by what the file contains.

Developing

This package lives in the howdy repository and depends on howdy by path, so a change to both lands in one commit. Before publishing, replace the path dependency in gleam.toml with a version range, since gleam publish rejects path dependencies.

cd ui
gleam test

The interactive behaviour is tested in a real browser. With the gallery running (cd examples/gallery && gleam run), drive Chromium through its DevTools protocol; it needs Node 22 or later and nothing else:

node ui/browser_test/run.mjs          # every test
node ui/browser_test/run.mjs drawer   # the ones whose name contains "drawer"

Most tests open a component’s gallery preview. The live ones use the gallery’s /lab page, a live view that shows the server’s view of each component beside it, and /survey, a questionnaire posted as a plain form.

✨ Search Document