Accessibility pattern · Interaction and motion

Focus indicator

The ring is an outline in the focus color between two halos in the surface color, so one band contrasts with whatever sits behind it: a light page, a dark band or a brand fill. Use Tab in the gallery and the meter measures the ring on the focused element: its width, its contrast against the background and whether anything covers it.

WCAG criteria
5
Keyboard rules
4
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Focus meter

Press Tab to measure the ring on whatever has focus.

Ring width
–
Gap to the edge
–
Ring against background
–
Halo against background
–
  • 2.4.7 Focus visible Not measured
  • 1.4.11 Non-text contrast Not measured
  • 2.4.11 Focus not obscured Not measured
  • 2.4.13 Focus appearance Not measured

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Tab or ShiftTabMoves through the gallery; whatever takes focus shows the two-color ring, and the meter measures it.
Arrow Right or Arrow LeftIn the menu tabs, moves to the next or previous tab and shows its panel.
Home or EndIn the menu tabs, moves to the first or last tab.
Enter or SpaceUses the focused button; Enter also follows a link. The ring stays on the element afterwards.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Focus reaches the booking buttonBook a table, button
Focus reaches the Save icon buttonSave, toggle button, not pressed
Focus reaches the first menu tabLunch, tab, selected, 1 of 3
Focus reaches the card on the brand bandThali of the day, Twelve dishes, ₹320, link
The ring appearsNothing extra: the ring is visual, and the name and role are what is read
A table is bookedTable request sent. We confirm by SMS within the hour.

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-focus" data-ap-focus-indicator>
  <div class="ap-focus__layout">
    <section class="ap-focus__meter" aria-labelledby="focus-meter-name">
      <div class="ap-focus__meter-top">
        <h3 class="ap-focus__meter-name" id="focus-meter-name">Focus meter</h3>
        <p class="ap-focus__target" data-ap-target><span class="ap-focus__target-idle">Press Tab to measure the ring on whatever has focus.</span></p>
      </div>
      <dl class="ap-focus__nums">
        <div class="ap-focus__num"><dt>Ring width</dt><dd><span data-ap-m="width" translate="no">–</span></dd></div>
        <div class="ap-focus__num"><dt>Gap to the edge</dt><dd><span data-ap-m="gap" translate="no">–</span></dd></div>
        <div class="ap-focus__num"><dt>Ring against background</dt><dd><span class="ap-focus__swatch" data-ap-swatch="ring" aria-hidden="true"></span><span data-ap-m="ring" translate="no">–</span></dd></div>
        <div class="ap-focus__num"><dt>Halo against background</dt><dd><span class="ap-focus__swatch" data-ap-swatch="halo" aria-hidden="true"></span><span data-ap-m="halo" translate="no">–</span></dd></div>
      </dl>
      <ul class="ap-focus__checks">
      <li class="ap-focus__check" data-ap-check="visible" data-state="idle">
        <svg class="ap-focus__mark" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-focus__mark-idle" d="M7 12h10"/><path class="ap-focus__mark-pass" d="m5 12.5 4.5 4.5L19 7.5"/><path class="ap-focus__mark-fail" d="M7 7l10 10M17 7 7 17"/></svg>
        <span class="ap-focus__what"><span class="ap-focus__ref" translate="no">2.4.7</span> <span class="ap-focus__crit">Focus visible</span></span>
        <span class="ap-focus__verdict" data-ap-verdict>Not measured</span>
      </li>
      <li class="ap-focus__check" data-ap-check="contrast" data-state="idle">
        <svg class="ap-focus__mark" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-focus__mark-idle" d="M7 12h10"/><path class="ap-focus__mark-pass" d="m5 12.5 4.5 4.5L19 7.5"/><path class="ap-focus__mark-fail" d="M7 7l10 10M17 7 7 17"/></svg>
        <span class="ap-focus__what"><span class="ap-focus__ref" translate="no">1.4.11</span> <span class="ap-focus__crit">Non-text contrast</span></span>
        <span class="ap-focus__verdict" data-ap-verdict>Not measured</span>
      </li>
      <li class="ap-focus__check" data-ap-check="obscured" data-state="idle">
        <svg class="ap-focus__mark" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-focus__mark-idle" d="M7 12h10"/><path class="ap-focus__mark-pass" d="m5 12.5 4.5 4.5L19 7.5"/><path class="ap-focus__mark-fail" d="M7 7l10 10M17 7 7 17"/></svg>
        <span class="ap-focus__what"><span class="ap-focus__ref" translate="no">2.4.11</span> <span class="ap-focus__crit">Focus not obscured</span></span>
        <span class="ap-focus__verdict" data-ap-verdict>Not measured</span>
      </li>
      <li class="ap-focus__check" data-ap-check="appearance" data-state="idle">
        <svg class="ap-focus__mark" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-focus__mark-idle" d="M7 12h10"/><path class="ap-focus__mark-pass" d="m5 12.5 4.5 4.5L19 7.5"/><path class="ap-focus__mark-fail" d="M7 7l10 10M17 7 7 17"/></svg>
        <span class="ap-focus__what"><span class="ap-focus__ref" translate="no">2.4.13</span> <span class="ap-focus__crit">Focus appearance</span></span>
        <span class="ap-focus__verdict" data-ap-verdict>Not measured</span>
      </li>
      </ul>
      <p class="ap-focus__why" data-ap-why></p>
    </section>

    <div class="ap-focus__gallery">
      <p class="ap-focus__bad-note"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 9v4m0 4h.01M10.3 3.9 2.4 18a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/></svg><span>Bad versions: the link, the button and the tabs have no ring at all, the field, the icon buttons and the card get a faint 1 px ring, and the order list lets focus slide under its sticky bar.</span></p>

      <div class="ap-focus__tile ap-focus__tile--page" role="group" aria-labelledby="focus-cap-page">
        <p class="ap-focus__cap" id="focus-cap-page">On the page surface</p>
        <p class="ap-focus__text">Saffron Table serves Udupi thalis in Indiranagar, Bengaluru. <a class="ap-focus__link" href="#focus-menu">See the full menu</a> before you book.</p>
        <div class="ap-focus__book">
          <div class="ap-field ap-focus__guests">
            <label class="ap-label" for="focus-guests">Guests</label>
            <input class="ap-input" id="focus-guests" type="number" min="1" max="12" value="2" inputmode="numeric" />
          </div>
          <button type="button" class="ap-btn ap-btn--primary" data-ap-book>Book a table</button>
        </div>
      </div>

      <div class="ap-focus__tile ap-focus__tile--invert" role="group" aria-labelledby="focus-cap-invert">
        <div class="ap-focus__tile-top">
          <p class="ap-focus__cap" id="focus-cap-invert">On an inverted band</p>
          <div class="ap-focus__icons">
            <button type="button" class="ap-focus__icon" aria-label="Save" aria-pressed="false" data-ap-save>
              <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M19 21 12 16.5 5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2Z"/></svg>
            </button>
            <button type="button" class="ap-focus__icon" aria-label="Share" data-ap-share>
              <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>
            </button>
          </div>
        </div>
        <div class="ap-focus__tabs" role="tablist" aria-label="Menus">
          <button type="button" class="ap-focus__tab" role="tab" id="focus-tab-lunch" aria-selected="true" aria-controls="focus-panel-lunch">Lunch</button>
          <button type="button" class="ap-focus__tab" role="tab" id="focus-tab-dinner" aria-selected="false" aria-controls="focus-panel-dinner" tabindex="-1">Dinner</button>
          <button type="button" class="ap-focus__tab" role="tab" id="focus-tab-drinks" aria-selected="false" aria-controls="focus-panel-drinks" tabindex="-1">Drinks</button>
        </div>
        <p class="ap-focus__panel" role="tabpanel" id="focus-panel-lunch" aria-labelledby="focus-tab-lunch" tabindex="0">A thali of twelve dishes, served from noon until three.</p>
        <p class="ap-focus__panel" role="tabpanel" id="focus-panel-dinner" aria-labelledby="focus-tab-dinner" tabindex="0" hidden>Dosas and appams to order, from seven until eleven.</p>
        <p class="ap-focus__panel" role="tabpanel" id="focus-panel-drinks" aria-labelledby="focus-tab-drinks" tabindex="0" hidden>Filter coffee, buttermilk and fresh lime soda all day.</p>
      </div>

      <div class="ap-focus__tile ap-focus__tile--brand" role="group" aria-labelledby="focus-cap-brand">
        <p class="ap-focus__cap" id="focus-cap-brand">On the brand color</p>
        <a class="ap-focus__card" href="#focus-thali">
          <svg class="ap-focus__art" viewBox="0 0 96 64" aria-hidden="true" focusable="false"><circle class="ap-focus__plate" cx="48" cy="32" r="29"/><circle class="ap-focus__bowl ap-focus__bowl--a" cx="34" cy="19" r="8"/><circle class="ap-focus__bowl ap-focus__bowl--b" cx="54" cy="16" r="7"/><circle class="ap-focus__bowl ap-focus__bowl--c" cx="66" cy="31" r="7.5"/><ellipse class="ap-focus__rice" cx="42" cy="40" rx="13" ry="9"/><circle class="ap-focus__bowl ap-focus__bowl--a" cx="61" cy="47" r="6"/></svg>
          <span class="ap-focus__card-body">
            <span class="ap-focus__card-name">Thali of the day</span>
            <span class="ap-focus__card-meta">Twelve dishes, ₹320</span>
          </span>
        </a>
        <div class="ap-focus__brand-foot">
          <p class="ap-focus__brand-text">Festive menu from 20 October.</p>
          <button type="button" class="ap-focus__ghost" data-ap-gift>Gift a meal</button>
        </div>
      </div>

      <div class="ap-focus__tile ap-focus__tile--list" role="group" aria-labelledby="focus-cap-list">
        <p class="ap-focus__cap" id="focus-cap-list">In a list with a sticky bar</p>
        <div class="ap-focus__scroll" data-ap-scroll>
          <ul class="ap-focus__orders" aria-label="Your orders">
        <li><a class="ap-focus__order" href="#focus-order-4821"><span class="ap-focus__dish">Masala dosa, two</span> <span class="ap-focus__when">12 Sep · ₹240</span></a></li>
        <li><a class="ap-focus__order" href="#focus-order-4790"><span class="ap-focus__dish">Lunch thali</span> <span class="ap-focus__when">9 Sep · ₹320</span></a></li>
        <li><a class="ap-focus__order" href="#focus-order-4755"><span class="ap-focus__dish">Bisi bele bath</span> <span class="ap-focus__when">4 Sep · ₹180</span></a></li>
        <li><a class="ap-focus__order" href="#focus-order-4712"><span class="ap-focus__dish">Filter coffee, four</span> <span class="ap-focus__when">1 Sep · ₹160</span></a></li>
        <li><a class="ap-focus__order" href="#focus-order-4688"><span class="ap-focus__dish">Neer dosa with curry</span> <span class="ap-focus__when">28 Aug · ₹260</span></a></li>
        <li><a class="ap-focus__order" href="#focus-order-4651"><span class="ap-focus__dish">Mysore pak, half kilo</span> <span class="ap-focus__when">24 Aug · ₹350</span></a></li>
        <li><a class="ap-focus__order" href="#focus-order-4630"><span class="ap-focus__dish">Rava idli, two</span> <span class="ap-focus__when">20 Aug · ₹150</span></a></li>
          </ul>
          <div class="ap-focus__bar">
            <button type="button" class="ap-btn" data-ap-bills>Download bills</button>
          </div>
        </div>
      </div>

      <p class="ap-focus__status" role="status"></p>
    </div>
  </div>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 2.4.7 Focus Visible Level AA

    Every link, button, field, tab, card and icon button draws a ring when it has keyboard focus; nothing sets outline: none without a replacement.

  • 1.4.11 Non-text Contrast Level AA

    The ring and its halo are two colors that contrast with each other, so at least one band stands out from the light page, the dark band and the brand fill; the meter shows the ratios.

  • 2.4.11 Focus Not Obscured (Minimum) Level AA

    The order list has scroll-padding as tall as its sticky bar, so the browser stops a focused row above the bar instead of under it.

  • 2.4.12 Focus Not Obscured (Enhanced) Level AAA

    The same scroll-padding keeps the whole focused row in view, not just part of it.

  • 2.4.13 Focus Appearance Level AAA

    The ring is a 3 px outline 2 px out from the element, more than a 2 px perimeter, and its pixels change by at least 3:1 from the unfocused background through one of its bands.

