Focus Scope
stimeo--focus
A standalone focus boundary — Tab cycling, initial focus, and restore — for any region.
The stimeo--focus controller exposes the shared FocusTrap as a declarative focus boundary for any region, without building a full modal (the counterpart to Alpine focus / Headless UI's trap). While trap is on, Tab / Shift+Tab cycle within the element, focus moves to the initial target (or the first focusable) when auto, Escape releases it, and on release focus returns to the opener when restore. With inert the rest of the page is made inert (a hard, modal-style isolation); left off it is a soft boundary — Tab still cycles but the background stays reachable. The element carries data-focus-trapped while active and emits activate / deactivate. Behavior only — it does not open/close or render an overlay (pair with Dialog) and does not move DOM (pair with Portal). It reuses the shared focus_trap util, so unlike the modal overlays it never locks page scroll, and it tracks live focusable children (dynamic additions are picked up on the next Tab). Everything is torn down on disconnect (Turbo navigation included) without yanking focus.
Activate, then press Tab: focus cycles within the box. Press Escape, Release, or the same button (now Deactivate) to exit.
Keyboard
| Key | Action |
|---|---|
| Tab / Shift+Tab | Cycle focus within the scope (wraps at the ends). |
| Escape | Release the trap and restore focus to the opener. |
<%# Focus-scope demo: the Activate button sits outside the scope, so it invokes the
controller's activate action through the playground's exposed Stimulus app
(window.Stimulus) in demo.js. Once active, Tab cycles within the box, focus lands on
the initial target, and Escape or the Release button (a descendant, wired with a plain
data-action) ends it and restores focus. inert is left off so the soft boundary does
not isolate the rest of the catalog page. The library only manages focus and reflects
data-focus-trapped; demo.css owns the look. %>
<div class="focus-demo">
<button
type="button"
class="demo-trigger"
data-focus-demo-activate
aria-pressed="false"
data-activate-label="<%= t('components.focus.demo.activate') %>"
data-deactivate-label="<%= t('components.focus.demo.deactivate') %>">
<%= t("components.focus.demo.activate") %>
</button>
<div class="focus-demo__scope" data-controller="stimeo--focus">
<p class="focus-demo__hint"><%= t("components.focus.demo.hint") %></p>
<label class="focus-demo__field">
<span><%= t("components.focus.demo.field") %></span>
<input type="text" class="demo-input" data-stimeo--focus-target="initial" />
</label>
<div class="focus-demo__bar">
<button type="button" class="demo-trigger"><%= t("components.focus.demo.inner") %></button>
<button type="button" class="demo-trigger" data-action="click->stimeo--focus#deactivate">
<%= t("components.focus.demo.release") %>
</button>
</div>
</div>
</div>
/*
* Presentation-only styles for the focus-scope demo. The library manages focus and
* reflects data-focus-trapped; this CSS lays out the scope and highlights it while the
* trap is active so the boundary is visible.
*/
.focus-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.focus-demo__scope {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 24rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
/* Make the active boundary obvious. */
.focus-demo__scope[data-focus-trapped] {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(var(--vital-rgb), 0.2);
}
.focus-demo__hint {
margin: 0;
color: var(--color-text-muted);
}
.focus-demo__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.focus-demo__field input {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
}
.focus-demo__bar {
display: flex;
gap: 0.5rem;
}
// Focus-scope demo (consumer-side JS).
//
// The Activate button sits outside the (trapped) scope, so a plain data-action can't
// reach the controller. The playground exposes its Stimulus application as
// window.Stimulus, so we fetch the controller instance and call its activate() action.
// Release (inside the scope) uses a normal data-action, and Escape works on its own.
document.querySelectorAll(".focus-demo").forEach((root) => {
const scope = root.querySelector('[data-controller~="stimeo--focus"]');
const button = root.querySelector("[data-focus-demo-activate]");
if (!scope || !button) return;
// Idempotent: Turbo can re-run this inline module on navigation; wire each root once.
if (root.dataset.demoWired) return;
root.dataset.demoWired = "1";
const controller = () =>
window.Stimulus?.getControllerForElementAndIdentifier(scope, "stimeo--focus");
// The external button toggles the scope on/off and stays in sync with the controller's
// activate/deactivate events, so its label is correct even when Escape or the inner
// Release ends the scope.
const sync = (active) => {
button.textContent = active ? button.dataset.deactivateLabel : button.dataset.activateLabel;
button.setAttribute("aria-pressed", String(active));
};
button.addEventListener("click", () => {
if (scope.hasAttribute("data-focus-trapped")) controller()?.deactivate();
else controller()?.activate();
});
scope.addEventListener("stimeo--focus:activate", () => sync(true));
scope.addEventListener("stimeo--focus:deactivate", () => sync(false));
sync(false);
});
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--focus"
Targets
| Name | Description | Attribute |
|---|---|---|
initial
|
Optional element to focus on activation (defaults to the first focusable). | data-stimeo--focus-target="initial" |
Values
| Name | Description | Attribute |
|---|---|---|
trap
|
Whether the focus trap is active (default false). |
data-stimeo--focus-trap-value |
auto
|
Move focus inside on activation (default true). |
data-stimeo--focus-auto-value |
restore
|
Return focus to the opener on release (default true). |
data-stimeo--focus-restore-value |
inert
|
Make the background inert while active for a hard isolation (default false). |
data-stimeo--focus-inert-value |
Actions
| Name | Action |
|---|---|
activate
|
stimeo--focus#activate |
deactivate
|
stimeo--focus#deactivate |
Events
| Name | Description | Event |
|---|---|---|
activate
|
Fires when the trap is activated. | stimeo--focus:activate |
deactivate
|
Fires when the trap is released. | stimeo--focus:deactivate |
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 |
|---|---|---|
data-focus-trapped |
Controller element | Present (true) while the trap is active. |
inert |
Background siblings | Applied while active when the inert value is set. |