Typed, stateless UI widgets for Lustre applications. Built on top of Basecoat CSS (a pure-HTML port of shadcn/ui).
Widget vs Component — Lustre uses the word "component" for elements with their own runtime instance. Saola elements are called widgets to avoid confusion: they are plain view functions, no runtime state.
| Module | Shortcuts | Full API |
|---|---|---|
saola/accordion |
accordion_simple |
accordion_full |
saola/alert |
alert_default, alert_destructive |
alert_full |
saola/alert_dialog |
alert_dialog_simple |
alert_dialog_full |
saola/aspect_ratio |
aspect_ratio |
— |
saola/avatar |
avatar_initials, avatar_image |
avatar_full |
saola/badge |
badge_default, badge_secondary, badge_outline, badge_destructive |
— |
saola/breadcrumb |
breadcrumb_simple |
breadcrumb_full |
saola/button |
button_primary, button_secondary, button_outline, button_ghost, button_destructive, button_submit |
button_full |
saola/card |
card_simple |
card |
saola/checkbox |
checkbox_simple |
checkbox_full |
saola/collapsible |
collapsible_simple |
collapsible_full |
saola/command |
— | command_full, command_nav_up, command_nav_down, command_get_value_at, command_item_count |
saola/data_table |
data_table_simple |
data_table_full |
saola/dialog |
— | dialog_full |
saola/field |
field_simple |
field |
saola/hover_card |
hover_card_simple |
hover_card_full |
saola/input |
— | input_full |
saola/input_otp |
input_otp_simple |
input_otp_full |
saola/label |
label_for |
— |
saola/menubar |
menubar_simple |
menubar_full |
saola/multiselect |
multiselect_simple |
multiselect_full |
saola/pagination |
pagination_simple |
pagination_full |
saola/popover |
popover_simple |
popover_full |
saola/progress |
progress_simple |
progress_full |
saola/radio_group |
radio_group_simple |
radio_group_full |
saola/rating |
rating_readonly, rating_interactive |
rating_full |
saola/resizable |
resizable_simple |
resizable_full |
saola/scroll_area |
scroll_area_simple |
scroll_area_full |
saola/search |
search_simple, search_clearable |
search_full |
saola/select |
select_simple |
select_full |
saola/separator |
separator, separator_vertical |
— |
saola/sheet |
sheet_simple |
sheet_full |
saola/sidebar |
sidebar_simple |
sidebar_full |
saola/skeleton |
skeleton_text, skeleton_circle |
skeleton |
saola/slider |
slider_simple |
slider_full |
saola/switch |
switch_simple |
switch_full |
saola/table |
table_simple |
— |
saola/tabs |
tabs_simple |
— |
saola/textarea |
— | textarea_full |
saola/toast |
new_toast (factory) |
toaster (container) |
saola/toggle |
toggle_simple |
toggle_full |
saola/button_group |
button_group_simple |
button_group_full |
saola/calendar |
— | calendar_full |
saola/carousel |
carousel_simple |
carousel_full |
saola/combobox |
combobox_simple |
combobox_full |
saola/context_menu |
— | context_menu_full |
saola/date_picker |
— | date_picker_full |
saola/drawer |
drawer_simple |
drawer_full |
saola/empty |
empty_simple |
empty_full |
saola/input_group |
input_group_simple |
input_group_full |
saola/item |
item_simple, item_link |
item_full |
saola/native_select |
native_select_simple |
native_select_full |
saola/navigation_bar |
nav_bar_simple, nav_bar_link |
nav_bar_full |
saola/navigation_menu |
navigation_menu_simple |
navigation_menu_full |
saola/spinner |
spinner_simple |
spinner_full |
saola/stepper |
stepper_simple |
stepper_full |
saola/theme |
— | apply_to_html, watch_system_dark, get_system_dark |
saola/time_picker |
time_picker_simple |
time_picker_full |
saola/timeline |
timeline_simple |
timeline_full |
saola/toggle_group |
toggle_group_simple |
toggle_group_full |
saola/tooltip |
tooltip, tooltip_side |
attr, side_attr |
saola/tree_view |
tree_view_simple |
tree_view_full |
These wrappers ship as custom elements (<script> required separately):
| Module | Custom element | Dependency |
|---|---|---|
saola/codemirror_editor |
<saola-codemirror-editor> |
CodeMirror 6 |
saola/monaco_editor |
<saola-monaco-editor> |
Monaco / VS Code |
saola/d3_bar_chart |
<saola-d3-bar-chart> |
D3.js v7 |
Theme variants: theme.Light (default) | theme.Dark | theme.System (follows OS preference).
Use apply_to_html as a Lustre Effect when the theme changes, because it needs to update the <html> class outside the area where the Lustre app mounts. Pair it with watch_system_dark to track OS preference changes:
import saola/theme
// In init — apply initial theme and subscribe to OS preference changes
fn init(_flags) -> #(Model, lustre.Effect(Msg)) {
#(
Model(theme: theme.Light, system_os_dark: theme.get_system_dark(), ...),
effect.batch([
theme.watch_system_dark(True, SystemOsDarkChanged),
theme.apply_to_html(theme.Light, theme.get_system_dark()),
]),
)
}
// In update — apply theme on change
ThemeToggled(t) -> #(Model(..model, theme: t), theme.apply_to_html(t, model.system_os_dark))
SystemOsDarkChanged(is_dark) -> #(Model(..model, system_os_dark: is_dark), theme.apply_to_html(model.theme, is_dark))Add this script to <head> to avoid flash-of-wrong-theme on load:
<script>
if (window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.add('dark')
</script>apply_to_html explicitly calls classList.add/remove('dark') on <html>, so it always overrides whatever the inline script set — no conflict.
If the theme is fixed at startup and never changes at runtime, apply_to_html is still the preferred way to sync the root theme class.
Use saola/field to wrap any input with label, description, hint, and error:
import saola/field
import saola/input
field.field(
field.FieldAttrs(
label: "Email", description: "", error: "",
orientation: field.Vertical, required: True, hint: "",
),
input.input_full(input.Email, option.Some(input.SyncValue(model.email)),
on_input: option.Some(EmailChanged),
extra_attrs: input.InputExtraAttrs(..input.default_extra_attrs, placeholder: "you@example.com"),
),
)To wire formal validation results into FieldAttrs:
// Add `formal` to your app's [dependencies], then:
fn field_from_result(result: Result(String, String), attrs: field.FieldAttrs) -> field.FieldAttrs {
case result {
Ok(_) -> field.FieldAttrs(..attrs, error: "")
Error(e) -> field.FieldAttrs(..attrs, error: e)
}
}- Stateless — every widget is a pure
fn ... -> Element(msg). The consumer'sModelowns all state. - External-state duality — form widgets accept
InitValue(v)(seed once) orSyncValue(v)(keep in sync with model). - Two-tier API — each widget exposes a
_simpleshortcut for the common case and a_fullfunction for complete control. - Typed, not stringly typed — variants (
ButtonVariant,BadgeVariant, …) are Gleam custom types, not magic strings.
just preview- Clone the repo (includes
external/basecoatsubmodule — rungit submodule update --init). - Run
gleam testto verify all tests pass. - The live preview app lives in
dev/saola/preview/; add a showcase page for any new widget. - Follow the rules in
CLAUDE.mdfor widget API conventions.
To update the bundled Basecoat CSS after pulling submodule changes:
cd external/basecoat && bun run build
cp external/basecoat/packages/css/dist/basecoat.cdn.css assets/basecoat.cssThe basecoat submodule used to be placed under "dev", but "dev" is one of the directories that Gleam will scan for source code
to build, so basecoat deep directory tree will waste Gleam attempt.
Apache-2.0