autumn_web::widgets has a set of small widgets. Each one renders native HTML with autumn-* classes. The stylesheet uses only the design tokens. Only the htmx states need JavaScript: htmx for an indicator, and autumn-widgets.js for a busy button.

WidgetElement
button<button> with a variant, a size, an icon and a loading state
button_grouprole="group" of buttons that touch
spinnerrole="status" ring with a visually hidden label
skeletonaria-hidden placeholder: line, block or circle
progressnative <progress>
meternative <meter>
separator<hr>, or a vertical role="separator"
kbd, kbd_combo<kbd>, and nested <kbd> for a key combination
empty_statetitle, text and action for a list with no data
avatar_groupavatars that overlap, with a +N item

A textarea can also grow with its text: see Auto-grow textarea.

The /_stories gallery shows each widget. See Widget Stories.

Button

Rust
use autumn_web::widgets::{ButtonConfig, ButtonSize, ButtonVariant, button};

let save = button("Save", &ButtonConfig::new().submit());
let delete = button(
    "Delete",
    &ButtonConfig::new()
        .variant(ButtonVariant::Destructive)
        .size(ButtonSize::Small),
);
  • Variants: Primary (the default), Secondary, Ghost, Destructive and Link.
  • Sizes: Small, Medium (the default) and Large. With a compact density, a small button is 24 px high: the WCAG 2.5.8 minimum.
  • Type: the default is type="button". Use .submit() or .kind(autumn_web::a11y::ButtonType::Reset).
  • Icon: .icon(markup) puts the icon before the label. The icon is aria-hidden.
  • Icon only: .icon_only() hides the label visually. The label stays in the button, so it is still the accessible name.
  • Attributes: .attrs(&[("hx-post", "/save")]) adds attributes. They pass through as given, hx-on* and style too: the app owns its CSP. The widget drops a malformed name and a second copy of a name.

The label is required. A debug build panics on an empty label. The same is true for the labels of the group, spinner, progress, meter and avatar group widgets, and for the empty-state title.

The classes also style a link from link_to_with and the button of button_to_with:

Rust
use autumn_web::links::{LinkToOptions, link_to_with};

let docs = link_to_with(
    "Read the docs",
    "/docs",
    &LinkToOptions::new().class("autumn-button autumn-button--secondary autumn-button--md"),
);

Loading state

There are two loading states:

  • .loading(): the server knows that work is in progress. The button gets aria-busy="true", disabled and a spinner.
  • .loading_on_request(): the button is busy during an htmx request. It gets data-autumn-busy and a hidden spinner.
Rust
use autumn_web::widgets::{ButtonConfig, button};

let save = button(
    "Save",
    &ButtonConfig::new()
        .loading_on_request()
        .attrs(&[("hx-post", "/posts/7"), ("hx-target", "#post")]),
);

The button is busy during its own request only:

  • The button sends the request.
  • The button submits the form that sends the request.
  • hx-indicator names the button, for example hx-indicator="#save". Set the id with .id("save").

htmx adds the htmx-request class to the element that sends a request, or to the elements that hx-indicator names. CSS shows the spinner while the button has that class. htmx 2 does not set aria-busy, so autumn-widgets.js sets aria-busy="true" on the busy button and on the swap target. On a form submitter, this aria-busy shows the spinner. The runtime removes only an aria-busy that it set. It removes it when the request ends, also after an error or an abort.

hx-indicator is inherited. If an element around the button sets it, htmx marks that element, not the button. The runtime still sets aria-busy on the button, so the spinner shows.

The spinner covers the label, so the width does not change. CSS hides the icon and the label with opacity: 0 only. They stay in the accessibility tree, so the button keeps its name.

aria-busy does not tell the user that the work is done. Put the result in a role="status" region that is on the page before the request, for example span id="save-status" role="status" {}.

To stop a second click, add hx-sync="this:drop". Do not use hx-disabled-elt="this": a disabled button loses the keyboard focus. A .loading() button is disabled. Thus a swap that puts one in place of the focused button moves the focus.

Button group

Rust
use autumn_web::widgets::{ButtonConfig, ButtonGroupConfig, ButtonVariant, button, button_group_with};

let secondary = ButtonConfig::new().variant(ButtonVariant::Secondary);
let zoom = button_group_with(
    "Zoom",
    &[button("Zoom in", &secondary), button("Zoom out", &secondary)],
    &ButtonGroupConfig::new().vertical(),
);

The label names the group (aria-label). It is required.

Spinner

Rust
use autumn_web::widgets::{SpinnerConfig, SpinnerSize, spinner, spinner_with};

let loading = spinner("Loading results");
let small = spinner_with("Saving", &SpinnerConfig::new().size(SpinnerSize::Small));

The label is the text that a screen reader reads. With prefers-reduced-motion: reduce, the ring does not turn. It fades in and out slowly. In forced colors mode, the ring uses system colors.

Do not put a spinner in a button. Use ButtonConfig::loading_on_request.

Skeleton

Rust
use autumn_web::widgets::{SkeletonConfig, SkeletonShape, skeleton, skeleton_with};

let avatar = skeleton(SkeletonShape::Circle);
let text = skeleton_with(SkeletonShape::Line, &SkeletonConfig::new().count(3));
  • Line fills the width. With more than one line, the last line is shorter. count is at most MAX_SKELETON_COUNT (64).
  • Block is for an image or a card.
  • Circle is avatar size. Circles go in a row.

A skeleton is aria-hidden. Tell screen readers about the wait in a different way, for example with a spinner. With prefers-reduced-motion: reduce, the skeleton does not pulse.

