25 CSS Play / Pause Buttons07 / 25

Light JSMIT licensed

Accessible WAI-ARIA Play/Pause Button (WCAG 2.2)

The reference implementation, with the audit built in. A real <button> carrying aria-pressed, a 44 × 44 px target, a 3:1 focus indicator that survives forced-colors mode, a polite live region for state changes, and a side panel that prints exactly what a screen reader announces at each step — so you can see the semantics, not just trust them. This is the demo to copy when the component ships to production.

Published

Live Demo
Try it

The code

<section class="pp-07" aria-label="Accessible WAI-ARIA play pause button demo">
  <div class="pp-07__stage">
    <div class="pp-07__grid">
      <div class="pp-07__card">
        <p class="pp-07__eyebrow">Toggle button pattern</p>
        <button class="pp-07__btn" type="button" aria-pressed="false" aria-describedby="pp-07-hint">
          <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
            <path class="pp-07__p" d="M8.6 5.4v13.2L19 12z" fill="currentColor"/>
            <path class="pp-07__q" d="M8 5h3.1v14H8zM12.9 5H16v14h-3.1z" fill="currentColor"/>
          </svg>
          <span class="pp-07__name">Play</span>
        </button>
        <p class="pp-07__hint" id="pp-07-hint">Episode 214 — The Cost of Abstraction</p>
        <p class="pp-07__status" role="status">Paused</p>
      </div>
      <div class="pp-07__audit">
        <p class="pp-07__audit-h">Announced as</p>
        <p class="pp-07__say"><span class="pp-07__say-icon" aria-hidden="true">◎</span><span class="pp-07__say-text">“Play, toggle button, not pressed”</span></p>
        <ul class="pp-07__list">
          <li><b>2.5.8</b> Target 56 × 56 px (min 24)</li>
          <li><b>1.4.11</b> Focus ring ≥ 3:1, 2 px, offset</li>
          <li><b>4.1.2</b> aria-pressed carries state</li>
          <li><b>2.3.1</b> Motion behind reduced-motion</li>
          <li><b>1.4.1</b> State never color-only</li>
        </ul>
      </div>
    </div>
    <p class="pp-07__chip" aria-hidden="true">aria-pressed · role=&quot;status&quot; live region · forced-colors fallback</p>
  </div>
</section>
.pp-07,
.pp-07 *,
.pp-07 *::before,
.pp-07 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.pp-07 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--pp-07-bg);
  --pp-07-bg: oklch(0.97 0.006 250);
  --pp-07-card: oklch(1 0 0);
  --pp-07-ink: oklch(0.24 0.02 260);
  --pp-07-mut: oklch(0.52 0.02 260);
  --pp-07-line: oklch(0.9 0.008 260);
  --pp-07-acc: oklch(0.52 0.19 268);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--pp-07-ink);
}

.pp-07__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 22px;
  padding: clamp(20px,5vw,56px);
}

.pp-07__grid {
  display: flex;
  flex-wrap: wrap;
  gap: 18px;
  justify-content: center;
  align-items: stretch;
  width: min(100%,720px);
}

.pp-07__card,
.pp-07__audit {
  flex: 1 1 280px;
  padding: 26px;
  border: 1px solid var(--pp-07-line);
  border-radius: 18px;
  background: var(--pp-07-card);
  box-shadow: 0 10px 30px oklch(0.5 0.03 265/.09);
}

.pp-07__eyebrow {
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--pp-07-mut);
  margin-bottom: 18px;
}

.pp-07__btn {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  min-height: 56px;
  padding: 0 26px 0 18px;
  border: 1px solid transparent;
  border-radius: 14px;
  background: var(--pp-07-acc);
  color: oklch(0.99 0 0);
  font: inherit;
  font-size: 16px;
  font-weight: 640;
  cursor: pointer;
  transition: background .2s,transform .18s;
}

.pp-07__btn:hover {
  background: color-mix(in oklch,var(--pp-07-acc) 88%,black);
}

.pp-07__btn:active {
  transform: scale(.97);
}

.pp-07__btn:focus-visible {
  outline: 3px solid var(--pp-07-ink);
  outline-offset: 3px;
}

.pp-07__btn[aria-pressed="true"] {
  background: var(--pp-07-ink);
}

.pp-07__q {
  display: none;
}

.pp-07__btn[aria-pressed="true"] .pp-07__p {
  display: none;
}

