Tabs
The template already brings the list drawn, the initial tab marked and the other panels with hidden — the page
is born right before the script runs. JavaScript adds what HTML can't: the roles that make the screen reader announce
"tab, 2 of 4", the link between tab and panel, and the keyboard, where the whole list is a single Tab stop.
Three open invoices. The panel is any HTML, including what arrives through HTMX.
Examples
The same markup, with one more class or attribute.
Segmented
is-segmented is the height of a field, for switching a list's view next to a filter.
128 contracts.
12 awaiting payment.
116 settled.
Segmented next to a field
Border and padding add up to the control's height: the row stays aligned.
Manual activation
data-manual="true": the arrows only move focus, and Enter or Space opens.
This is where the panel loaded through HTMX would go.
One request per tab opened, not per tab passed over.
No attachments.
With icon and counter
The svg and the .tuc-badge is-plain inside the tab follow its color.
Project overview.
Seven open tasks.
Project preferences.
Many tabs
The list scrolls horizontally instead of wrapping onto two lines; the tab focused with the arrows scrolls into view.
January.
February.
March.
April.
May.
June.
July.
August — the tab marked in the template.
Disabled tab
disabled or aria-disabled="true": it doesn't open, and the arrows skip it.
Being edited.
Review.
Live.
How to use
Write the classes, aria-selected="true" on the initial tab and hidden on the other panels. Tabs
and panels are matched by order: the third tab opens the third panel.
<div class="tuc-tabs" data-tuc-tabs>
<div class="tuc-tabs__list">
<button class="tuc-tabs__tab" aria-selected="true">Details</button>
<button class="tuc-tabs__tab">Address</button>
</div>
<div class="tuc-tabs__panel">…</div>
<div class="tuc-tabs__panel" hidden>…</div>
</div>With aria-selected on none of them, the first enabled tab opens. The script gives type="button" to tabs that have no
type — without it, switching tabs inside a <form> would submit the form —, creates the missing
ids and links tab and panel through aria-controls and aria-labelledby.
Why the classes come from the template, not the script
That way the list is drawn from the very first frame, with no placeholder block and no jump when JavaScript arrives. For the same reason hidden on the panels is required: without it, they would all show up stacked until the script runs.
With HTMX, panels on demand
To avoid loading the content of every tab at once, each tab fetches its own panel the first time it is opened.
Turn on data-manual="true": in manual mode, opening is always a click — from the mouse, from Enter or from
Space —, so hx-trigger on the tab covers every path.
<div class="tuc-tabs" data-tuc-tabs data-manual="true">
<div class="tuc-tabs__list">
<button class="tuc-tabs__tab" aria-selected="true">Summary</button>
<button class="tuc-tabs__tab" hx-get="{% url 'customer-invoices' customer.pk %}"
hx-target="#invoices" hx-trigger="click once">Invoices</button>
</div>
<div class="tuc-tabs__panel">{% include "customers/summary.html" %}</div>
<div class="tuc-tabs__panel" id="invoices" hidden></div>
</div>Why manual mode exists
With automatic activation, the arrow switches the panel instantly. With panels that load from the server, passing over four tabs to reach the one you want would fire four requests. In manual mode the arrow only moves focus, and the person decides when to open.
In JavaScript
const tabs = new Tucano.Tabs('#customer', {
selected: 1, // index of the initial tab
manual: true,
onChange: (index, { tab, panel }) => console.log(index, tab.textContent),
});
tabs.select(2); // opens the third; fires onChange
tabs.select(0, { silent: true }); // opens without firing anything
tabs.index; // index of the open tab, or -1
tabs.tabs; // the tabs, in order
tabs.panels; // the panels, in orderEvery switch — by the person or by select() — fires tucano:change on the tabs element, and the event
bubbles. select() on a disabled tab does nothing.
document.addEventListener('tucano:change', (e) => {
if (!e.target.matches('.tuc-tabs')) return;
e.detail.value; // index of the open tab
e.detail.tab; // the tab button
e.detail.panel; // the panel
e.detail.instance; // the Tabs
});Tabs inside a panel are a separate set: the component only looks at the list and the panels that are direct children of its element.
Keyboard and accessibility
The keyboard follows the ARIA APG tabs pattern. The whole list is a single Tab stop — otherwise keyboard
users would pass through every tab before reaching the content of the open one.
| Key | Action |
|---|---|
Tab | Enters the open tab; the next Tab goes to the panel content |
← → | Previous and next tab, skipping disabled ones and wrapping around at the ends |
Home End | First and last enabled tab |
Enter Space | In manual mode, opens the focused tab |
The list gets role="tablist", each tab role="tab" with aria-selected, and each panel
role="tabpanel". A panel with nothing focusable inside gets tabindex="0", so Tab reaches
the content; with a field or link inside, it doesn't — that would be a useless stop before it.
API
Generated from the code on every build — if something is not here, it does not exist.
[data-tuc-tabs]new Tucano.Tabs(alvo, opcoes)data-manualselect destroytucano:changeOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
selected | null | indice da aba inicial; sem ele vale a marcada com aria-selected="true", ou a primeira |
manual | false | setas so movem o foco, e Enter ou Espaco trocam o painel — para painel que carrega por HTMX |
onChange | null | (index, detail) a cada troca feita pela pessoa ou por select() |