Theme studio

Design a theme, preview it live, then export it. Saved in this browser.

Quick picks #10b981
Generated scale
50100200300400500600700800900
Some text is below AA
Accessibility

Focus that never gets lost

How Nexera UI shows keyboard focus, keeps it inside dialogs and drawers, returns it to the trigger on close, and keeps sticky bars from hiding it.

NAccessibility team· 4 min read

For someone using a keyboard, the focus indicator is the cursor. If it disappears, falls behind a sticky header or lands on <body> after a dialog closes, they have to Tab through the page to find their place again. Nexera UI treats focus as part of every component's contract, and the tests check where it goes.

A ring you can see, only when you need it

The focus indicator comes from the Figma Focus/ring and Focus/halo effects: a 2 px outline with a 2 px offset, plus a soft halo. Its colour is the interaction/focus-ring token, and the token audit checks it at 3:1 against five surfaces (page, surface, raised, sunken and input) in light and dark mode, as WCAG 1.4.11 asks.

The ring appears for keyboard focus only. React Aria sets data-focus-visible when focus arrives from the keyboard, and the shared focusRing classes draw the outline from that attribute. A mouse click on a button does not leave a ring behind. The Button tests check both halves: Tab shows the ring, a pointer press does not.

In Windows High Contrast and other forced-colour modes, the outline switches to the system Highlight colour, so it survives when the theme's colours are replaced.

Rings that do not get clipped

A focus ring drawn outside an element can be cut off by a parent with overflow: hidden or a scrolling container. That fails WCAG 2.4.11 Focus Not Obscured in a quiet way: the element has focus, and nobody can see it.

Components that live in clipped or scrolling containers use an inset variant, focusRingInset, that draws the outline inside the element's box. List rows, table cells, tabs in a scrolling strip and the code block use it. Web menu rows keep the outer ring and add a scroll margin, so scrolling a long menu never leaves the focused row half hidden.

Overlays keep focus inside

Modal, Dialog, Drawer, BottomSheet, ActionSheet and CommandPalette share one overlay root built on React Aria's ModalOverlay. They all follow the same sequence:

  1. On open, focus moves inside the overlay.
  2. Tab and Shift+Tab cycle through the overlay and never leave it. The rest of the page is inert, and page scrolling is locked.
  3. Escape closes the overlay. In Modal, so do the close button and any button with slot="close".
  4. On close, focus returns to the trigger.

The Modal test walks this path with a real keyboard simulation: it tabs to the trigger, opens the modal with Enter, presses Tab eight times and checks focus stays inside every time, then presses Escape and checks the trigger has focus again. A second test checks that a button outside the modal is inert while it is open and that <html> has overflow: hidden.

app.tsx
import { Button, Modal } from "@nexera-ui/react";

<Modal
  title="Add employee"
  subtitle="They will get an invite to set up their account."
  trigger={<Button>Add employee</Button>}
  secondaryAction={
    <Button variant="secondary" size="md" slot="close">
      Cancel
    </Button>
  }
  primaryAction={
    <Button size="md" onPress={save}>
      Send invite
    </Button>
  }
>
  <EmployeeForm />
</Modal>;

When a modal's body is long enough to scroll, it becomes focusable itself, so a keyboard user can scroll it with the arrow keys.

Where focus starts matters

For most dialogs, the first focusable element is a fine place to start. For a destructive confirmation, it is a risk: if focus lands on "Delete", an Enter press meant for something else deletes the record.

Dialog with variant="destructive" becomes an alertdialog and puts focus on the secondary action when it opens, following the WAI-ARIA alert dialog pattern:

app.tsx
import { Button, Dialog } from "@nexera-ui/react";
import { LuTrash2 } from "react-icons/lu";

<Dialog
  variant="destructive"
  title="Delete contract?"
  body="Contract 2026.pdf will be removed for Ayesha Khan. You can upload it again later."
  icon={<LuTrash2 />}
  primaryLabel="Delete"
  secondaryLabel="Cancel"
  onPrimaryAction={deleteContract}
  trigger={<Button variant="destructive">Delete contract</Button>}
/>;

A click on the scrim does not close a Dialog by default, because a dialog asks for a decision. Escape still closes it, like the Cancel button.

CommandPalette starts on its search field, because typing is the first thing you do there. Outside overlays the same idea applies: when OTPInput fails validation on submit, focus moves to the field, so the error is read with it.

Sticky surfaces are your call

Some focus problems sit at the page level, where a component cannot fix them alone. A sticky Banner at the top, a BulkActionBar fixed over a table or a stack of toasts at the bottom can cover the element you tab to. Each of these components documents the fix, which is a scroll padding on the page or the scroller:

css
html {
  scroll-padding-top: 4rem; /* the height of your sticky banner */
}

With that, the browser scrolls a newly focused element clear of the banner instead of behind it.

Test it with your keyboard

The quickest check is still manual. Put the mouse away, Tab through a page, open each dialog and drawer, press Escape and see where you land. If focus ever disappears or ends up on the page body, that is a bug, and the Dialog, Modal and Drawer tests are the place to compare against.

Keep reading

All posts →

One email when we publish.

New posts and releases, about twice a month. Or follow the RSS feed.