Browse docs
Browse docs
Hide text from the eye, keep it in the accessibility tree. A 30-line component with disproportionately high value: it's the difference between an icon-only button that screen readers announce as "button" (useless) and one announced as "button, Close dialog" (actually navigable).
import { VisuallyHidden } from '@dashforge/tw';
<button type="button">
<CloseIcon />
<VisuallyHidden>Close dialog</VisuallyHidden>
</button>A sighted user sees only the icon. A screen reader user hears "button, Close dialog". Voice control users can say "click Close dialog". Keyboard users — same as everyone, because the button still works the same.
Tailwind ships the sr-only utility class. So why a component?
{/* Both work. They're functionally identical. */}
<span className="sr-only">Close dialog</span>
<VisuallyHidden>Close dialog</VisuallyHidden>Two reasons for the wrapper:
Discoverability in code review — <VisuallyHidden> reads as an intentional accessibility decision. className="sr-only" reads like a typo, a forgotten utility, or a "WTF is this for". The reviewer pings the author to ask; with the component name, they don't.
Single source of truth — if we ever need to change the implementation (e.g. to add support for a future browser hack, or to opt into a different a11y technique), it lives in one place. Hard-coding sr-only everywhere makes that migration manual.
The component is intentionally tiny because the primitive itself is conceptually tiny. The value is in the name.
{/* Icon-only button */}
<button type="button" onClick={handleClose}>
<CloseIcon aria-hidden="true" />
<VisuallyHidden>Close</VisuallyHidden>
</button>
{/* Skip-link for keyboard navigation */}
<a href="#main-content" className="absolute top-2 left-2 focus:not-sr-only sr-only">
<VisuallyHidden>Skip to main content</VisuallyHidden>
</a>
{/* Form label hidden visually but kept for screen readers */}
<label>
<VisuallyHidden>Search query</VisuallyHidden>
<input type="search" placeholder="Search…" />
</label><Stack direction="row" gap={2}>
<button type="button">
<BoldIcon aria-hidden="true" />
<VisuallyHidden>Bold</VisuallyHidden>
</button>
<button type="button">
<ItalicIcon aria-hidden="true" />
<VisuallyHidden>Italic</VisuallyHidden>
</button>
<button type="button">
<UnderlineIcon aria-hidden="true" />
<VisuallyHidden>Underline</VisuallyHidden>
</button>
</Stack>Pair with aria-hidden="true" on the icon: the icon is decorative once the label is present (otherwise the screen reader reads the icon's alt text AND the visually hidden label — double-speak).
Alternative: use aria-label="Bold" on the button instead of VisuallyHidden. Both are valid. Use VisuallyHidden when:
aria-label feels awkward<label>
<VisuallyHidden>Search products</VisuallyHidden>
<input
type="search"
placeholder="Search…"
className="..."
/>
</label>Placeholder text is not a substitute for a label — it disappears the moment the user types. A visually-hidden <label> keeps the field accessible without adding visual chrome.
const [status, setStatus] = useState('');
return (
<>
<Button onClick={async () => {
setStatus('Saving…');
await save();
setStatus('Saved successfully.');
}}>
Save
</Button>
{/* Screen readers announce status changes politely */}
<VisuallyHidden aria-live="polite">{status}</VisuallyHidden>
</>
);aria-live="polite" tells assistive tech to announce changes when the user is idle (vs "assertive" which interrupts). Wrap in VisuallyHidden so the message is announced without taking up screen space.
<a
href="#main"
className="
absolute left-2 top-2
focus:not-sr-only sr-only
focus:px-3 focus:py-2 focus:bg-primary-600 focus:text-white focus:rounded
"
>
Skip to main content
</a>
<main id="main">…</main>Hidden by default. Becomes visible (not-sr-only reverses the clip) when keyboard-focused. The first keyboard tab of any page should reveal this link — lets users bypass nav menus without 47 Tab presses.
<nav aria-label="Pagination">
<Stack direction="row" gap={1}>
<button type="button">
<ChevronLeftIcon aria-hidden="true" />
<VisuallyHidden>Previous page</VisuallyHidden>
</button>
{[1, 2, 3].map((n) =>
<button key={n} type="button">
<VisuallyHidden>Go to page </VisuallyHidden>
{n}
</button>
)}
<button type="button">
<ChevronRightIcon aria-hidden="true" />
<VisuallyHidden>Next page</VisuallyHidden>
</button>
</Stack>
</nav>The page numbers (1, 2, 3) appear plain to sighted users; screen readers hear "Go to page 1", "Go to page 2" — context that disambiguates them from "1 unread message" or any other "1" on the page.
| Prop | Type | Default | Description |
|---|---|---|---|
as | ElementType | 'span' | Override the HTML tag. Default span for inline placement. Use div for block content. |
sx | string | — | Utility-class override, merged via tailwind-merge. Rarely needed. |
children | ReactNode | — | The hidden content (typically a string label). |
| ...rest | HTMLAttributes<HTMLElement> | — | Native attributes pass through (id, aria-*, data-*, etc.). |
sr-only actually doesUnder the hood, Tailwind's sr-only expands to:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}The classic WebAIM clip technique. Critically:
| Approach | A11y tree? | Why we don't use it |
|---|---|---|
display: none | ❌ Removed | Screen readers skip it entirely. |
visibility: hidden | ❌ Removed | Same — removed from a11y tree. |
opacity: 0 | ✅ Kept | But still occupies layout space (bad). |
width/height: 0 | ⚠️ Some AT skip | Inconsistent across screen readers. |
sr-only (clip) | ✅ Kept | The canonical pattern. We use this. |
<span> because the 99% case is inline placement inside a button or link. Use as="div" for block content (but never nest block inside inline — invalid HTML).aria-hidden="true" on adjacent icons — otherwise the screen reader reads both the icon's alt AND the VisuallyHidden label, producing double-speak.asChild — the whole point is to wrap content in our own (hidden) element. Slot would defeat the purpose.sr-only. There's nothing to vary.display: none or simply omit the element.aria-hidden="true", not a visually-hidden label.aria-modal, etc.), not just a clip.<label>. Use a real <label> and wrap it in VisuallyHidden if you can't afford the visual space.