Toolbar
stimeo--toolbar
A group of controls that is a single Tab stop, navigated with the arrow keys.
The stimeo--toolbar controller implements the WAI-ARIA Toolbar pattern. It keeps the group a single Tab stop with a roving tabindex (exactly one control is tabindex=0, the rest -1) and moves focus between controls with the arrow keys — ArrowRight/ArrowLeft when horizontal, ArrowUp/ArrowDown when vertical — plus Home/End for the first/last. With wrap=true movement cycles past the ends; with wrap=false it stops. Returning focus from outside lands on the most recently active control. Each control's own function (press, toggle, open a menu) stays with that element — here a small consumer script toggles aria-pressed. Behavior only — the look is yours.
Keyboard
| Key | Action |
|---|---|
| → / ← | Move to the next / previous control (horizontal orientation). |
| ↓ / ↑ | Move to the next / previous control (vertical orientation). |
| Home / End | Move to the first / last control. |
<%# Markup for the toolbar demo.
Two toolbars share the same roving-tabindex behavior; only orientation-value differs,
so the arrow axis follows it: Left/Right for horizontal, Up/Down for vertical (Home/End
jump to the ends in both). Each caption is the toolbar's accessible name via
aria-labelledby. Pressing/toggling each button is each element's responsibility. %>
<div class="toolbar-demo">
<%# Horizontal: arrows Left/Right. %>
<div class="toolbar-demo__group">
<span class="toolbar-demo__caption" id="toolbar-demo-horizontal">
<%= t("components.toolbar.demo.horizontal") %>
</span>
<div
class="toolbar"
data-controller="stimeo--toolbar"
role="toolbar"
aria-labelledby="toolbar-demo-horizontal"
data-stimeo--toolbar-orientation-value="horizontal">
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="0"
data-stimeo--toolbar-target="control" data-action="keydown->stimeo--toolbar#onKeydown">
<%= t("components.toolbar.demo.bold") %>
</button>
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="-1"
data-stimeo--toolbar-target="control" data-action="keydown->stimeo--toolbar#onKeydown">
<%= t("components.toolbar.demo.italic") %>
</button>
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="-1"
data-stimeo--toolbar-target="control" data-action="keydown->stimeo--toolbar#onKeydown">
<%= t("components.toolbar.demo.underline") %>
</button>
<span class="toolbar__separator" role="separator" aria-orientation="vertical"></span>
<button type="button" class="toolbar__button" tabindex="-1"
data-stimeo--toolbar-target="control" data-action="keydown->stimeo--toolbar#onKeydown">
<%= t("components.toolbar.demo.link") %>
</button>
</div>
</div>
<%# Vertical: same controls, arrows Up/Down. The separator is horizontal here. %>
<div class="toolbar-demo__group">
<span class="toolbar-demo__caption" id="toolbar-demo-vertical">
<%= t("components.toolbar.demo.vertical") %>
</span>
<div
class="toolbar toolbar--vertical"
data-controller="stimeo--toolbar"
role="toolbar"
aria-labelledby="toolbar-demo-vertical"
data-stimeo--toolbar-orientation-value="vertical">
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="0"
data-stimeo--toolbar-target="control" data-action="keydown->stimeo--toolbar#onKeydown">
<%= t("components.toolbar.demo.bold") %>
</button>
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="-1"
data-stimeo--toolbar-target="control" data-action="keydown->stimeo--toolbar#onKeydown">
<%= t("components.toolbar.demo.italic") %>
</button>
<button type="button" class="toolbar__button" aria-pressed="false" tabindex="-1"
data-stimeo--toolbar-target="control" data-action="keydown->stimeo--toolbar#onKeydown">
<%= t("components.toolbar.demo.underline") %>
</button>
<span class="toolbar__separator" role="separator" aria-orientation="horizontal"></span>
<button type="button" class="toolbar__button" tabindex="-1"
data-stimeo--toolbar-target="control" data-action="keydown->stimeo--toolbar#onKeydown">
<%= t("components.toolbar.demo.link") %>
</button>
</div>
</div>
</div>
/*
* Presentation-only styles for the toolbar demo.
* The library toggles the controls' tabindex (active=0 / others=-1). The pressed-state
* look is built by reacting to each button's aria-pressed.
*/
.toolbar-demo {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: flex-start;
}
.toolbar-demo__group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.toolbar-demo__caption {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.toolbar {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem;
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
background: var(--surface, var(--surface-card));
}
.toolbar__button {
min-width: 2.25rem;
padding: 0.35rem 0.6rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: none;
color: var(--fg, var(--color-text));
font: inherit;
cursor: pointer;
}
.toolbar__button:hover {
background: var(--surface-subtle);
}
.toolbar__button:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 1px;
}
.toolbar__button[aria-pressed="true"] {
border-color: var(--accent, var(--color-primary));
background: var(--vital-100);
color: var(--accent, var(--color-primary));
font-weight: 600;
}
.toolbar__separator {
width: 1px;
align-self: stretch;
margin: 0.15rem 0.25rem;
background: var(--surface-subtle);
}
/* Vertical toolbar: stack the controls and lay the separator across the column. */
.toolbar--vertical {
flex-direction: column;
align-items: stretch;
}
.toolbar--vertical .toolbar__separator {
width: auto;
height: 1px;
margin: 0.25rem 0.15rem;
}
// Demo of each toolbar control's function (consumer-side JS).
//
// The core controller (stimeo--toolbar) only handles roving tabindex and arrow-key
// focus movement; what each button does (here, toggling aria-pressed) is the consumer's
// job. This shows the toolbar's "single tab stop + arrow movement" coexisting
// independently with each button's pressed state.
document.querySelectorAll('[data-controller~="stimeo--toolbar"]').forEach((toolbar) => {
toolbar.querySelectorAll('[aria-pressed]').forEach((button) => {
button.addEventListener('click', () => {
const pressed = button.getAttribute('aria-pressed') === 'true';
button.setAttribute('aria-pressed', pressed ? 'false' : 'true');
});
});
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--toolbar"
Targets
| Name | Description | Attribute |
|---|---|---|
control
required
|
A toolbar control; the controls form one Tab stop with arrow-key roving among them. | data-stimeo--toolbar-target="control" |
Values
| Name | Description | Attribute |
|---|---|---|
orientation
|
Arrow-key axis, horizontal or vertical; default horizontal. |
data-stimeo--toolbar-orientation-value |
wrap
|
Whether arrow movement cycles past the ends; default true. | data-stimeo--toolbar-wrap-value |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Arrow keys (per orientation) and Home/End move focus and the single tab stop. | stimeo--toolbar#onKeydown |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
tabindex |
Control | 0 on the active control, -1 on the rest (single Tab stop). |