.pp-07__btn[aria-pressed="true"] .pp-07__q {
  display: block;
}

.pp-07__hint {
  font-size: 13.5px;
  color: var(--pp-07-mut);
  margin-top: 16px;
}

.pp-07__status {
  display: inline-block;
  margin-top: 14px;
  padding: 5px 12px;
  border-radius: 99px;
  font-size: 12px;
  font-weight: 640;
  letter-spacing: .04em;
  background: color-mix(in oklch,var(--pp-07-acc) 12%,transparent);
  color: color-mix(in oklch,var(--pp-07-acc) 78%,black);
}

.pp-07__audit {
  background: oklch(0.99 0.004 260);
}

.pp-07__audit-h {
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--pp-07-mut);
}

.pp-07__say {
  display: flex;
  gap: 9px;
  align-items: flex-start;
  margin: 12px 0 18px;
  padding: 12px 14px;
  border-radius: 12px;
  background: color-mix(in oklch,var(--pp-07-acc) 8%,transparent);
  font-size: 13.5px;
  line-height: 1.45;
  text-wrap: pretty;
}

.pp-07__say-icon {
  color: var(--pp-07-acc);
}

.pp-07__list {
  list-style: none;
  display: grid;
  gap: 9px;
  font-size: 12.5px;
  color: var(--pp-07-mut);
}

.pp-07__list li {
  display: flex;
  gap: 9px;
  align-items: baseline;
  padding-top: 9px;
  border-top: 1px solid var(--pp-07-line);
}

.pp-07__list b {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--pp-07-ink);
  flex: none;
}

.pp-07__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--pp-07-mut);
  padding: 7px 14px;
  border: 1px solid var(--pp-07-line);
  border-radius: 99px;
  text-align: center;
}

@media (forced-colors: active) {
  .pp-07__btn {
    border-color: ButtonText;
  }

  .pp-07__btn:focus-visible {
    outline: 3px solid Highlight;
  }
}

