Detailed guide

Focus Indicators Implementation Guide

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

.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

  1. Keyboard navigation test:

    • Disconnect mouse/trackpad
    • Tab through entire page
    • Verify every focusable element is visible
    • Check Shift+Tab works correctly
  2. Focus contrast test:

    • Use color contrast checker on focus state
    • Verify 3:1 ratio against adjacent colors
    • Test in both light and dark themes
  3. 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-visible for 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

  1. W3C - WCAG 2.2 SC 2.4.7 Focus Visible
  2. W3C - WCAG 2.2 SC 2.4.11 Focus Not Obscured
  3. W3C - Focus Appearance Understanding
  4. Sara Soueidan - A Guide to Designing Accessible Focus Indicators
  5. WebAIM - Keyboard Accessibility

Related articles

In the same category

Introduction

Ai Crawlability Explained

As AI assistants like ChatGPT, Claude, Gemini, and Perplexity become primary information sources, a new question emerges: Should you allow AI bots to access...

Detailed guide

Ai Crawler Management Guide

Managing AI crawler access requires understanding the diverse landscape of AI bots, their purposes, and the technical mechanisms to control them

Introduction

Alt Text Explained

Alt text (alternative text) provides a text description of images for users who cannot see them

Detailed guide

Alt Text Implementation Guide

This comprehensive guide covers the technical implementation of alt text across all image types and contexts

Introduction

Alt Text Seo Explained

Every image on your website is either helping or hurting your SEO

Detailed guide

Alt Text Seo Optimization Guide

Alt text optimization sits at the intersection of SEO, accessibility, and user experience