Skip to main content
ENKO

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

ComponentPurpose
Screenreader-onlyVisually hidden content accessible to screen readers
Skip navigationBypass repeated content for keyboard users
Focus trapContain focus within a modal or dialog
Visually hidden inputCustom checkbox/radio styling with accessible native input
Live regionDynamic 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: none or visibility: hidden for content that should be read
  • asChild prop merges sr-only class onto the child element instead of wrapping in a <span>

Props

PropTypeDefaultDescription
childrenReactNode(required)Content to hide visually
asChildbooleanfalseMerge onto child element instead of wrapping
classNamestring—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

UseWhen
<VisuallyHidden>The text should be translated with the rest of the page, or it supplements visible text
aria-labelThe 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

DoDon'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 nameUse it to hide text you have decided is unimportant
Keep it shortWrite 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.

Screenreader-only
Icon button with VisuallyHidden label
<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

RequirementWhy
First focusable element in the DOMA 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 :focusAn invisible skip link strands sighted keyboard users who cannot see where focus went
Target is the <main> landmarkThe 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

StateRendering
HiddenClipped, but present and first in the tab order
FocusedVisible, 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

KeyAction
Tab (first press on the page)Reveal
EnterMove focus to the target

Tokens

--ds-primary · --ds-on-accent · --ds-z-sticky · --ds-radius-md

Skip navigation link

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

PhaseBehavior
OpenFocus moves to the first focusable element, or to the dialog container if there is none
While openTab cycles within; content outside is inert or aria-hidden
EscReleases the trap and closes
CloseFocus 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

StateBehaviour
InactiveNo effect; children behave normally
ActiveTab cycles inside; outside content is inert
ReleasingFocus 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

KeyAction
Tab / Shift+TabCycle within
EscCalls 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
  • :checked state 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-hidden and 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-visible on 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

KeyAction
TabThe hidden input is the tab stop
SpaceToggle

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 immediately
  • atomic: true (default) reads the entire region, false reads only the changed nodes
  • relevant: controls which mutations trigger announcements — "additions", "removals", "text", or "all"
  • role: semantic roles "status", "alert", "log", "timer" — "alert" and "status" imply aria-live so it is omitted
  • visuallyHidden: true (default) applies sr-only — set to false when the live content is also visible

Props

PropTypeDefaultDescription
politeness"polite" | "assertive""polite"Urgency level of the announcement
atomicbooleantrueRead 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)
visuallyHiddenbooleantrueApply sr-only styling

Usage

  • Transfer confirmations: polite announcement after form submit
  • Error alerts: assertive announcement with role="alert"
  • Chat/log feeds: role="log" with relevant="additions"
  • Timers: role="timer" for countdown displays

Choosing politeness

PolitenessUse forSenco examples
politeStatus the user can receive at a natural pause"Rate updated", "3 recipients found", "Saved"
assertiveSomething that changes what the user should do right nowTransfer 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

EventRegionPoliteness
Rate refreshedStatuspolite
Recipient search returned resultsStatuspolite
Field validation failed on blurField's aria-describedby— (not a live region)
Form submission failedError summaryassertive (role="alert")
Transfer submittedStatuspolite
Transfer failedAlertassertive

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

VariantDimensionRole
Statustyperole="status", polite
Alerttyperole="alert", assertive
Logtyperole="log", additions only
Timertyperole="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

StateBehaviour
Mounted, emptyThe required resting state — the region must pre-exist its content
Content changedAnnounced at the configured politeness
UnmountedAnnounces 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.

Live region

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 polite live regions — use assertive only 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 inert usage is specified for content behind an open sheet, though the behavior is described.