Utilities
Helpers for accessibility, layout, and shared patterns that don't fit a specific component category.
None of these render anything a sighted user sees in normal use, which is exactly why they are documented as components rather than left as implementation details. A utility that lives only in someone's head gets reimplemented slightly differently each time, and the failure is invisible until a screen reader user hits it.
Components
| Component | Purpose |
|---|---|
| Screenreader-only | Visually hidden content accessible to screen readers |
| Skip navigation | Bypass repeated content for keyboard users |
| Focus trap | Contain focus within a modal or dialog |
| Visually hidden input | Custom checkbox/radio styling with accessible native input |
| Live region | Dynamic content announcements for screen readers |
Screenreader-only
Hides content visually while keeping it in the accessibility tree. Implemented as the <VisuallyHidden> component.
- Used for descriptive labels on icon-only buttons
- Used for additional context that sighted users get visually
- Never use
display: noneorvisibility: hiddenfor content that should be read asChildprop mergessr-onlyclass onto the child element instead of wrapping in a<span>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | (required) | Content to hide visually |
asChild | boolean | false | Merge onto child element instead of wrapping |
className | string | — | Additional classes |
Usage
- Icon-only buttons: add a
<VisuallyHidden>label inside the button alongside the icon - Data tables: add screenreader-only context to column headers where the visual context is ambiguous
- Navigation: provide descriptive text that supplements visual cues
How it hides
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
The element keeps a non-zero size and stays in the layout. display: none, visibility: hidden, width: 0, and aria-hidden all remove it from the accessibility tree, which is the opposite of what is wanted. white-space: nowrap prevents a long hidden string from being wrapped into an invisible column that some browsers then read one word per line.
Visually hidden text or aria-label
| Use | When |
|---|---|
<VisuallyHidden> | The text should be translated with the rest of the page, or it supplements visible text |
aria-label | The element has no text content at all and a single short name suffices |
Visually hidden text is preferred in Senco. It goes through the same localization pipeline as visible copy, whereas aria-label strings are routinely missed by translators — leaving an English accessible name inside an otherwise translated interface.
Do and don't
| Do | Don't |
|---|---|
| Add hidden context to an ambiguous link ("Edit recipient Maria") | Hide an entire duplicate version of the page for screen readers |
| Use it for an icon-only button's name | Use it to hide text you have decided is unimportant |
| Keep it short | Write a paragraph only screen reader users receive |
Anatomy
A 1×1 clipped element that remains in the layout and in the accessibility tree.
Variants
One, with asChild controlling whether it wraps or merges.
API
interface VisuallyHiddenProps {
children: React.ReactNode;
asChild?: boolean; // default: false — merge sr-only onto the child instead of wrapping
className?: string;
}
States
No states. It is never visible and never focusable — its children may be, and a focusable descendant of a visually hidden container is a keyboard trap.
Behavior
Renders its children into the accessibility tree and out of the visual layout. asChild merges the class onto the child rather than adding a wrapper element.
Content
Short, and translated with the rest of the page — which is why it is preferred over aria-label, whose strings are routinely missed by translators.
Accessibility
Uses clipping, not display: none, visibility: hidden, or zero size — each of those removes the content from the accessibility tree, which is the opposite of the intent.
Keyboard
Not focusable.
Tokens
None — it renders no visible style.
<VisuallyHidden>Visible: Account balance (Current balance for your primary checking account)
Skip navigation
A link that becomes visible on keyboard focus, allowing users to bypass repeated navigation.
- First focusable element on the page
- Links to the main content landmark
- Visible only on
:focus— hidden by default - Must pass WCAG 2.4.1 (bypass blocks)
Requirements
| Requirement | Why |
|---|---|
| First focusable element in the DOM | A skip link after the navigation skips nothing |
Target has tabindex="-1" | Without it focus lands on the anchor, and the next Tab returns to the navigation |
Visible on :focus | An invisible skip link strands sighted keyboard users who cannot see where focus went |
Target is the <main> landmark | The skip should land on content, not on a wrapper div |
Testing it
Load the page, press Tab once, and confirm that a visible link appears. Press Enter, then Tab again, and confirm focus lands on the first control inside the main content rather than back at the top of the navigation. The second half of that test is the one that usually fails.
Anatomy
A single anchor rendered before the header.
Variants
One.
API
interface SkipNavProps {
targetId?: string; // default: "ds-main-content"
label?: string; // default: "Skip to main content"
}
The target element must carry tabIndex={-1}. Without it focus lands on the anchor and the next Tab returns to the navigation, which is the failure this component exists to prevent.
States
| State | Rendering |
|---|---|
| Hidden | Clipped, but present and first in the tab order |
| Focused | Visible, pinned to the top of the viewport, above all chrome |
Behavior
Rendered before the header so it is the first tab stop. Activating it moves focus to the target, which must carry tabIndex={-1}.
Content
"Skip to main content". Naming the destination matters more than brevity.
Accessibility
Satisfies WCAG 2.4.1 Bypass Blocks. Verified by pressing Tab once, then Enter, then Tab again — focus must land inside the main content, not back at the navigation.
Keyboard
| Key | Action |
|---|---|
Tab (first press on the page) | Reveal |
Enter | Move focus to the target |
Tokens
--ds-primary · --ds-on-accent · --ds-z-sticky · --ds-radius-md
Tab into the box below to reveal the skip link:
Focus trap
Contains keyboard focus within a specific area (dialog, modal, drawer).
- Tab cycles through focusable elements inside the trap
- Shift+Tab moves backwards within the trap
- Focus returns to the trigger element when the trap is released
- Built into the Dialog and Popover components via
@base-ui/react
Lifecycle
| Phase | Behavior |
|---|---|
| Open | Focus moves to the first focusable element, or to the dialog container if there is none |
| While open | Tab cycles within; content outside is inert or aria-hidden |
Esc | Releases the trap and closes |
| Close | Focus returns to the element that opened it |
The return step is the one most often skipped. Without it, closing a dialog drops focus to <body> and a keyboard user restarts from the top of the page — after which they usually give up rather than tab back through the navigation.
Where a trap is wrong
Only modal surfaces trap focus: dialogs, sheets, modal popovers. A non-modal popover, a dropdown, or a hover card must not trap — focus should be able to leave, which is what dismisses them. A trap on a non-modal surface is indistinguishable from a broken page.
Anatomy
A wrapper with sentinel nodes at each end. Renders nothing visible.
Variants
One. A non-modal surface must not trap.
API
interface FocusTrapProps {
active: boolean;
children: React.ReactNode;
initialFocus?: React.RefObject<HTMLElement>; // default: first focusable descendant
returnFocusTo?: React.RefObject<HTMLElement>; // default: the element focused when active became true
onEscape?: () => void;
}
Only modal surfaces trap. A trap on a non-modal popover is indistinguishable from a broken page.
States
| State | Behaviour |
|---|---|
| Inactive | No effect; children behave normally |
| Active | Tab cycles inside; outside content is inert |
| Releasing | Focus returns to returnFocusTo before the trap unmounts |
Behavior
Activating moves focus in; deactivating returns it. Content outside is marked inert so a screen reader cannot browse into it.
Content
None — it renders no content of its own.
Accessibility
Required for modal surfaces and forbidden on non-modal ones. A trap on a popover is indistinguishable from a broken page.
Keyboard
| Key | Action |
|---|---|
Tab / Shift+Tab | Cycle within |
Esc | Calls onEscape; the trap does not close itself |
Tokens
None.
Visually hidden input
Native <input> elements positioned off-screen for custom styled checkboxes and radios.
- Maintains native keyboard behavior and form submission
- Screen readers announce the native input, not the visual replacement
:checkedstate drives visual styling via CSS sibling selectors- Used when the default Checkbox/Switch components don't fit the design
Why not rebuild the control
A native <input type="checkbox"> brings the checked state, keyboard activation, form participation, the correct role, label association, and :indeterminate for free. A div with role="checkbox" has to reimplement every one of those, and the one that gets missed is usually form participation — the control looks right and submits nothing.
Requirements
- The input stays in the DOM and in the tab order. It is positioned off-screen, never
display: none. - The visual replacement is
aria-hiddenand non-focusable. - A real
<label>wraps or is associated with the input, so the visual element is clickable through the label. - The focus ring is rendered on the visual element, driven by
:focus-visibleon the hidden input via a sibling selector. An off-screen input with no visible focus state is a keyboard trap in practice.
Anatomy
Off-screen native input, plus an aria-hidden visual indicator that renders the focus ring on the input's behalf.
Variants
By input type: checkbox, radio, file.
API
interface VisuallyHiddenInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
type: "checkbox" | "radio" | "file";
indicator: React.ReactNode; // the visual replacement — rendered aria-hidden, non-focusable
}
The input stays in the DOM and in the tab order, positioned off-screen. display: none removes it from the accessibility tree and from form submission.
States
Mirrors the native input's states. The visual indicator is driven by :checked and :focus-visible on the hidden input through sibling selectors.
Behavior
The native input handles activation, form participation, and state. The visual indicator only reflects it.
Content
None — the associated <label> carries the text.
Accessibility
The input stays in the tab order and in the accessibility tree. The visual replacement is aria-hidden and never focusable, and the focus ring renders on it via a sibling selector so keyboard users still see focus.
Keyboard
| Key | Action |
|---|---|
Tab | The hidden input is the tab stop |
Space | Toggle |
Tokens
Inherited from the control it replaces.
Live region
Announces dynamic content changes to screen readers via aria-live.
politeness:"polite"(default) waits for the user to finish,"assertive"interrupts immediatelyatomic:true(default) reads the entire region,falsereads only the changed nodesrelevant: controls which mutations trigger announcements —"additions","removals","text", or"all"role: semantic roles"status","alert","log","timer"—"alert"and"status"implyaria-liveso it is omittedvisuallyHidden:true(default) appliessr-only— set tofalsewhen the live content is also visible
Props
| Prop | Type | Default | Description |
|---|---|---|---|
politeness | "polite" | "assertive" | "polite" | Urgency level of the announcement |
atomic | boolean | true | Read entire region or only changes |
relevant | "additions" | "removals" | "text" | "all" | — | Which mutations to announce |
role | "status" | "alert" | "log" | "timer" | — | Semantic role (some imply aria-live) |
visuallyHidden | boolean | true | Apply sr-only styling |
Usage
- Transfer confirmations: polite announcement after form submit
- Error alerts: assertive announcement with
role="alert" - Chat/log feeds:
role="log"withrelevant="additions" - Timers:
role="timer"for countdown displays
Choosing politeness
| Politeness | Use for | Senco examples |
|---|---|---|
polite | Status the user can receive at a natural pause | "Rate updated", "3 recipients found", "Saved" |
assertive | Something that changes what the user should do right now | Transfer failed, session expiring, insufficient balance |
Assertive interrupts mid-sentence. A rate that refreshes every thirty seconds on an assertive region cuts the user off four times while they read the fee breakdown. In Senco only failures and security events are assertive.
The container must pre-exist
A live region only announces changes to a region that was already in the accessibility tree. Rendering {error && <LiveRegion>{error}</LiveRegion>} announces nothing, because the region and its content appear in the same tick. The container is always mounted; only its text content changes.
Announcements in the transfer flow
| Event | Region | Politeness |
|---|---|---|
| Rate refreshed | Status | polite |
| Recipient search returned results | Status | polite |
| Field validation failed on blur | Field's aria-describedby | — (not a live region) |
| Form submission failed | Error summary | assertive (role="alert") |
| Transfer submitted | Status | polite |
| Transfer failed | Alert | assertive |
Field-level validation is deliberately not a live region. A message announced on every blur interrupts a user who is still filling in the form; the message is associated with the field through aria-describedby and is read when the user returns to it. See Validation.
Anatomy
A visually hidden container, mounted for the lifetime of the surface that announces through it.
Variants
| Variant | Dimension | Role |
|---|---|---|
| Status | type | role="status", polite |
| Alert | type | role="alert", assertive |
| Log | type | role="log", additions only |
| Timer | type | role="timer" |
API
interface LiveRegionProps {
children: React.ReactNode;
politeness?: "polite" | "assertive"; // default: "polite"
atomic?: boolean; // default: true
relevant?: "additions" | "removals" | "text" | "all";
role?: "status" | "alert" | "log" | "timer";
visuallyHidden?: boolean; // default: true
}
The component must be mounted before the content it announces changes. Conditionally rendering it alongside its message announces nothing, because the region and its content enter the accessibility tree in the same tick.
States
| State | Behaviour |
|---|---|
| Mounted, empty | The required resting state — the region must pre-exist its content |
| Content changed | Announced at the configured politeness |
| Unmounted | Announces nothing, which is the bug this component exists to prevent |
Behavior
Mounted for the lifetime of the surface it serves. Only its text content changes. Rendering it conditionally alongside its message announces nothing.
Content
One sentence, naming the subject: "Transfer to Maria Santos is now completed", not "Completed".
Accessibility
This component is the accessibility mechanism. role="status" and role="alert" imply their politeness, so aria-live is omitted when a role is given.
Keyboard
Not focusable.
Tokens
None.
Click the button to trigger a screen reader announcement:
politeness="polite"politeness="assertive"role="status"role="alert"role="log"role="timer"Design and accessibility
- Screenreader-only content must be meaningful and concise
- Test with actual screen readers (VoiceOver, NVDA) to verify announcements
- Skip navigation must be the first focusable element
- Focus traps must release focus on Escape and return to trigger
- Never remove focus outlines without providing an alternative indicator
- Prefer
politelive regions — useassertiveonly for errors or critical alerts - Live regions must exist in the DOM before content changes; do not conditionally render the container
- Keep announcements brief — screen readers cannot be interrupted mid-sentence by a new polite announcement
These utilities cannot be verified visually
Every component on this page is invisible in a design review and in a screenshot diff. They are verified by a screen reader pass and by keyboard walkthrough, and that pass is the release gate rather than a nice-to-have. A skip link that points at a missing id, a focus trap that never returns focus, and a live region that was conditionally rendered all look perfect in QA screenshots.
Known gaps
- No automated test covers skip-link targets, focus return, or live-region pre-mounting.
<VisuallyHidden>is documented; the other four utilities have no documented component API.- No
inertusage is specified for content behind an open sheet, though the behavior is described.