[]previous and next page

Focus

Focus in the Accessibility guide.

For agentsUpdated

Focus is DOM state the framework does not manage. Three rules cover most cases:

  1. Move focus when something the user acted on appears or disappears: a dialog opens, a form error appears, a route changes.
  2. Return it when that thing goes away, to the element that caused it.
  3. Do it after the element exists in the document. A ref callback runs while the element is being built, before it is inserted, so el.focus() there does nothing. onMount runs after the first flush, untracked, once the tree is in the document.
src/dialog.tsxtsx
import { onCleanup, onMount, useStore } from "@shadow-js/core";
import { Portal, Show } from "@shadow-js/web";

function Dialog(props: { onClose: () => void }) {
  let close: HTMLButtonElement | undefined;
  // Remember who had focus, to give it back.
  const opener = document.activeElement;
  onMount(() => close?.focus());
  onCleanup(() => {
    if (opener instanceof HTMLElement) opener.focus();
  });
  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
      onKeyDown={(event) => {
        if (event.key === "Escape") props.onClose();
      }}
    >
      <h2 id="dialog-title">Delete item?</h2>
      <button type="button" ref={close} onClick={props.onClose}>
        Cancel
      </button>
    </div>
  );
}

export function Page() {
  const [open, setOpen] = useStore(false);
  return (
    <div>
      <button type="button" onClick={() => setOpen(true)}>
        Delete
      </button>
      <Show when={open()}>
        <Portal>
          <Dialog onClose={() => setOpen(false)} />
        </Portal>
      </Show>
    </div>
  );
}

Things the example does that you should copy, and what it leaves out:

  • ref={close} assigns the element to the variable (a callback ref={(el) => ...} also works); onMount(() => close?.focus()) uses it after insertion.
  • onCleanup runs when the dialog is removed, so focus returns however it closes (button, Escape, navigation).
  • aria-modal="true" tells assistive technology the rest of the page is inert, but it does not stop keyboard focus from leaving. A real modal also needs a focus trap (Tab and Shift+Tab cycle inside), to make the background inert, to lock body scroll, and to close on outside press. The framework provides none of these.
  • Portal renders nothing in place on the server. A dialog open at render time is emitted at the end of body when the render has a portal collector (see the SSR guide, "Portals on the server"). Events follow the DOM tree, not the component tree: a handler on an ancestor of the call site does not see the portal's events.