Focus
Keep focus where it belongs: trapped in a dialog or sheet, roving inside a toolbar or radio group, and given back when the surface closes.
Primitives
Primitives are the behaviour Sveltier's components are built on, exported for your own elements. Each one is an attachment: it runs in the browser only, starts and stops with a condition, and cleans up when the element goes away.
<script>
import { focusTrap } from 'sveltier';
let open = $state(false);
</script>
<!-- Active while open; cleaned up when open turns false or the element goes away. -->
<div {@attach open && focusTrap()}>…</div>Focus trap
focusTrap() moves focus inside, wraps Tab and Shift+Tab at the edges, and brings focus
back when a pointer or a script moves it out. When it ends, focus returns to where it was. Traps stack:
one opened inside another takes over until it ends.
While the panel is open, Tab and Shift+Tab stay inside it. Close gives focus back to the button that opened it.
<script>
import { focusTrap } from 'sveltier';
let open = $state(false);
let opener = $state();
</script>
<button type="button" bind:this={opener} onclick={() => (open = true)}>Transfer</button>
{#if open}
<!-- Pass the trigger: Safari does not focus a button when it is clicked. -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="transfer-title"
{@attach focusTrap({ returnFocus: opener })}
>
<h2 id="transfer-title">Transfer</h2>
<input aria-label="Amount" />
<button type="button" onclick={() => (open = false)}>Close</button>
</div>
{/if}| Name | Type | Default | Description |
|---|---|---|---|
initialFocus | HTMLElement | SVGElement | string | false | first tabbable | Where focus goes when the trap starts, unless it is already inside: an element, a selector inside the container, or false to leave it. With nothing tabbable inside, the container itself takes focus. |
returnFocus | boolean | HTMLElement | SVGElement | null | true | Where focus goes when the trap ends, if it is still inside or lost to the page: true for the element that had it when the trap started, an element (pass the trigger, since Safari does not focus a clicked button), or false. |
Roving focus
rovingFocus() gives a composite widget one tab stop. The arrow keys move between items,
Home and End jump to the ends, disabled items are skipped, and the first stop is the item marked selected,
checked or current.
Tab in, then use the arrow keys, Home and End. The group is a single tab stop, and Tab leaves it.
<script>
import { rovingFocus } from 'sveltier';
</script>
<div role="toolbar" aria-label="Chart" {@attach rovingFocus()}>
<button type="button" data-sv-item>1D</button>
<button type="button" data-sv-item>1W</button>
<button type="button" data-sv-item aria-disabled="true">1M</button>
<button type="button" data-sv-item>1Y</button>
</div>| Name | Type | Default | Description |
|---|---|---|---|
orientation | 'horizontal' | 'vertical' | 'both' | 'horizontal' | Which arrow keys move. Left and right follow the writing direction. |
loop | boolean | true | Wrap from the last item to the first and back. |
items | string | '[data-sv-item]' | Selector for the items inside the container. |
Tabbable elements
Both are built on tabbable(), which lists what Tab reaches in a container, in Tab
order: positive tabindex first, one stop per radio group, nothing disabled, hidden,
inert or inside a closed <details>.
import { isTabbable, tabbable } from 'sveltier';
const stops = tabbable(dialog); // what Tab reaches, in Tab order
isTabbable(button); // false when disabled, hidden, inert, or tabindex="-1"