View contents
Focus Indicators Implementation Guide: CSS Techniques for Accessible Focus States
Introduction
Focus indicators are the visual feedback that shows which element currently has keyboard focus. While browsers provide default focus styles, they’re often insufficient—too subtle, removed by CSS resets, or inconsistent across browsers. This guide provides comprehensive CSS techniques for implementing focus indicators that meet WCAG 2.2 requirements while maintaining design aesthetics.
WCAG 2.2 introduced new focus-related criteria (2.4.11, 2.4.12, and 2.4.13) that establish specific requirements for focus indicator visibility, size, and contrast. Understanding and implementing these requirements ensures your site is accessible to keyboard users.
Understanding WCAG Focus Requirements
WCAG 2.4.7: Focus Visible (Level AA)
The focused element must have a visible focus indicator. This is the foundational requirement—if users can tab to it, they must be able to see focus.
WCAG 2.4.11: Focus Not Obscured (Minimum) (Level AA)
When an element receives focus, it can’t be entirely hidden by author-created content like:
- Sticky headers or footers
- Modal overlays
- Cookie banners
- Chat widgets
WCAG 2.4.13: Focus Appearance (Level AAA)
For enhanced compliance, focus indicators must:
- Have an area of at least the perimeter of the element × 2 CSS pixels
- Have a 3:1 contrast ratio against unfocused state
- Have a 3:1 contrast ratio against adjacent colors
CSS Focus Pseudo-Classes
The :focus Pseudo-Class
Applies whenever an element receives focus (keyboard, mouse, or touch):
/* Basic focus styling */
button:focus {
outline: 2px solid blue;
}
/* Problem: Shows for mouse clicks too */
The :focus-visible Pseudo-Class
Applies only when focus should be visible (primarily keyboard):
/* Modern approach - keyboard users only */
button:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
/* Remove default outline for non-keyboard focus */
button:focus:not(:focus-visible) {
outline: none;
}
The :focus-within Pseudo-Class
Applies when any descendant has focus (useful for form groups):
/* Highlight entire form group when any field focused */
.form-group:focus-within {
background-color: #f0f7ff;
border-left: 3px solid #005fcc;
}
Browser Support Strategy
/* Fallback for older browsers */
button:focus {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
/* Modern browsers that support :focus-visible */
button:focus:not(:focus-visible) {
outline: none;
}
button:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
Focus Indicator Design Patterns
Pattern 1: Outline with Offset
The most reliable and widely-compatible approach:
:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
/* Inverted for dark backgrounds */
.dark-theme :focus-visible {
outline: 3px solid #66b3ff;
outline-offset: 2px;
}
Pros:
- Works on all element shapes
- Doesn’t affect layout
- High browser support
- Clear visibility
Cons:
- Can’t have rounded corners (outline doesn’t follow border-radius)
- May overlap adjacent elements
Pattern 2: Box Shadow
Creates rounded focus indicators that follow element shape:
:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5);
}
/* Solid version */
:focus-visible {
outline: none;
box-shadow: 0 0 0 3px #005fcc;
}
/* Double ring effect */
:focus-visible {
outline: none;
box-shadow:
0 0 0 2px #ffffff,
0 0 0 4px #005fcc;
}
Pros:
- Follows border-radius
- Can create multiple rings
- Supports transparency
Cons:
- May conflict with existing box-shadows
- Can be clipped by
overflow: hidden
Pattern 3: Border Change
Uses border color change for focused state:
/* Base state with transparent border */
.input-field {
border: 2px solid #ccc;
transition: border-color 0.15s ease;
}
/* Focused state */
.input-field:focus-visible {
border-color: #005fcc;
outline: none;
}
/* With additional indicator */
.input-field:focus-visible {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.25);
outline: none;
}
Pros:
- Clean, modern appearance
- No additional space needed
Cons:
- Requires base border
- May not be visible enough alone
Pattern 4: Background Change
Changes background color when focused:
.nav-link:focus-visible {
background-color: #e6f0ff;
outline: 2px solid #005fcc;
}
/* For buttons */
.button:focus-visible {
background-color: #004494;
outline: 2px solid #002255;
outline-offset: 2px;
}
Pros:
- High visibility
- Clear state change
Cons:
- May conflict with design
- Requires color planning
Component-Specific Implementation
Navigation Links
.nav-link {
position: relative;
padding: 0.5rem 1rem;
}
.nav-link:focus-visible {
outline: none;
background-color: rgba(0, 95, 204, 0.1);
border-radius: 4px;
}
/* Underline indicator */
.nav-link::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: transparent;
transition: background 0.15s ease;
}
.nav-link:focus-visible::after {
background: #005fcc;
}
Buttons
/* Primary button */
.btn-primary {
background: #005fcc;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
}
.btn-primary:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
box-shadow: 0 0 0 5px rgba(0, 95, 204, 0.3);
}
/* Secondary button */
.btn-secondary {
background: transparent;
color: #005fcc;
border: 2px solid #005fcc;
}
.btn-secondary:focus-visible {
background: #e6f0ff;
outline: 2px solid #003d99;
outline-offset: 2px;
}
Form Inputs
.form-input {
border: 2px solid #ccc;
padding: 0.75rem;
border-radius: 4px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-input:focus-visible {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.2);
outline: none;
}
/* Error state with focus */
.form-input.error:focus-visible {
border-color: #dc3545;
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.2);
}
Checkboxes and Radios
/* Custom checkbox */
.checkbox-custom {
width: 20px;
height: 20px;
border: 2px solid #666;
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.checkbox-input:focus-visible + .checkbox-custom {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.3);
}
/* Custom radio */
.radio-custom {
width: 20px;
height: 20px;
border: 2px solid #666;
border-radius: 50%;
}
.radio-input:focus-visible + .radio-custom {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.3);
}
Cards and Interactive Containers
.card-link {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
text-decoration: none;
transition: box-shadow 0.2s, transform 0.2s;
}
.card-link:focus-visible {
outline: none;
border-color: #005fcc;
box-shadow:
0 0 0 3px rgba(0, 95, 204, 0.3),
0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
Handling Focus with Sticky Headers
The Problem
<header class="sticky-header">Navigation</header>
<main>
<a href="#content">Link that gets hidden</a>
</main>
When tabbing to elements near the top of the viewport, sticky headers can cover them.
Solution: Scroll Margin
/* Add scroll margin to all focusable elements */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
scroll-margin-top: 100px; /* Height of sticky header + padding */
}
/* Or apply to all focusable elements */
:focus-visible {
scroll-margin-top: var(--header-height, 80px);
scroll-margin-bottom: var(--footer-height, 0);
}
Solution: Scroll Padding on Container
html {
scroll-padding-top: 100px;
}
/* With CSS custom property */
:root {
--header-height: 80px;
}
html {
scroll-padding-top: calc(var(--header-height) + 20px);
}
Framework Integration
React Component Pattern
// FocusRing.tsx
import { forwardRef } from 'react'
interface FocusRingProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
offset?: number
}
export const FocusRing = forwardRef<HTMLDivElement, FocusRingProps>(
({ children, offset = 2, className, ...props }, ref) => {
return (
<div
ref={ref}
className={`focus-ring-container ${className}`}
style={{ '--focus-offset': `${offset}px` } as React.CSSProperties}
{...props}
>
{children}
</div>
)
}
)
.focus-ring-container:focus-visible {
outline: 3px solid var(--focus-color, #005fcc);
outline-offset: var(--focus-offset, 2px);
}
Vue 3 Composable
<!-- FocusIndicator.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
color?: string
offset?: number
}>()
const element = ref<HTMLElement | null>(null)
const isFocusVisible = ref(false)
function handleFocus() {
isFocusVisible.value = true
}
function handleBlur() {
isFocusVisible.value = false
}
</script>
<template>
<div
ref="element"
:class="{ 'focus-visible': isFocusVisible }"
:style="{
'--focus-color': color || '#005fcc',
'--focus-offset': `${offset || 2}px`
}"
@focus="handleFocus"
@blur="handleBlur"
tabindex="0"
>
<slot />
</div>
</template>
<style scoped>
.focus-visible {
outline: 3px solid var(--focus-color);
outline-offset: var(--focus-offset);
}
</style>
Tailwind CSS Utilities
/* Custom Tailwind utilities */
@layer utilities {
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2;
}
.focus-ring-inset {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset;
}
}
<!-- Usage -->
<button class="focus-ring">Click me</button>
<input class="focus-ring-inset" type="text" />
Testing Focus Indicators
Manual Testing Protocol
-
Keyboard navigation test:
- Disconnect mouse/trackpad
- Tab through entire page
- Verify every focusable element is visible
- Check Shift+Tab works correctly
-
Focus contrast test:
- Use color contrast checker on focus state
- Verify 3:1 ratio against adjacent colors
- Test in both light and dark themes
-
Sticky element test:
- Tab to elements near page top
- Verify focus isn’t hidden behind headers
- Check modal and overlay interactions
Automated Testing
// Cypress test for focus visibility
describe('Focus Indicators', () => {
it('should show visible focus on all interactive elements', () => {
cy.visit('/')
// Tab through focusable elements
cy.get('body').tab()
cy.focused().should('have.css', 'outline-style', 'solid')
// Check specific elements
cy.get('a[href]').first().focus()
cy.focused()
.should('have.css', 'outline-width')
.and('not.eq', '0px')
})
it('should not hide focus behind sticky header', () => {
cy.visit('/')
cy.get('.main-content a').first().focus()
cy.focused().then($el => {
const rect = $el[0].getBoundingClientRect()
const headerHeight = 80
expect(rect.top).to.be.greaterThan(headerHeight)
})
})
})
Accessibility Linting
// ESLint plugin jsx-a11y rules
module.exports = {
rules: {
'jsx-a11y/no-noninteractive-tabindex': 'error',
'jsx-a11y/tabindex-no-positive': 'error',
}
}
Common Mistakes and Solutions
Mistake 1: Global outline:none
/* DON'T */
*:focus {
outline: none;
}
/* DO */
*:focus:not(:focus-visible) {
outline: none;
}
*:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
Mistake 2: Forgetting Custom Components
/* DON'T forget custom interactive elements */
[role="button"]:focus-visible,
[role="link"]:focus-visible,
[tabindex="0"]:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
Mistake 3: Low Contrast Indicators
/* DON'T - low contrast */
:focus-visible {
outline: 1px solid #ccc;
}
/* DO - high contrast */
:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
Summary and Checklist
Focus Implementation Checklist
- [ ] All interactive elements have visible focus indicators
- [ ] Using
:focus-visiblefor keyboard-only focus - [ ] Focus indicators have 3:1 contrast ratio
- [ ] Focus is not obscured by sticky/fixed elements
- [ ] Custom components include focus styles
- [ ] Form inputs show clear focus states
- [ ] Focus works in both light and dark themes
- [ ] Tested with keyboard-only navigation
Quick Reference
| Technique | Best For | Contrast Needs |
|---|---|---|
| Outline + offset | All elements | 3:1 vs adjacent |
| Box-shadow | Rounded elements | 3:1 vs background |
| Border change | Form inputs | 3:1 vs unfocused |
| Background change | Navigation | 3:1 vs default |
References
- W3C - WCAG 2.2 SC 2.4.7 Focus Visible
- W3C - WCAG 2.2 SC 2.4.11 Focus Not Obscured
- W3C - Focus Appearance Understanding
- Sara Soueidan - A Guide to Designing Accessible Focus Indicators
- WebAIM - Keyboard Accessibility