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 inui.h1("Orders")orui.muted("Updated two minutes ago"). -
A component that comes in several looks takes the variant first, as in
ui.button(Primary, [], [...])orui.badge(Secondary, [], [...]). The variants live in the component's own module, such ashowdy/ui/button. -
Attributes come from
lustre/attributeand events fromlustre/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.h1toui.h4, paragraphs, muted text, links,prosefor 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:
-
Primaryis the main action, filled with the primary colour. Aim for one per view. -
Secondaryis a supporting action, filled with the muted colour. -
Outlineis a supporting action, outlined. -
Ghostis a quiet action, such as one in a toolbar, filled only when hovered. -
Linkis an action that looks like a link. -
Dangeris 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.containeris a centred column, at most 960 pixels wide, with padding at the edges. Wrap the content of a page in one. -
ui.stackplaces its children in a column with an even gap. -
ui.rowplaces its children in a line with a gap, centred vertically, wrapping when they run out of room. -
ui.separatoris a dividing line. -
ui.cardis a raised surface for related content, withcard_header,card_title,card_description,card_action,card_contentandcard_footerfor 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.
