Visually hidden implementation breakdown

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 !important protects 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. clip is deprecated and applies only to absolutely positioned elements. Modern implementations generally use clip-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:

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 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:

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.

Each claim: how it is checked, and what has been found
ClaimDeterministic checkWhat only AT can confirmFinding to date
Boundary spaces (visible/hidden text not concatenated)Proposed adds ::before/::after \00a0; current Drupal does notWhether 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 region without 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 textwhite-space: nowrap is computedWhether historical concatenation from tiny-box wrapping is avoided in spoken outputCSS difference confirmed; spoken effect not isolated from the boundary-space case yet.
Modern clippingclip-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 wrappersWrapper leaves the clipped 1px state when a descendant is focusedWhether a magnification user can locate the revealed contextReal 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 tabindexWhether keyboard focus and the screen-reader reading position actually resume in the main region after activationWorks. 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

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

The case for the GOV.UK model

The case for The A11Y Project model

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

ApproachModern clip-pathNo wrappingGenerated boundary spacesConditional hidingDescendant focusSkip target
Current DrupalNoPartialNoNo, reset modelYes, :focus-withinEmpty tabindex="-1" anchor
A11Y ProjectYesYesNoYesNo with direct :focustabindex="-1" on <main>
GOV.UKYesYesYesYesNot part of its direct-focus helpertabindex="-1" on <main> + focus JS
Proposed DrupalYesYesYesYesYes, :focus-withinid on <main>, no tabindex (SFNSP)