Howdy

Components

Howdy UI comes with more than fifty ready-made components: text, buttons, form controls, cards, tables, charts, dialogs, menus, tabs and more. Each is a function that returns a Lustre element, already styled, that follows whatever theme the page is using.

To see them, browse the component gallery. Every component, block and theme preset has a page there with its examples drawn live, the code that drew each one, the command that copies it into your project, and its full source.

Using a component

Import howdy/ui, which re-exports every component, and call them like any other Lustre element:

import howdy/ui
import howdy/ui/button.{Outline, Primary}
import lustre/element.{text}

ui.card([], [
  ui.card_header([], [
    ui.card_title([text("Delete this order?")]),
    ui.card_description([text("This cannot be undone.")]),
  ]),
  ui.card_footer([], [
    ui.button(Outline, [], [text("Cancel")]),
    ui.button(Primary, [], [text("Delete")]),
  ]),
])

Render them in a page or in the view of a live component. Both include the CSS for the components they draw, so there is nothing else to set up.

How they are called

Components follow Lustre's conventions, so they mix freely with elements from lustre/element/html:

  • A component that holds other elements takes a list of attributes and a list of children, as in ui.card([], [...]).
  • A component that only holds text takes a String, as in ui.h1("Orders") or ui.muted("Updated two minutes ago").
  • A component that comes in several looks takes the variant first, as in ui.button(Primary, [], [...]) or ui.badge(Secondary, [], [...]). The variants live in the component's own module, such as howdy/ui/button.
  • Attributes come from lustre/attribute and events from lustre/event. Whatever you pass is added to the element beside its styling.
import lustre/attribute
import lustre/event

ui.card([attribute.id("summary")], [ui.h2("Summary")])
ui.button(Primary, [event.on_click(Save)], [text("Save")])

Each component also has its own module under howdy/ui, such as howdy/ui/button or howdy/ui/card. Its page in the gallery names the module. A few components with many options, calendar, data_table and chart, are builders used from their own modules only.

What there is

The gallery groups them the same way:

  • Typography: headings ui.h1 to ui.h4, paragraphs, muted text, links, prose for rendered Markdown, and keyboard keys.
  • Actions: buttons, button groups and toggles.
  • Forms: inputs and text areas, fields with hints and errors, checkboxes and radios, switches, sliders, one-time code inputs, selects, comboboxes and command menus, calendars and date pickers, and questionnaires that ask one question at a time.
  • Layout: containers, stacks and rows, cards, an application sidebar, scroll areas, resizable panels and right-to-left sections.
  • Overlays: dialogs and sheets, drawers, popovers, tooltips, menus, context menus, menubars and hover cards.
  • Navigation: tabs, accordions, pagination, breadcrumbs and navigation menus.
  • Data display: tables, data tables, charts, badges, avatars, carousels and item lists.
  • Feedback: progress bars, empty states, alerts, toasts, skeletons and spinners.
  • Chat: a conversation that stays on the newest message, and file attachments.

Buttons

ui.button takes a variant, attributes and children. The variants are in howdy/ui/button:

  • Primary is the main action, filled with the primary colour. Aim for one per view.
  • Secondary is a supporting action, filled with the muted colour.
  • Outline is a supporting action, outlined.
  • Ghost is a quiet action, such as one in a toolbar, filled only when hovered.
  • Link is an action that looks like a link.
  • Danger is a destructive action, filled with the danger colour.

ui.sized_button takes a size as well: ExtraSmall, Small, Medium or Large, or Icon, IconExtraSmall, IconSmall or IconLarge for a square button holding one icon. Give an icon button an aria-label.

import howdy/ui/button.{Danger, Ghost, Icon, Primary}

ui.button(Primary, [event.on_click(Save)], [text("Save")])
ui.button(Danger, [attribute.disabled(True)], [text("Delete")])
ui.sized_button(Ghost, Icon, [attribute.aria_label("Close")], [text("×")])

A button does nothing on its own. That is what you want in a live view, where event.on_click does the work. To submit an HTML form, use ui.submit_button:

ui.submit_button(Primary, [], [text("Sign up")])

Do not pass attribute.type_("submit") to ui.button instead. The button would have two types, which a page and a live view resolve differently.

See the button page for every variant and size.

Forms

ui.field groups a label, a control and the text that explains or corrects it:

html.form([attribute.method("post"), attribute.action("/signups")], [
  ui.stack([], [
    ui.field([], [
      ui.label([attribute.for("email")], [text("Email address")]),
      ui.input([
        attribute.id("email"),
        attribute.name("email"),
        attribute.type_("email"),
        attribute.aria_describedby("email-hint"),
      ]),
      ui.field_description([attribute.id("email-hint")], [
        text("We never share it."),
      ]),
    ]),
    ui.submit_button(Primary, [], [text("Sign up")]),
  ]),
])