Usage

When to use it

Use it

  • Every interface: each focusable element needs a ring, and one shared style keeps them consistent.
  • Components that sit on more than one background, like a button that appears on white cards, dark bands and brand fills.

Use something else

  • Removing the ring because it shows on click: use :focus-visible, which already leaves pointer clicks alone.
  • A color change alone, like a slightly darker button: it is not a ring, and it fails people who cannot tell the shades apart.
  • A box-shadow on its own as the ring: forced-colors mode drops shadows, so keep an outline.

Common failures

How it usually goes wrong

  • outline: none with nothing in its place

    A reset that removes the outline everywhere leaves keyboard users guessing where they are. Turn on the bad versions and Tab past the link and the button: nothing shows.

  • A ring that only works on one background

    An indigo ring on an indigo banner is invisible. The halo in the surface color separates the ring from any fill, which the brand band shows.

  • A faint 1 px ring

    A hairline in a light gray is technically drawn but nearly invisible, especially on high-resolution screens. Use 2 px or more, at 3:1 against what sits next to it.

  • Focus under a sticky header or footer

    The browser scrolls a focused element into view, but not out from under a sticky bar. scroll-padding as tall as the bar fixes it, in a scrolling panel or on the page.

  • Styling :focus, then removing it

    A ring on :focus also appears after a mouse click, so designers remove it. :focus-visible shows it for the keyboard and text fields only.

  • Clipped rings

    overflow: hidden on a parent cuts off a ring drawn outside the element. Give the container padding as wide as the ring, or draw it inset.

Notes

Building it

  • The ring is outline: 3px solid var(--ap-focus) with outline-offset: 2px, plus two box-shadow halos in var(--ap-surface): one fills the 2 px gap, one sits 2 px outside the ring. The outline survives forced-colors mode, where shadows are dropped.
  • Two colors with contrast C between them guarantee at least √C against any background. Indigo and white give 2.5:1 at worst on a mid-tone; near-black and white give 4.1:1, so use the text color as the ring on photos.
  • Set scroll-padding on any scrolling container with a sticky bar, and on html when the site header is fixed, so focus scrolling stops short of the bar.
  • The meter reads computed styles: the outline width, offset and color, the halo from box-shadow, and the first solid background behind the element. It checks covering by asking elementFromPoint what is on top at points across the element.
  • The demo's links point to #… because their pages do not exist here; in a real site they are ordinary links.

Sources: Understanding WCAG 2.2: Focus Appearance · Understanding WCAG 2.2: Focus Not Obscured (Minimum) · CSS Selectors Level 4: :focus-visible

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction