VisuallyHidden
Content that screen readers announce and eyes do not see.
Preview
visually-hidden · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/visually-hidden
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { VisuallyHidden } from "@/components/ui/visually-hidden"<button type="button">
<XIcon aria-hidden="true" />
<VisuallyHidden>Close the panel</VisuallyHidden>
</button>When to use it
Use it when
- Give an icon-only control an accessible name, by putting the words the icon stands for inside it.
- Add context a sighted reader gets from layout, such as the subject of a repeated "Read more" link.
- Provide a heading or a label for a region that reads clearly to the eye without one.
Do not use it when
- The content must become visible when it receives focus, which is how a skip link works.Use
linkinstead. - You are labelling a form input and want the label, the hint and the error wired together.Use
fieldinstead. - The words explain jargon and should be readable by everyone, not only by assistive technology.Use
terminstead.
Examples
A name for an icon-only button
The button shows only a cross, so a screen-reader user meets a control with no
name. The words inside VisuallyHidden become its accessible name, and the icon
is aria-hidden.
visually-hidden-a-name-for-an-icon-button · base base · style base-lyraOpen under the product themeContext for a repeated link
Three rows end in the same two words, which a screen reader can list with no sentence around them. Appending the subject inside VisuallyHidden names each link without changing a pixel.
visually-hidden-context-for-a-repeated-link · base base · style base-lyraOpen under the product themeContent guidelines
Write the hidden words a sighted reader already has, no more. For an icon-only control, name the action in full. Keep the leading space when you splice hidden words into visible text, because a screen reader concatenates them. Never hide a caveat, a source or a clinical status, because everyone needs those.
Do
"Close the reminders panel" names the action a sighted reader takes from the cross.
Don’t
An empty VisuallyHidden names nothing, and the markup now looks labelled when it is not.
Accessibility
Checked by pnpm run check:a11y on every commit. The component sets no
colour and no type size, so the gate's token and unit rules pass trivially.
Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is author run, not an independent review, and clinical review is still pending. The source pass covered the clip technique, the accessibility tree and the two shipped examples; the rendered pass ran axe against the live preview and checked target size, focus visibility and reflow at 320px, and found nothing to flag.
- Content is announced because the span is clipped, not
display:none,visibility:hiddenor thehiddenattribute, each of which removes content from the tree. The clip pattern was checked against the WebAIM sr-only reference and stays hidden through 200% text and increased text spacing, because neither touches the clip's own overflow and clip-path. - It is text rather than a control, so it is never focusable and never a tab stop.
- The audit fixed one gap the component's own open question named: nothing previously warned when a focusable control was placed inside the always hidden clip, a keyboard trap with no visible focus ring. The component now walks its children in development and warns when it finds a focusable descendant, the same pattern it already used for empty children. The warning is development only, so it changes no markup, class or visual output.
- The component paints nothing, so no colour or type size applies and there is nothing to reflow at 200% text.
Not checked by any gate. Whether a screen reader announces the content across VoiceOver, NVDA, JAWS and TalkBack. Whether hidden text duplicates a visible name beside it is a per-usage question the component cannot see from its own source, so it stays a concern for the caller rather than something this audit could check.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the hidden text | Never a tab stop. A control placed inside keeps its own place in the order. |
| Shift+Tab | Skips the hidden text in reverse | Reverse order matches forward order, because there is nothing to stop on. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from VisuallyHiddenProps in registry/bases/base/visually-hidden.tsx.
children is required, because a VisuallyHidden with nothing to announce is a
silent no-op. Empty children, an empty string, undefined or false raise a
development warning naming the mistake. The span still renders, because a
presentation layer reports rather than throws. className reaches the span for
positioning, and a class that makes the content visible defeats the component.