Always pair a control with a label, joined by matching for and id. The label is what a screen reader announces, and clicking it focuses the control.

Controls are styled by their own state. attribute.disabled(True) dims them, and attribute.aria_invalid("true") gives them the danger colour, so the attributes that tell assistive technology about an error also show it:

ui.field([], [
  ui.label([attribute.for("email")], [text("Email address")]),
  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."),
  ]),
])

The other controls work the same way: ui.textarea, ui.checkbox, ui.radio and ui.choice (a control with its label), ui.switch, ui.slider, ui.select and ui.combobox. Forms covers reading and validating what the form sends.

Layout

  • ui.container is a centred column, at most 960 pixels wide, with padding at the edges. Wrap the content of a page in one.
  • ui.stack places its children in a column with an even gap.
  • ui.row places its children in a line with a gap, centred vertically, wrapping when they run out of room.
  • ui.separator is a dividing line.
  • ui.card is a raised surface for related content, with card_header, card_title, card_description, card_action, card_content and card_footer for its parts.
ui.container([], [
  ui.stack([], [
    ui.h1("Dashboard"),
    ui.row([], [
      ui.card([], [ui.h3("Orders"), ui.p([text("128")])]),
      ui.card([], [ui.h3("Customers"), ui.p([text("64")])]),
    ]),
  ]),
])

Use stack and row for spacing rather than adding margins to the things inside them. The gap belongs to the layout, so components stay reusable and spacing stays even.

For a whole application, ui.sidebar_layout puts a collapsible sidebar beside the page.

Interactive components

Dialogs, popovers, menus, tooltips, tabs, accordions and selects are built on what the browser already does: the <dialog> element, the popover API and <details>. The browser keeps focus inside a modal, closes things on Escape or a click outside, and puts focus back afterwards.

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

import howdy/ui/button.{Outline, Primary}

html.div([], [
  ui.button(Outline, ui.dialog_trigger("rename"), [text("Rename")]),
  ui.dialog("rename", [], [
    ui.dialog_header([
      ui.dialog_title("rename", [text("Rename project")]),
    ]),
    ui.input([attribute.aria_label("Name")]),
    ui.dialog_footer([
      ui.button(Outline, ui.dialog_close("rename"), [text("Cancel")]),
      ui.button(Primary, ui.dialog_close("rename"), [text("Save")]),
    ]),
  ]),
])

Menus work the same way, with ui.menu_trigger and ui.menu:

html.div([], [
  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")]),
  ]),
])

A small script, howdy/ui/behaviour, adds what the browser does not do: arrow keys and typeahead in menus and selects, arrow keys between tabs, and tooltips on hover and focus. Every page includes it, and it works inside live views too, without a round trip to the server.

The browser keeps track of what is open and what is selected. A live view that wants to know listens for the native events: close on a dialog, toggle on a popover, change on a select, or click on a tab or menu item. Keep a trigger and what it opens in the same place: both in the page, or both in one live view.

Builders

calendar, data_table and chart have many options, so they are built up in a pipeline and drawn with view. Use them from their own modules:

import gleam/int
import gleam/string
import howdy/ui/data_table

let columns = [
  data_table.column("id", "Invoice", fn(invoice) { text(invoice.id) }),
  data_table.column("customer", "Customer", fn(invoice) {
    text(invoice.customer)
  })
    |> data_table.sortable(fn(a, b) { string.compare(a.customer, b.customer) }),
  data_table.column("amount", "Amount", fn(invoice) {
    text(int.to_string(invoice.amount))
  })
    |> data_table.sortable(fn(a, b) { int.compare(a.amount, b.amount) })
    |> data_table.numeric,
]

data_table.new(columns, invoices)
|> data_table.empty([text("No invoices yet.")])
|> data_table.view

Sorting, selection and paging happen on the server: the table shows the rows it is given. The data table, calendar and chart pages show the rest of their options.

Charts are drawn on the server too, as SVG and HTML, and need no script. "Show data" opens the numbers as a table, so no value depends on colour or a mouse.

Blocks

Blocks are whole screens and cards built from the components: an application shell with a sidebar and top bar, a stat card, and sign-in and sign-up screens. Use them from howdy/ui/blocks, or copy one into your project to make it your own.

Styling another element

Each component's module exposes the class behind it, such as card.card_class(). Use one to give the same look to a different element:

import howdy/ui/card
import lustre/element/html

html.section([ui.class(card.card_class())], [ui.h2("Summary")])

Making them your own

Howdy UI does not have an option for every detail. When a component is not quite right, copy its source into your project and change it:

gleam run -m howdy/ui add button

Every page in the gallery shows the command for that component. Custom components covers copying, keeping copies up to date, and writing components of your own.