htmx indicators

A spinner or a skeleton can be an htmx indicator. .indicator(id) sets the id and the autumn-indicator class. The element is hidden until htmx gives it, or an element around it, the htmx-request class:

Rust
use autumn_web::widgets::{SkeletonConfig, SkeletonShape, skeleton_with};
use maud::html;

let page = html! {
    button type="button" hx-get="/posts" hx-target="#posts" hx-indicator="#posts-loading" {
        "Reload"
    }
    (skeleton_with(
        SkeletonShape::Line,
        &SkeletonConfig::new().count(3).indicator("posts-loading"),
    ))
    div id="posts" {}
};

The autumn-indicator class does not use the htmx indicator styles. Those styles are an inline <style>, which a strict CSP can block.

A spinner indicator keeps its role="status" element on the page and hides only its content. When the content shows, a screen reader reads the label. A skeleton indicator is hidden completely.

Progress and meter

Use progress for a task that completes. Use meter for a measurement in a known range.

Rust
use autumn_web::widgets::{MeterConfig, ProgressConfig, meter_with, progress, progress_with};

let upload = progress("Upload", Some(40.0), 100.0);
let loading = progress("Loading", None, 1.0); // indeterminate
let import = progress_with(
    "Import",
    Some(3.0),
    8.0,
    &ProgressConfig::new().visible_label().value_text("3 of 8 files"),
);
let disk = meter_with(
    "Disk use",
    7.0,
    0.0..=10.0,
    &MeterConfig::new().low(5.0).high(8.0).optimum(0.0).visible_label(),
);
  • The label is the accessible name (aria-label). It is required.
  • .visible_label() shows the label and the value above the bar. That text is aria-hidden, because the bar has the same name and value.
  • .value_text(text) shows text in place of the percent. It is also the aria-valuetext.
  • The widget clamps each value to its range. A NaN progress value gives an indeterminate bar. The progress percent rounds down, so 99.6% shows "99%".
  • The meter fills use --success, --warning-text and --danger. Each has 3:1 contrast with the track in both schemes. In forced colors mode, the meter uses system colors.

Separator

Rust
use autumn_web::widgets::{SeparatorConfig, separator, separator_with};

let line = separator(); // <hr class="autumn-separator">
let bar = separator_with(&SeparatorConfig::new().vertical());
let look = separator_with(&SeparatorConfig::new().decorative());

A vertical separator is a <span> with role="separator" and aria-orientation="vertical", so it can go in a paragraph. A decorative one is role="none".

Kbd

Rust
use autumn_web::widgets::{kbd, kbd_combo};

let esc = kbd("Esc");
let search = kbd_combo(&["Ctrl", "K"]);

kbd_combo puts one <kbd> for each key in an outer <kbd>. This is the HTML pattern for keys that you press together.

Empty state

Rust
use autumn_web::links::link_to;
use autumn_web::widgets::{EmptyStateConfig, HeadingLevel, empty_state_with};

let action = link_to("New post", "/posts/new");
let empty = empty_state_with(
    "No posts yet",
    "Write your first post.",
    Some(&action),
    &EmptyStateConfig::new().level(HeadingLevel::H3),
);

The title is a heading. The default is <h2>. Set the level one below the heading that holds the empty state. An empty body gives no paragraph.

Avatar group

Rust
use autumn_web::widgets::{AvatarConfig, AvatarGroupConfig, avatar, avatar_group_with};

let names = ["Ada", "Grace", "Linus"];
let avatars: Vec<_> = names.iter().map(|n| avatar(n, &AvatarConfig::new())).collect();
let likes = avatar_group_with(
    "Liked by",
    &avatars,
    &AvatarGroupConfig::new().max(3).total(120).more_label("{count} more"),
);
  • max is the most items that show, the +N item included. The default is 5. With more avatars, the group shows max - 1 avatars and +N.
  • total sets the number of people when you give fewer avatars.
  • The +N item is role="img". Its name comes from more_label, where {count} becomes the number. Translate it there. The item is a pill, so a long count does not clip.

Auto-grow textarea

FieldOptions::auto_grow() lets a textarea grow with its text. It uses field-sizing: content:

Rust
use autumn_web::form::{Changeset, FieldOptions, textarea_input_with};

#[derive(serde::Serialize)]
struct Profile { bio: String }

let changeset = Changeset::new(Profile { bio: String::new() });
let bio = textarea_input_with(&changeset, "bio", "Bio", &FieldOptions::new().auto_grow());

It also works on rich_text_area_with, the nested-form textarea and a11y::TextArea::auto_grow(). The textarea is at least three lines high and at most half the screen. A browser with no field-sizing keeps the rows height.

CSS hooks

SelectorElement
.autumn-button, --primary … --link, --sm … --lg, --iconButton and its modifiers
.autumn-button__icon, __label, __spinnerButton parts
.autumn-button-group, --verticalButton group
.autumn-spinner, --sm … --lg, __iconSpinner
.autumn-skeleton, --line, --block, --circle, __itemSkeleton
.autumn-indicatorHidden until an htmx request
.autumn-progress, .autumn-meterThe bars
.autumn-progress-field, __header, __label, __valueThe visible label of a bar
.autumn-separator, --verticalSeparator
.autumn-kbd, .autumn-kbd-comboKeys
.autumn-empty-state, __icon, __title, __body, __actionEmpty state
.autumn-avatar-group, __moreAvatar group
.autumn-auto-growAuto-grow textarea

To change a color, set a token. See Widget Styling.