Custom components
The built-in components are a starting point. Sooner or later you will want a button with different padding, or a component the set does not have at all. Howdy UI gives you two ways to get there, and both end with ordinary Gleam files in your own project that you are free to change.
Copying a built-in component
Howdy UI does not have options for every detail of every component. When one is not quite right, copy its source into your project and edit it. The package comes with a command line for this:
gleam run -m howdy/ui list
button Buttons, and the theme toggle built on them.
heading Headings.
typography Paragraphs, secondary text and links.
input Form fields.
layout Containers, cards, stacks and rows.
Copy one or more by name:
gleam run -m howdy/ui add button layout
That writes src/<your app>/ui/button.gleam and layout.gleam. They are
the same source the package ships, with a header noting the version they
came from. The files are yours. Import your copy where you would have used
the built-in:
import my_app/ui/button.{Primary}
button.button(Primary, [], [text("Save")])
The copies import lustre and sketch themselves, so both must be direct
dependencies of your project. add tells you if one is missing:
gleam add sketch
Two options change what add does:
-
--to=<dir>writes the copies somewhere other thansrc/<app>/ui. -
--forceoverwrites a copy you have edited. Without it,addleaves an edited copy alone and shows you how it differs.
Keeping up with the package
A copy does not change when you upgrade Howdy UI, which is the point: an upgrade cannot alter how your site looks. To see what you are missing, or just what you have changed, compare a copy with the version the package ships:
gleam run -m howdy/ui diff button
The output is a diff in the usual style. Lines starting - are only in
your copy, and lines starting + are only in the package's version. Bring
across by hand whatever you want from it.
Writing a component from scratch
A component is a function that returns an element with a class on it. The class is written with Sketch, which is CSS as Gleam functions:
// 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.padding_(tokens.space_1 <> " " <> tokens.space_2),
css.property("border-radius", tokens.radius_small),
css.font_size_("0.75rem"),
css.font_weight("600"),
])
}
There are three parts, and the built-in components are made the same way:
-
badge_classdescribes the styles.css.classtakes a list of declarations. Most CSS properties have a function, andcss.propertycovers any that do not. -
badgebuilds the element.classfromhowdy/ui/styleattaches the class and registers its CSS, so that pages and live views include it.ui.classis the same function, if you already importhowdy/ui. -
classeslists every class the module uses. It is what lets the styles be written to a file for publishing.
Use it like any other component:
ui.row([], [ui.h2("Orders"), badge.badge("New")])
Use tokens, never colours
The badge never says what colour it is. It uses tokens from
howdy/ui/theme/tokens, which stand for whatever the current
theme says:
-
Colours:
background,surface,border,text,text_muted,primary,primary_hover,on_primary,danger,on_danger,focus. -
Fonts:
font_body,font_heading,font_mono. -
Radii:
radius_small,radius_medium,radius_large. -
Spacing:
space_1,space_2,space_3,space_4,space_6,space_8, running from0.25remto2rem.
A component built from tokens looks right under every theme, including ones
written after it. Write css.color("#2563eb") and the component is stuck
with that blue, in dark mode and in every theme a visitor picks.
Spacing tokens are a fixed scale, not part of the theme, so layouts stay the same size when the theme changes. Using the scale, not arbitrary numbers, is what keeps spacing looking even across a site.
States and variants
Sketch has functions for the states of an element, each taking the declarations that apply in that state:
css.class([
css.color(tokens.text_muted),
css.hover([css.color(tokens.text)]),
css.focus_visible([css.outline("2px solid " <> tokens.focus)]),
css.disabled([css.property("opacity", "0.5")]),
])
For a component with variants, take a custom type and build the class from it, as the built-in button does:
pub type Tone {
Neutral
Alert
}
pub fn badge_class(tone: Tone) -> Class {
let #(background, color) = case tone {
Neutral -> #(tokens.surface, tokens.text)
Alert -> #(tokens.danger, tokens.on_danger)
}
css.class([css.background(background), css.color(color)])
}
pub fn classes() -> List(Class) {
[badge_class(Neutral), badge_class(Alert)]
}
List every variant in classes. A variant left out still works while you
develop, because its CSS is made when it is first used, but it would be
missing from the published file.
The all.gleam module
Whenever add runs, it also writes all.gleam in the same directory. It
gathers the classes of every module there that has a classes function:
pub fn classes() -> List(Class) {
list.flatten([
badge.classes(),
button.classes(),
])
}
Your own components are included, as long as they live in that directory
and have a classes function. Do not edit the file, since the next add
replaces it. Publishing uses this list.
add needs the name of at least one component, so to bring all.gleam up
to date after writing a component of your own, run it again for one you
have already copied. An edited copy is left as it is:
gleam run -m howdy/ui add button
How class names work
Sketch names each class after a hash of its contents, such as
css-7B0E1023. Two classes with the same declarations share a name, and
changing a declaration changes the name. You never write these names, and
should not rely on them in tests or in hand-written CSS. To style something
from your own stylesheet, add a class of your own with attribute.class
alongside the generated one.
