autumn_web::widgets::tabs groups related content into switchable panels —
"Profile / Security / Billing", "Overview / Comments / Activity" — with
zero hand-written JavaScript or CSS. Switching is pure CSS (:target
anchors), so it works with JavaScript disabled and each panel is
deep-linkable via a URL fragment.
Out of scope: lazy loading, dynamically-added tabs, visual variants, and related widgets like accordions or drawers.
A three-tab detail view
use autumn_web::prelude::*;
use maud::html;
#[get("/posts/{id}")]
async fn show(Path(id): Path<i64>) -> Markup {
let panels = [
("overview", "Overview", html! { p { "Post " (id) " overview" } }),
("comments", "Comments", html! { p { "Comments for post " (id) } }),
("activity", "Activity", html! { p { "Activity for post " (id) } }),
];
html! {
h1 { "Post " (id) }
(tabs("post-tabs", &panels))
}
}
That's the whole widget: 9 lines of Maud produce a full tablist strip and
three panels. Visiting /posts/42 shows the Overview panel by default;
visiting /posts/42#comments shows the Comments panel on load — no
JavaScript involved either way.
Signature
pub fn tabs(id: &str, panels: &[(&str, &str, maud::Markup)]) -> maud::Markup
Each tuple is (panel_id, label, body), in display order. panel_id and
label are &str (HTML-escaped by Maud); body is pre-rendered
maud::Markup — the caller owns escaping for rich content, same as card's
body parameter.
panel_idbecomes the panel's ownid, so#<panel_id>in the URL targets it directly.- The tab's element
idis"{id}-tab-{panel_id}". - The first tab/panel is marked active by default (
aria-selected="true",autumn-tabs__tab--active/autumn-tabs__panel--active), so a panel is always visible even before any fragment is present. - An empty
panelsslice renders the emptyautumn-tabscontainer without panicking. - Rendering more than one
tabs()widget on the same page: panel ids must be unique across the entire page, not just within one call. Twotabs()calls that both use a panel id like"overview"emit two elements withid="overview"— invalid duplicate-id HTML that makes:target/aria-controls/aria-labelledbylookups ambiguous. Prefix panel ids per widget instance (e.g."post-overview","related-overview") if more than onetabs()appears on a page. - Nesting a
tabs()widget inside anothertabs()panel: panel visibility handles this — the shared widget stylesheet (autumn_web::ui::WIDGETS_CSS_PATH) reveals the whole ancestor chain down to a deep-linked inner panel, so nested content is reachable no matter which outer panel it lives in. The outer widget's active-tab highlight can only sync when the shown outer panel is itself the direct target, though — CSS forbids nesting:has()inside:has(), so a panel shown only because it contains a nested target can't be tied back to a specific outer tab position. No outer tab is highlighted in that case, rather than the wrong one being highlighted.
CSS hooks
| Selector | Element |
|---|---|
.autumn-tabs | Root wrapper |
.autumn-tabs__list | Tab strip (role="tablist") |
.autumn-tabs__tab | Individual tab link |
.autumn-tabs__tab--active | The initially-selected tab |
.autumn-tabs__panel | Individual panel |
.autumn-tabs__panel--active | The initially-visible panel |
Accessibility
- The tab strip carries
role="tablist". - Each tab carries
role="tab",aria-controls(pointing at its panel'sid), andaria-selected. - Each panel carries
role="tabpanel",aria-labelledby(pointing at its tab'sid), andtabindex="0"so it can receive keyboard focus directly. - Known limitation:
aria-selectedreflects the server's default selection (the first tab) only. The server never sees the URL fragment, so it can't know which tab a client-side:targetnavigation actually landed on — fixing this would require JavaScript, which this widget deliberately has none of. A screen reader always hears the first tab announced as "selected", even when a different panel is what's shown.
No-JavaScript switching
Each tab is a plain <a href="#panel-id">. The framework's shared widget
stylesheet (autumn_web::ui::WIDGETS_CSS_PATH) targets
.autumn-tabs__panel:target to show the panel matching the URL fragment,
with a :has()-based fallback that shows the first (--active) panel when
no fragment targets any panel in the widget. No <script>, inline event
handler, or hx-* attribute is emitted or required.
The active-tab visual highlight also tracks whichever panel is actually
:target-ed, using position-based :has()/:nth-child() CSS (ids are
arbitrary caller strings, so a shared stylesheet can only match them by
position, not value) — this covers the first 6 tabs; widgets with more tabs
keep switching panels correctly, but the highlight itself stops updating
past the 6th.
:has() has narrower browser support than :target. In a browser that
supports :target but not :has(), the tabs widget degrades gracefully
rather than breaking: an @supports not selector(:has(a)) rule
unconditionally shows the first panel, so the widget is never blank — a
fragment link to a different panel in such a browser may show that panel
alongside the first rather than replacing it.