Focus Management
focus management css focus management complements javascript focus traps in modals, drawers, and spa route changes — scroll-margin for fixed
Introduction
CSS focus management complements JavaScript focus traps in modals, drawers, and SPA route changes — scroll-margin for fixed headers, z-index stacking, :focus-visible styling, and :has() selectors to adapt layout when dialogs open. WCAG 2.4.11 Focus Not Obscured is primarily a CSS layout problem.
Business problem
Users lose context when focus moves under sticky headers, behind cookie banners, or off-screen in horizontal scroll containers — especially keyboard and low-vision zoom users.
- 2.4.11 AA: Focused element not entirely hidden by author-created content.
- 2.4.12 AAA: No part of focus indicator obscured.
Why this feature exists
Sticky and fixed positioning proliferated in modern UI — headers, chat widgets, toasts stack in z-index layers. Without scroll-margin and stacking discipline, focus rings render under opaque overlays.
- scroll-margin / scroll-padding: CSS Scroll Snap module — offsets scrollIntoView for focus navigation.
- :has(): Style page when dialog open — e.g. prevent body scroll, dim background.
Browser rendering perspective
Focus scroll — browser calls scrollIntoView on focused element; scroll-margin-top adjusts final position relative to scrollport, accounting for fixed headers without JS.
- Composite layers: High z-index fixed elements paint above focus ring if stacking context wrong.
Internal browser workflow
Modal open flow: JS moves focus to dialog → CSS ensures dialog above chrome (z-index) → focused element scroll-margin clears sticky header → :focus-visible ring visible on dark overlay backdrop context.
- Route change: Move focus to h1 with tabindex=-1 — style :focus outline none on heading if programmatic only.
Feature deep dive
Focus management CSS toolkit:
:root {--sticky-header: 72px;--focus-ring: 2px solid #2563eb;}:focus-visible { outline: var(--focus-ring); outline-offset: 2px; }:focus { scroll-margin-top: var(--sticky-header); }.dialog { z-index: 1000; }.site-header { z-index: 100; }body:has(.dialog[open]) { overflow: hidden; }
Syntax
Programmatic focus target (route heading):
h1:focus { outline: none; } /* tabindex=-1 programmatic focus — provide visible page change via other cues */h1:focus-visible { outline: var(--focus-ring); } /* if user tabs to it */
Examples
Chat bubble covering submit: Raise z-index of focused form field via :focus-within on fieldset or increase scroll-margin-bottom above widget height.
Real-world use
Radix, Headless UI, and Adobe React Spectrum document focus ring tokens and scroll-margin integration. Chrome devrel published focus-not-obscured debugging guide.
Real production example
Design token --scroll-margin-top per layout variant — marketing long header vs app compact header.
Enterprise use case
Modal component CSS contract: z-index scale, scroll-margin on dialog content, body scroll lock via :has.
Accessibility considerations
Focus not obscured audits — tab through form behind sticky footer CTA; measure overlap in DevTools screenshot.
Performance considerations
body:has() can be expensive on huge DOM — scope to direct child dialog open selector where possible.
SEO considerations
N/A direct — focus management is operability not crawl.
Scalability considerations
Global z-index token scale — 100 header, 200 dropdown, 1000 modal — prevents ad-hoc 99999 wars obscuring focus unpredictably.
Common production issues
Incidents: iOS Safari address bar resize broke scroll-margin calc — focus hidden under header. Intercom widget z-index above modal focus ring.
Debugging guide
Tab and screenshot each step — overlay semi-transparent debug mode shows stacking. Chrome Layers panel for z-index context.
Best practices
- Define z-index token scale in design system.
- Apply scroll-margin-top on all focusable elements matching sticky header height.
- Use :has(.dialog[open]) for scroll lock instead of inline body styles when possible.
- Test focus not obscured with 400% zoom.
- Pair CSS scroll-margin with JS focus trap in modals.
Anti-patterns
- z-index: 999999 on marketing widgets without focus consideration.
- Removing focus outline on h1 programmatic focus without alternative cue.
- Fixed bottom bars without padding-bottom on main content — focus lands under bar.
Trade-offs
- Benefit: scroll-margin fixes most sticky header obscuring without JS.
- Cost: Per-layout header height variables multiply in white-label.
Architecture review questions
- Is focused control ever hidden under sticky/fixed UI?
- Does modal z-index exceed all page chrome?
- Is scroll-margin set for header height on focusable elements?
Interview questions
How does CSS fix focus obscured by sticky header?(Advanced)
scroll-margin-top on :focus/:focus-visible matching header height; ensure modal z-index above header; optionally :has dialog open to hide/shrink sticky promo. Test 2.4.11 with keyboard tab + zoom.
Follow-up: JS vs CSS focus management split?
Hands-on exercise
Exercise: Add sticky header + form; tab to first field; fix scroll-margin; open modal and verify focus ring not obscured.
Staff engineer notes
- Film keyboard tab through checkout — obscured focus visible in 30 seconds.
- Z-index tokens prevent focus wars across teams.
Common pitfalls
- calc header height wrong on mobile dynamic viewport — use env(safe-area-inset-top) too.
Try it yourself
Edit the CSS panel — the preview updates live. Use DevTools Performance and Accessibility panels to validate.
Try it yourself
Summary
CSS focus management uses scroll-margin, z-index scales, :focus-visible rings, and :has() scroll lock so focused elements remain visible and unobscured — meeting WCAG 2.4.11 across sticky headers, modals, and widgets.
Key takeaways
- scroll-margin and z-index tokens fix most focus-not-obscured failures.
- Pair CSS with JS focus trap for modals.
- Test at 400% zoom with sticky chrome present.