@media (prefers-reduced-motion: reduce) {
  .pp-07 * {
    transition-duration: .01ms !important;
  }
}
(() => {
  document.querySelectorAll('.pp-07__card').forEach((card) => {
    if (card.dataset.wired) return;
    card.dataset.wired = '1';
    const btn = card.querySelector('.pp-07__btn');
    const status = card.querySelector('.pp-07__status');
    const say = card.parentElement.querySelector('.pp-07__say-text');
    btn.addEventListener('click', () => {
      const playing = btn.getAttribute('aria-pressed') !== 'true';
      btn.setAttribute('aria-pressed', String(playing));
      status.textContent = playing ? 'Playing' : 'Paused';
      if (say) say.textContent = playing ? '\u201CPlay, toggle button, pressed\u201D' : '\u201CPlay, toggle button, not pressed\u201D';
    });
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Play / Pause Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Accessible WAI-ARIA Play/Pause Button (WCAG 2.2)
Source: https://codefronts.com/components/css-play-pause-buttons/accessible-wai-aria-play-pause-button-wcag-2-2/

The reference implementation, with the audit built in. A real <button> carrying aria-pressed, a 44 × 44 px target, a 3:1 focus indicator that survives forced-colors mode, a polite live region for state changes, and a side panel that prints exactly what a screen reader announces at each step — so you can see the semantics, not just trust them. This is the demo to copy when the component ships to production.
## HTML
```html
<section class="pp-07" aria-label="Accessible WAI-ARIA play pause button demo">
  <div class="pp-07__stage">
    <div class="pp-07__grid">
      <div class="pp-07__card">
        <p class="pp-07__eyebrow">Toggle button pattern</p>
        <button class="pp-07__btn" type="button" aria-pressed="false" aria-describedby="pp-07-hint">
          <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
            <path class="pp-07__p" d="M8.6 5.4v13.2L19 12z" fill="currentColor"/>
            <path class="pp-07__q" d="M8 5h3.1v14H8zM12.9 5H16v14h-3.1z" fill="currentColor"/>
          </svg>
          <span class="pp-07__name">Play</span>
        </button>
        <p class="pp-07__hint" id="pp-07-hint">Episode 214 — The Cost of Abstraction</p>
        <p class="pp-07__status" role="status">Paused</p>
      </div>
      <div class="pp-07__audit">
        <p class="pp-07__audit-h">Announced as</p>
        <p class="pp-07__say"><span class="pp-07__say-icon" aria-hidden="true">◎</span><span class="pp-07__say-text">“Play, toggle button, not pressed”</span></p>
        <ul class="pp-07__list">
          <li><b>2.5.8</b> Target 56 × 56 px (min 24)</li>
          <li><b>1.4.11</b> Focus ring ≥ 3:1, 2 px, offset</li>
          <li><b>4.1.2</b> aria-pressed carries state</li>
          <li><b>2.3.1</b> Motion behind reduced-motion</li>
          <li><b>1.4.1</b> State never color-only</li>
        </ul>
      </div>
    </div>
    <p class="pp-07__chip" aria-hidden="true">aria-pressed · role=&quot;status&quot; live region · forced-colors fallback</p>
  </div>
</section>
```
## CSS
```css
.pp-07,
.pp-07 *,
.pp-07 *::before,
.pp-07 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.pp-07 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--pp-07-bg);
  --pp-07-bg: oklch(0.97 0.006 250);
  --pp-07-card: oklch(1 0 0);
  --pp-07-ink: oklch(0.24 0.02 260);
  --pp-07-mut: oklch(0.52 0.02 260);
  --pp-07-line: oklch(0.9 0.008 260);
  --pp-07-acc: oklch(0.52 0.19 268);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--pp-07-ink);
}

.pp-07__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 22px;
  padding: clamp(20px,5vw,56px);
}

.pp-07__grid {
  display: flex;
  flex-wrap: wrap;
  gap: 18px;
  justify-content: center;
  align-items: stretch;
  width: min(100%,720px);
}

.pp-07__card,
.pp-07__audit {
  flex: 1 1 280px;
  padding: 26px;
  border: 1px solid var(--pp-07-line);
  border-radius: 18px;
  background: var(--pp-07-card);
  box-shadow: 0 10px 30px oklch(0.5 0.03 265/.09);
}

.pp-07__eyebrow {
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--pp-07-mut);
  margin-bottom: 18px;
}

.pp-07__btn {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  min-height: 56px;
  padding: 0 26px 0 18px;
  border: 1px solid transparent;
  border-radius: 14px;
  background: var(--pp-07-acc);
  color: oklch(0.99 0 0);
  font: inherit;
  font-size: 16px;
  font-weight: 640;
  cursor: pointer;
  transition: background .2s,transform .18s;
}

.pp-07__btn:hover {
  background: color-mix(in oklch,var(--pp-07-acc) 88%,black);
}

.pp-07__btn:active {
  transform: scale(.97);
}

.pp-07__btn:focus-visible {
  outline: 3px solid var(--pp-07-ink);
  outline-offset: 3px;
}

.pp-07__btn[aria-pressed="true"] {
  background: var(--pp-07-ink);
}

.pp-07__q {
  display: none;
}

.pp-07__btn[aria-pressed="true"] .pp-07__p {
  display: none;
}

.pp-07__btn[aria-pressed="true"] .pp-07__q {
  display: block;
}

.pp-07__hint {
  font-size: 13.5px;
  color: var(--pp-07-mut);
  margin-top: 16px;
}

.pp-07__status {
  display: inline-block;
  margin-top: 14px;
  padding: 5px 12px;
  border-radius: 99px;
  font-size: 12px;
  font-weight: 640;
  letter-spacing: .04em;
  background: color-mix(in oklch,var(--pp-07-acc) 12%,transparent);
  color: color-mix(in oklch,var(--pp-07-acc) 78%,black);
}

.pp-07__audit {
  background: oklch(0.99 0.004 260);
}

.pp-07__audit-h {
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--pp-07-mut);
}

.pp-07__say {
  display: flex;
  gap: 9px;
  align-items: flex-start;
  margin: 12px 0 18px;
  padding: 12px 14px;
  border-radius: 12px;
  background: color-mix(in oklch,var(--pp-07-acc) 8%,transparent);
  font-size: 13.5px;
  line-height: 1.45;
  text-wrap: pretty;
}

.pp-07__say-icon {
  color: var(--pp-07-acc);
}

.pp-07__list {
  list-style: none;
  display: grid;
  gap: 9px;
  font-size: 12.5px;
  color: var(--pp-07-mut);
}

.pp-07__list li {
  display: flex;
  gap: 9px;
  align-items: baseline;
  padding-top: 9px;
  border-top: 1px solid var(--pp-07-line);
}

.pp-07__list b {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--pp-07-ink);
  flex: none;
}

.pp-07__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--pp-07-mut);
  padding: 7px 14px;
  border: 1px solid var(--pp-07-line);
  border-radius: 99px;
  text-align: center;
}

@media (forced-colors: active) {
  .pp-07__btn {
    border-color: ButtonText;
  }

  .pp-07__btn:focus-visible {
    outline: 3px solid Highlight;
  }
}

@media (prefers-reduced-motion: reduce) {
  .pp-07 * {
    transition-duration: .01ms !important;
  }
}
```

## JavaScript
```js
(() => {
  document.querySelectorAll('.pp-07__card').forEach((card) => {
    if (card.dataset.wired) return;
    card.dataset.wired = '1';
    const btn = card.querySelector('.pp-07__btn');
    const status = card.querySelector('.pp-07__status');
    const say = card.parentElement.querySelector('.pp-07__say-text');
    btn.addEventListener('click', () => {
      const playing = btn.getAttribute('aria-pressed') !== 'true';
      btn.setAttribute('aria-pressed', String(playing));
      status.textContent = playing ? 'Playing' : 'Paused';
      if (say) say.textContent = playing ? '\u201CPlay, toggle button, pressed\u201D' : '\u201CPlay, toggle button, not pressed\u201D';
    });
  });
})();
```

How this works

There are two correct patterns, and picking the wrong one is the most common mistake in media UI:

<!-- A: toggle button — the LABEL STAYS PUT, the state changes -->
<button aria-pressed="false">Play</button>   <!-- "Play, toggle button, not pressed" -->

<!-- B: action button — the LABEL CHANGES, no aria-pressed -->
<button aria-label="Play">▶</button> → <button aria-label="Pause">⏸</button>

Never mix them. A button labelled 'Pause' and marked aria-pressed="true" announces as 'Pause, pressed' — which most users read as 'pause is currently off'. This demo uses pattern A: the accessible name is the stable word 'Play', and pressed state carries the truth. The visible icon still swaps, because sighted users read icons, not ARIA.

btn.addEventListener('click', () => {
  playing = !playing;
  btn.setAttribute('aria-pressed', String(playing));
  live.textContent = playing ? 'Playing' : 'Paused';   /* role="status" */
});

WCAG 2.2 specifics baked in: 2.5.8 Target Size (Minimum) — the button is 56px, well over the 24px floor; 2.4.11 Focus Not Obscured — the focus ring uses outline-offset so nothing overlaps it; 1.4.11 Non-text Contrast — the ring is ≥ 3:1 against both the button and the page; 2.3.1 — motion is gated behind prefers-reduced-motion; and forced-colors mode gets explicit ButtonText/Highlight colors instead of vanishing shadows.

Make it yours

  • Add a keyboard shortcut: bind K and Space at document level and mirror the click handler — announce the change through the same live region so shortcut users get the same feedback.
  • Loading state: add aria-busy="true" while the media buffers and show a spinner; remove it on canplay.
  • Disabled tracks: prefer aria-disabled="true" over the disabled attribute so the button keeps focus and can explain WHY it is unavailable.
  • Skin freely: only the ring contrast and the 44px target are load-bearing — the visual style can be any of the other 24 demos in this collection.

Gotchas — read before shipping

  • role="switch" is for on/off settings, not transport controls. Play/pause is a toggle button; switch semantics ('on/off') mislead in a media context.
  • Don't put the state in the visible label AND aria-pressed — pick one. Duplicate state is worse than no state.
  • aria-live regions must exist in the DOM before you write to them; injecting the region and the text together is often not announced.
  • outline:none with no replacement fails 2.4.7 outright. If the design forbids outlines, use a box-shadow ring — but keep 3:1 contrast and 2px thickness.
  • Icon-only buttons need an accessible name. An SVG with aria-hidden and no text = a button named 'button'.
  • Screen readers announce aria-pressed changes automatically; the live region here is a belt-and-braces cue for the surrounding UI (time, track), not a replacement for correct semantics.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

aria-pressed is supported by every modern screen reader (NVDA, JAWS, VoiceOver, TalkBack). :focus-visible: Chrome 86, Safari 15.4, Firefox 85 — pair with a :focus fallback for older Safari.

Techniques used in this demo

Search CodeFronts

Loading…