In a hurry? The the decision page has the recommendation, a side-by-side sample, and a verdict table. This page is the detailed anatomy behind it: each implementation line by line, with the measured screen-reader findings.
Current Drupal
.visually-hidden {
position: absolute !important;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
width: 1px;
height: 1px;
word-wrap: normal;
}
.visually-hidden.focusable:active,
.visually-hidden.focusable:focus-within {
position: static !important;
overflow: visible;
clip: auto;
width: auto;
height: auto;
}
position: absolute !important;- Removes the element from normal document flow so that its content does not reserve visible layout space. The
!importantprotects this utility from ordinary theme rules that might otherwise reposition it. overflow: hidden;- Prevents content from painting outside the very small box created by the width and height declarations.
clip: rect(...);- Uses the legacy CSS clipping mechanism.
clipis deprecated and applies only to absolutely positioned elements. Modern implementations generally useclip-path. width: 1px; height: 1px;- Leaves the element with a non-zero box while reducing its visible footprint to approximately one pixel.
word-wrap: normal;- Attempts to avoid aggressive wrapping, but it does not provide the same explicit no-wrap behavior as
white-space: nowrap.
Drupal's reveal rule
Drupal does not make all visually hidden content focus-revealable. Authors add the additional focusable class. Drupal then overrides several hidden-state properties when the element is active or contains focus.
This works, but it has a maintenance cost: every hiding declaration that matters while visible may need a corresponding reset.
The A11Y Project
.visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
clip: rect(0 0 0 0);- Legacy fallback. It clips the visible area to nothing. For new Drupal CSS this is primarily historical compatibility evidence rather than a requirement.
clip-path: inset(50%);- Modern clipping technique. Insetting each edge by half the element's size collapses the visible clipped region while preserving the element for assistive technologies.
height: 1px; width: 1px;- Constrains the element to a tiny box rather than setting either dimension to zero.
overflow: hidden;- Stops text or replaced content from escaping the tiny box.
position: absolute;- Prevents the one-pixel element from affecting surrounding layout.
white-space: nowrap;- Prevents the contents from wrapping into many one-pixel-wide lines. This is useful because wrapping inside a tiny off-screen box has historically caused assistive-technology text concatenation problems.
Conditional hiding
.visually-hidden:not(:focus):not(:active) {
/* hiding declarations */
}
This model reverses Drupal's current logic. Instead of hiding first and later trying to undo each declaration, the hiding rule simply stops matching when the element receives focus or becomes active.
That is simpler for directly focusable elements such as skip links. It does not, by itself, reveal a hidden wrapper when a child inside the wrapper receives focus.
GOV.UK Frontend
GOV.UK uses the modern clipped one-pixel pattern, retains clip as a legacy fallback, uses white-space: nowrap, and adds generated non-breaking spaces before and after visually hidden content to protect text boundaries in screen-reader output.
.visually-hidden::before {
content: "\00a0";
}
.visually-hidden::after {
content: "\00a0";
}
This addresses a different failure from wrapping inside the hidden element. It targets the boundary between visible text and hidden text, such as the Drupal issue where “Place block” and “in the Header region” can be concatenated.
GOV.UK also applies user-select: none. That is a product choice that prevents accidentally copying visually hidden text. It is not required to hide content, so Drupal should decide independently whether it wants that behavior.
Understanding :focus-within
:focus and :focus-within are not interchangeable
:focus matches an element only when that element itself currently has focus.
<a class="visually-hidden" href="#main">
Skip to main content
</a>
When the link receives keyboard focus, .visually-hidden:focus matches because the link itself is focused.
:focus-within is broader. It matches if the element itself is focused or if any descendant inside it is focused.
<div class="visually-hidden focusable">
<a href="#main">Skip to main content</a>
</div>
In this example the div cannot normally receive keyboard focus. The link can. When the link receives focus:
- the link matches
:focus; - the wrapper does not match
:focus; - the wrapper does match
:focus-within.
That distinction explains why Drupal currently uses :focus-within. It allows a hidden container to reveal itself when an interactive descendant receives focus.
Why this matters for keyboard users
A keyboard user must never be able to move focus onto an interactive element that remains visually hidden. If a utility can be applied to a wrapper containing focusable descendants, direct :focus on the wrapper is insufficient.
:focus-within lets CSS react to the actual focus path through the DOM rather than requiring every focusable child to carry a second class or duplicate reveal rule.
Why this matters for screen magnification
A user working at high zoom or with screen magnification often follows keyboard focus visually. Revealing the whole relevant wrapper can provide context around the newly focused child. If only the child is exposed while its wrapper remains clipped to one pixel, the focused content can be difficult or impossible to locate.
Why this matters for Drupal compatibility
Drupal already exposes this behavior through .visually-hidden.focusable:focus-within. Replacing it with an implementation based only on :focus would be a behavioral change, not merely a modernization of CSS syntax.
This does not mean every design system needs :focus-within. GOV.UK's helper is designed primarily to be placed on the focusable element itself. Drupal's utility is more general and already supports hidden wrappers.
Conditional hiding with :focus-within
The cleaner Drupal model is to combine The A11Y Project's conditional-hiding architecture with Drupal's broader focus behavior:
.visually-hidden.focusable:not(:active):not(:focus-within) {
/* hidden-state declarations */
}
When neither the element nor anything inside it has focus, the hidden-state declarations apply. As soon as focus enters the element or one of its descendants, the selector no longer matches. The browser therefore falls back to the component's normal CSS rather than relying on a list of reset declarations.
Why not apply this to every .visually-hidden element?
That is a possible future simplification, but Drupal currently distinguishes permanently visually hidden content from content intended to become visible on focus. Keeping the focusable class preserves the existing API while allowing the internal CSS architecture to improve.
Proposed Drupal adaptation
.visually-hidden:not(.focusable),
.visually-hidden.focusable:not(:active):not(:focus-within) {
position: absolute !important;
width: 1px !important;
height: 1px !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
clip-path: inset(50%) !important;
border: 0 !important;
white-space: nowrap !important;
}
.visually-hidden:not(.focusable)::before,
.visually-hidden:not(.focusable)::after {
content: "\00a0";
}
This combines:
- The A11Y Project's conditional-hiding model;
- Drupal's existing
:focus-withinbehavior; - modern
clip-pathrather than relying on deprecatedclip; - GOV.UK's generated-space mitigation for visible/hidden text boundaries;
- no inherited
user-select: nonerestriction.
The generated-space selector remains an area that requires assistive-technology testing. The test page deliberately keeps this observable rather than declaring the proposal correct in advance.
Skip link target: removing tabindex="-1"
The proposal is not only a CSS change. It also changes how the skip link's destination is marked up.
Current Drupal
<a href="#main-content">Skip to main content</a>
<main>
<a id="main-content" tabindex="-1"></a>
...
</main>
The second anchor is not the skip link. It is an empty destination anchor made focusable with tabindex="-1" so that activating the skip link moved keyboard focus onto it, and the next Tab continued from there rather than jumping back to the top of the page.
Proposed
<a href="#main-content">Skip to main content</a>
<main id="main-content">
...
</main>
The fragment id moves onto the semantic <main> landmark, and the empty destination anchor and its tabindex="-1" are removed.
Why this is expected to work
Modern browsers implement a sequential focus navigation starting point. Following an in-page fragment link sets that starting point at the target, so the next Tab resumes from the target even when the target is not itself focusable. The tabindex="-1" workaround is therefore no longer required in supported browsers. Adding tabindex="-1" to a landmark also has reported side effects, such as interfering with the iOS back gesture, which is a further reason to prefer the landmark-only form.
Why this needs real testing, not just markup inspection
This is the change most at risk of being rearranging deck chairs
: the markup is objectively simpler, but simpler markup is not automatically a better experience. What matters is behavioral:
- After activating the skip link, does the next Tab actually land inside the main content?
- Does the screen reader move its reading position to the main region, or is only the tab order affected?
Neither question can be answered by checking the generated HTML. Both require driving a real browser and a real screen reader. The variant pages under the variants index exist so each skip-target pattern can be observed independently rather than assumed.
What this comparison can and cannot prove
The claims below differ in how strongly they can be tested. Reading a stylesheet or the DOM proves a difference; it does not prove that difference reaches a user. Only assistive-technology observation can confirm the latter. The Finding column records what was actually observed to date, not what is assumed.
Test environment for findings: VoiceOver on macOS 26, with WebKit and Chromium via Playwright. These are early results on one screen reader; see the testing plan for what still needs to be checked before any conclusion is treated as settled.
| Claim | Deterministic check | What only AT can confirm | Finding to date |
|---|---|---|---|
| Boundary spaces (visible/hidden text not concatenated) | Proposed adds ::before/::after \00a0; current Drupal does not | Whether the screen reader actually speaks a boundary between the words. A textContent check is blind to this: both words are present in the DOM either way. | Real improvement on NVDA + Chrome; no effect on VoiceOver. NVDA 2026.1.1 + Chrome concatenated Place blockin the Header regionwithout the spaces and spoke a clean boundary with them. Neutral on NVDA + Firefox (separate nodes) and on VoiceOver + WebKit (clean either way). See the testing plan. |
| No wrapping of long hidden text | white-space: nowrap is computed | Whether historical concatenation from tiny-box wrapping is avoided in spoken output | CSS difference confirmed; spoken effect not isolated from the boundary-space case yet. |
| Modern clipping | clip-path: inset(50%) present, clip not relied upon | (no meaningful AT-only aspect) | Cosmetic modernization; no user-facing difference expected or found. |
:focus-within reveals hidden wrappers | Wrapper leaves the clipped 1px state when a descendant is focused | Whether a magnification user can locate the revealed context | Real and differential. :focus-within (proposed, current Drupal) reveals the wrapper; :focus-only (GOV.UK, A11Y Project) leaves it hidden. Holds on WebKit and Blink. |
Skip target without tabindex="-1" | #main-content is on <main>; no tabindex | Whether keyboard focus and the screen-reader reading position actually resume in the main region after activation | Works. After activating the skip link, the next Tab resumes inside <main> for every target variant, including the no-tabindex form. Holds on WebKit and Blink. |
Three findings stand out, and one shows exactly why testing on more than one screen reader matters. The :focus-within reveal and the skip-target simplification are demonstrated improvements, confirmed across two rendering engines. The boundary-space mitigation showed no effect on VoiceOver — which, on that platform alone, looked like a change that only tidies the stylesheet. But cross-screen-reader testing (NVDA 2026.1.1 + Chrome, reported in the Drupal accessibility review) found that without the generated spaces NVDA actually concatenates the words — Place blockin the Header region
— and the mitigation fixes it. So the mitigation is a real improvement where it matters and harmless elsewhere; the VoiceOver result was the outlier, not the rule. It is also not solved by white-space: nowrap alone: that prevents a different, tiny-box wrapping problem, and NVDA still concatenated with nowrap present but the generated spaces absent.
Arguments against changing Drupal, and for the other models
A proposal that lists no counter-arguments is advocacy, not analysis. Here is the honest case against the proposed change, and the case for keeping closer to GOV.UK or The A11Y Project.
Arguments against changing Drupal's current approach at all
- Scale and churn risk. This utility ships on hundreds of thousands of sites. Any change to a base class risks subtle regressions in themes and modules that override or depend on the current computed values — for example code keyed to
clip: rect(1px, 1px, 1px, 1px)or to today'sword-wrapbehavior. The bar for touching it should be a demonstrated user benefit, not tidier source. - The
clipdeprecation is cosmetic.clipis deprecated but works in every browser and will for years. Moving toclip-pathis modernization for its own sake unless it fixes a real, observed bug. Our testing found no user-facing difference from that change. - The architecture change is invisible to users. The shift from Drupal's reset model to a conditional-hiding model (
:not(:active):not(:focus-within)) is cleaner to maintain, but that is a developer-experience argument, not an accessibility one — and DX changes to a base utility still carry regression risk. - Some benefits are engine-specific. The boundary-space mitigation helps NVDA + Chrome but is neutral on Firefox and VoiceOver; the
clip-pathmodernization is cosmetic. A change to a base utility is easier to justify when its benefit is broad; here the benefits are real but each applies to a subset of engines, so the value is in the sum, not any single line.
Should .focusable be dropped entirely?
A reviewer in the Drupal accessibility discussion argued that the reveal should apply to bare .visually-hidden:not(:active):not(:focus-within), not only to .visually-hidden.focusable. The reasoning: there should never be a focused element that stays visually hidden, so any visually hidden element that receives focus (or contains focus) should reveal. Applying the reveal to all .visually-hidden would only change behavior for elements that should not have been hidden while focused in the first place.
In principle this is correct, and it is a cleaner model. The proposal keeps the .focusable gate for a pragmatic reason: changing the base .visually-hidden behavior on hundreds of thousands of sites is the largest possible version of the churn risk above. Some sites may have .visually-hidden on something that receives focus and rely, however wrongly, on it staying hidden. The .focusable-gated form is the safer migration even if the broader form is more correct. This is a genuine, unresolved design tension — more correct versus safer to roll out — not a settled question.
Open technical points from review
white-space: nowrapdoes not replace the generated spaces. They address different bugs:nowrapprevents tiny-box line-wrapping; the generated spaces prevent the accessibility tree from concatenating adjacent text runs. NVDA + Chrome concatenated withnowrappresent but the spaces absent, so both are needed.borderandpaddingare likely redundant. Withwidth/height: 1px,overflow: hidden, andclip-path, they cannot render visibly. They are defensive resets; keeping them is cheap insurance, dropping them is defensible.margin: 0has a specific rationale worth confirming. GOV.UK's stylesheet notes that a negative margin can cause text to be announced in the wrong order in VoiceOver on macOS. That is a concrete, testable claim; it is now a scenario on the test page rather than an assumption.- If
user-select: noneis ever added, it needs the-webkit-prefix. GOV.UK shipsuser-select: noneand relies on its build to add-webkit-user-select: none; Safari only honors the prefixed form. The current proposal omitsuser-selectentirely, so this only applies if Drupal decides to adopt the clipboard-protection behavior — in which case the prefix must be explicit.
The case for the GOV.UK model
- Battle-tested at national scale and actively maintained.
user-select: noneprevents visually hidden text from being copied into the clipboard accidentally — a small but real behavior Drupal currently lacks.- Its
\00a0boundary mitigation is grounded in documented real-world screen-reader bugs, which is arguably more evidence-based than assuming the boundary is always fine. - Counter-argument: its reveal is designed for the focusable element itself, not a wrapper. We measured that the GOV.UK rule leaves a hidden wrapper clipped when a descendant link is focused — a keyboard user can focus a link that stays visually hidden. For Drupal's more general utility, that is a regression, not a win.
The case for The A11Y Project model
- The simplest and most widely referenced modern pattern, using
clip-pathand conditional hiding. - Easy to reason about and to teach.
- Counter-argument: it shares the same
:focus-only limitation — the wrapper does not reveal on descendant focus (measured) — and it has no boundary-space handling. It is a good pattern for directly focusable elements, but insufficient for Drupal's wrapper use case.
Where that leaves the proposal
Following the evidence: the proposed model's two demonstrated advantages over both GOV.UK and The A11Y Project are the :focus-within wrapper reveal (real and differential across two engines) and the generated boundary spaces (fixes real NVDA + Chrome concatenation that GOV.UK and A11Y Project variants without them do not). The GOV.UK model separately offers clipboard protection via user-select: none but fails the wrapper-reveal case; The A11Y Project model is simplest but fails the wrapper case and has no boundary handling. The remaining open questions are narrower than the whole proposal: whether to drop .focusable (correctness versus rollout safety), whether margin: 0 is load-bearing for VoiceOver order, and whether to adopt user-select. The clip-path modernization stays cosmetic. That is a proposal resting mostly on demonstrated benefits, with a short list of scoped decisions still to make — not on assumption.
Comparison summary
| Approach | Modern clip-path | No wrapping | Generated boundary spaces | Conditional hiding | Descendant focus | Skip target |
|---|---|---|---|---|---|---|
| Current Drupal | No | Partial | No | No, reset model | Yes, :focus-within | Empty tabindex="-1" anchor |
| A11Y Project | Yes | Yes | No | Yes | No with direct :focus | tabindex="-1" on <main> |
| GOV.UK | Yes | Yes | Yes | Yes | Not part of its direct-focus helper | tabindex="-1" on <main> + focus JS |
| Proposed Drupal | Yes | Yes | Yes | Yes | Yes, :focus-within | id on <main>, no tabindex (SFNSP) |