Focus Scope
stimeo--focus
Puts a focus boundary around any region: Tab cycles inside it and focus returns when you leave.
Draws a focus boundary around any region without building a dialog. While it is on, Tab cycles within it, and you can name where focus should land when it starts. Esc releases it, and on release focus returns where it came from. The boundary comes in two strengths: the strong one makes the rest of the page untouchable, while the soft one still cycles Tab but leaves the background reachable. What is focusable inside is counted afresh each time, so anything added later is picked up on the next Tab. It does not open, close, or show an overlay, so pair it with a dialog.
Turn it on and press Tab: focus cycles inside the box. Esc or the release button lets you out.
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. The
// marker is a property, not an attribute: Turbo copies attributes into its page snapshot,
// so an attribute one comes back set on a restored page whose elements carry no listeners.
if (root.demoWired) return;
root.demoWired = true;
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. |