25 CSS Play / Pause Buttons11 / 25

Light JSMIT licensed

Media Player Track Skip & Fast-Forward

A complete transport cluster — previous, rewind 15, play/pause, forward 30, next — with a real playlist behind it. Skip buttons show their offset as text inside a circular arrow, exactly like podcast apps, and pressing next actually advances the track, updates the artwork and resets the scrubber. The layout is a five-column grid with the play button optically weighted, which is what makes a transport row feel balanced instead of arbitrary.

Published Updated

Live Demo
Try it

The code

<section class="pp-11" aria-label="Media player skip and fast forward controls demo">
  <div class="pp-11__stage">
    <div class="pp-11__player" data-state="paused">
      <img class="pp-11__art" src="https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?q=80&w=500&auto=format&fit=crop" alt="" width="96" height="96" loading="lazy">
      <p class="pp-11__title">Signal to Noise</p>
      <p class="pp-11__sub">Episode 12 · 48 min</p>
      <div class="pp-11__scrub" aria-hidden="true"><b></b></div>
      <div class="pp-11__transport">
        <button class="pp-11__btn pp-11__btn--sm" type="button" data-act="prev" aria-label="Previous track">
          <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M7 6v12M19 6l-9 6 9 6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/></svg>
        </button>
        <button class="pp-11__btn pp-11__btn--md" type="button" data-act="back" aria-label="Rewind 15 seconds">
          <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M12 5.5a6.5 6.5 0 1 1-6.3 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M12 2.4v6.2L7.6 5.5z" fill="currentColor"/></svg>
          <span class="pp-11__num">15</span>
        </button>
        <button class="pp-11__btn pp-11__btn--lg" type="button" data-act="play" aria-pressed="false">
          <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
            <path class="pp-11__p" d="M8.6 5.4v13.2L19 12z" fill="currentColor"/>
            <path class="pp-11__q" d="M8 5h3.1v14H8zM12.9 5H16v14h-3.1z" fill="currentColor"/>
          </svg>
          <span class="pp-11__sr">Play</span>
        </button>
        <button class="pp-11__btn pp-11__btn--md" type="button" data-act="fwd" aria-label="Fast forward 30 seconds">
          <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M12 5.5a6.5 6.5 0 1 0 6.3 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M12 2.4v6.2l4.4-3.1z" fill="currentColor"/></svg>
          <span class="pp-11__num">30</span>
        </button>
        <button class="pp-11__btn pp-11__btn--sm" type="button" data-act="next" aria-label="Next track">
          <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M17 6v12M5 6l9 6-9 6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/></svg>
        </button>
      </div>
      <p class="pp-11__live" role="status"></p>
    </div>
    <p class="pp-11__chip" aria-hidden="true">5-column transport grid · state → render() · labelled seek arcs</p>
  </div>
</section>
.pp-11,
.pp-11 *,
.pp-11 *::before,
.pp-11 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.pp-11 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--pp-11-bg);
  --pp-11-bg: oklch(0.95 0.012 250);
  --pp-11-card: oklch(1 0 0);
  --pp-11-ink: oklch(0.24 0.02 265);
  --pp-11-mut: oklch(0.55 0.02 265);
  --pp-11-line: oklch(0.9 0.01 260);
  --pp-11-acc: oklch(0.55 0.2 300);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--pp-11-ink);
}

.pp-11__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 22px;
  padding: clamp(20px,5vw,56px);
  background: radial-gradient(80% 60% at 50% 0%,oklch(0.9 0.05 300/.55),transparent 70%);
}

.pp-11__player {
  display: grid;
  justify-items: center;
  width: min(100%,420px);
  padding: 30px 28px 26px;
  border-radius: 26px;
  background: var(--pp-11-card);
  box-shadow: 0 1px 0 var(--pp-11-line),0 24px 60px oklch(0.4 0.05 290/.18);
}

.pp-11__art {
  width: 104px;
  height: 104px;
  border-radius: 20px;
  object-fit: cover;
  background: linear-gradient(140deg,var(--pp-11-acc),oklch(0.7 0.14 340));
  box-shadow: 0 14px 30px oklch(0.4 0.1 300/.35);
}

.pp-11__title {
  font-size: 20px;
  font-weight: 670;
  letter-spacing: -.02em;
  margin-top: 18px;
  text-align: center;
}

.pp-11__sub {
  font-size: 13px;
  color: var(--pp-11-mut);
  margin-top: 5px;
}

.pp-11__scrub {
  width: 100%;
  height: 5px;
  margin: 20px 0 4px;
  border-radius: 99px;
  background: var(--pp-11-line);
  overflow: hidden;
}

.pp-11__scrub b {
  display: block;
  height: 100%;
  width: 0;
  border-radius: 99px;
  background: var(--pp-11-acc);
  animation: pp-11-fill 20s linear infinite;
  animation-play-state: paused;
}

.pp-11__player[data-state="playing"] .pp-11__scrub b {
  animation-play-state: running;
}

@keyframes pp-11-fill {
  from {
    width: 0;
  }

  to {
    width: 100%;
  }
}

.pp-11__transport {
  display: grid;
  grid-template-columns: repeat(5,auto);
  align-items: center;
  justify-content: center;
  gap: clamp(8px,3.5vw,18px);
  margin-top: 20px;
}

.pp-11__btn {
  position: relative;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--pp-11-ink);
  cursor: pointer;
  transition: background .2s,transform .18s,color .2s;
}

.pp-11__btn:hover {
  background: color-mix(in oklch,var(--pp-11-acc) 10%,transparent);
}

.pp-11__btn:active {
  transform: scale(.92);
}

.pp-11__btn:focus-visible {
  outline: 3px solid var(--pp-11-acc);
  outline-offset: 2px;
}

.pp-11__btn--sm {
  width: 44px;
  height: 44px;
  color: var(--pp-11-mut);
}

.pp-11__btn--md {
  width: 52px;
  height: 52px;
}

.pp-11__btn--lg {
  width: 72px;
  height: 72px;
  background: var(--pp-11-acc);
  color: oklch(0.99 0 0);
  box-shadow: 0 10px 24px color-mix(in oklch,var(--pp-11-acc) 45%,transparent);
}

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

.pp-11__num {
  position: absolute;
  font-size: 9.5px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: .02em;
  margin-top: 2px;
}

.pp-11__sr,
.pp-11__live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.pp-11__q {
  display: none;
}

.pp-11__player[data-state="playing"] .pp-11__p {
  display: none;
}

.pp-11__player[data-state="playing"] .pp-11__q {
  display: block;
}

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

@media (prefers-reduced-motion: reduce) {
  .pp-11 * {
    transition-duration: .01ms !important;
  }
}
(() => {
  const tracks = [
    { t: 'Signal to Noise', s: 'Episode 12 \u00b7 48 min', a: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?q=80&w=500&auto=format&fit=crop' },
    { t: 'Latency Budget', s: 'Episode 13 \u00b7 41 min', a: 'https://images.unsplash.com/photo-1487215078519-e21cc028cb29?q=80&w=500&auto=format&fit=crop' },
    { t: 'Ship It Friday', s: 'Episode 14 \u00b7 55 min', a: 'https://images.unsplash.com/photo-1478737270239-2f02b77fc618?q=80&w=500&auto=format&fit=crop' }
  ];
  document.querySelectorAll('.pp-11__player').forEach((root) => {
    if (root.dataset.wired) return;
    root.dataset.wired = '1';
    const el = (s) => root.querySelector(s);
    let i = 0, playing = false;
    const render = (msg) => {
      el('.pp-11__title').textContent = tracks[i].t;
      el('.pp-11__sub').textContent = tracks[i].s;
      el('.pp-11__art').src = tracks[i].a;
      root.dataset.state = playing ? 'playing' : 'paused';
      el('.pp-11__btn--lg').setAttribute('aria-pressed', String(playing));
      el('.pp-11__sr').textContent = playing ? 'Pause' : 'Play';
      el('.pp-11__live').textContent = msg || (playing ? 'Playing ' : 'Paused ') + tracks[i].t;
    };
    root.addEventListener('click', (e) => {
      const act = e.target.closest('[data-act]')?.dataset.act;
      if (!act) return;
      if (act === 'play') playing = !playing;
      if (act === 'next' || act === 'prev') i = (i + (act === 'next' ? 1 : -1) + tracks.length) % tracks.length;
      render(act === 'back' ? 'Rewound 15 seconds' : act === 'fwd' ? 'Forward 30 seconds' : '');
    });
    render();
  });
})();
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: Media Player Track Skip & Fast-Forward
Source: https://codefronts.com/components/css-play-pause-buttons/skip-hub/

A complete transport cluster — previous, rewind 15, play/pause, forward 30, next — with a real playlist behind it. Skip buttons show their offset as text inside a circular arrow, exactly like podcast apps, and pressing next actually advances the track, updates the artwork and resets the scrubber. The layout is a five-column grid with the play button optically weighted, which is what makes a transport row feel balanced instead of arbitrary.
## HTML
```html
<section class="pp-11" aria-label="Media player skip and fast forward controls demo">
  <div class="pp-11__stage">
    <div class="pp-11__player" data-state="paused">
      <img class="pp-11__art" src="https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?q=80&w=500&auto=format&fit=crop" alt="" width="96" height="96" loading="lazy">
      <p class="pp-11__title">Signal to Noise</p>
      <p class="pp-11__sub">Episode 12 · 48 min</p>
      <div class="pp-11__scrub" aria-hidden="true"><b></b></div>
      <div class="pp-11__transport">
        <button class="pp-11__btn pp-11__btn--sm" type="button" data-act="prev" aria-label="Previous track">
          <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M7 6v12M19 6l-9 6 9 6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/></svg>
        </button>
        <button class="pp-11__btn pp-11__btn--md" type="button" data-act="back" aria-label="Rewind 15 seconds">
          <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M12 5.5a6.5 6.5 0 1 1-6.3 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M12 2.4v6.2L7.6 5.5z" fill="currentColor"/></svg>
          <span class="pp-11__num">15</span>
        </button>
        <button class="pp-11__btn pp-11__btn--lg" type="button" data-act="play" aria-pressed="false">
          <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
            <path class="pp-11__p" d="M8.6 5.4v13.2L19 12z" fill="currentColor"/>
            <path class="pp-11__q" d="M8 5h3.1v14H8zM12.9 5H16v14h-3.1z" fill="currentColor"/>
          </svg>
          <span class="pp-11__sr">Play</span>
        </button>
        <button class="pp-11__btn pp-11__btn--md" type="button" data-act="fwd" aria-label="Fast forward 30 seconds">
          <svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M12 5.5a6.5 6.5 0 1 0 6.3 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M12 2.4v6.2l4.4-3.1z" fill="currentColor"/></svg>
          <span class="pp-11__num">30</span>
        </button>
        <button class="pp-11__btn pp-11__btn--sm" type="button" data-act="next" aria-label="Next track">
          <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M17 6v12M5 6l9 6-9 6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/></svg>
        </button>
      </div>
      <p class="pp-11__live" role="status"></p>
    </div>
    <p class="pp-11__chip" aria-hidden="true">5-column transport grid · state → render() · labelled seek arcs</p>
  </div>
</section>
```
## CSS
```css
.pp-11,
.pp-11 *,
.pp-11 *::before,
.pp-11 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.pp-11 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--pp-11-bg);
  --pp-11-bg: oklch(0.95 0.012 250);
  --pp-11-card: oklch(1 0 0);
  --pp-11-ink: oklch(0.24 0.02 265);
  --pp-11-mut: oklch(0.55 0.02 265);
  --pp-11-line: oklch(0.9 0.01 260);
  --pp-11-acc: oklch(0.55 0.2 300);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--pp-11-ink);
}

.pp-11__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 22px;
  padding: clamp(20px,5vw,56px);
  background: radial-gradient(80% 60% at 50% 0%,oklch(0.9 0.05 300/.55),transparent 70%);
}

.pp-11__player {
  display: grid;
  justify-items: center;
  width: min(100%,420px);
  padding: 30px 28px 26px;
  border-radius: 26px;
  background: var(--pp-11-card);
  box-shadow: 0 1px 0 var(--pp-11-line),0 24px 60px oklch(0.4 0.05 290/.18);
}

.pp-11__art {
  width: 104px;
  height: 104px;
  border-radius: 20px;
  object-fit: cover;
  background: linear-gradient(140deg,var(--pp-11-acc),oklch(0.7 0.14 340));
  box-shadow: 0 14px 30px oklch(0.4 0.1 300/.35);
}

.pp-11__title {
  font-size: 20px;
  font-weight: 670;
  letter-spacing: -.02em;
  margin-top: 18px;
  text-align: center;
}

.pp-11__sub {
  font-size: 13px;
  color: var(--pp-11-mut);
  margin-top: 5px;
}

.pp-11__scrub {
  width: 100%;
  height: 5px;
  margin: 20px 0 4px;
  border-radius: 99px;
  background: var(--pp-11-line);
  overflow: hidden;
}

.pp-11__scrub b {
  display: block;
  height: 100%;
  width: 0;
  border-radius: 99px;
  background: var(--pp-11-acc);
  animation: pp-11-fill 20s linear infinite;
  animation-play-state: paused;
}

.pp-11__player[data-state="playing"] .pp-11__scrub b {
  animation-play-state: running;
}

@keyframes pp-11-fill {
  from {
    width: 0;
  }

  to {
    width: 100%;
  }
}

.pp-11__transport {
  display: grid;
  grid-template-columns: repeat(5,auto);
  align-items: center;
  justify-content: center;
  gap: clamp(8px,3.5vw,18px);
  margin-top: 20px;
}

.pp-11__btn {
  position: relative;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--pp-11-ink);
  cursor: pointer;
  transition: background .2s,transform .18s,color .2s;
}

.pp-11__btn:hover {
  background: color-mix(in oklch,var(--pp-11-acc) 10%,transparent);
}

.pp-11__btn:active {
  transform: scale(.92);
}

.pp-11__btn:focus-visible {
  outline: 3px solid var(--pp-11-acc);
  outline-offset: 2px;
}

.pp-11__btn--sm {
  width: 44px;
  height: 44px;
  color: var(--pp-11-mut);
}

.pp-11__btn--md {
  width: 52px;
  height: 52px;
}

.pp-11__btn--lg {
  width: 72px;
  height: 72px;
  background: var(--pp-11-acc);
  color: oklch(0.99 0 0);
  box-shadow: 0 10px 24px color-mix(in oklch,var(--pp-11-acc) 45%,transparent);
}

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

.pp-11__num {
  position: absolute;
  font-size: 9.5px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: .02em;
  margin-top: 2px;
}

.pp-11__sr,
.pp-11__live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.pp-11__q {
  display: none;
}

.pp-11__player[data-state="playing"] .pp-11__p {
  display: none;
}

.pp-11__player[data-state="playing"] .pp-11__q {
  display: block;
}

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

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

## JavaScript
```js
(() => {
  const tracks = [
    { t: 'Signal to Noise', s: 'Episode 12 \u00b7 48 min', a: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?q=80&w=500&auto=format&fit=crop' },
    { t: 'Latency Budget', s: 'Episode 13 \u00b7 41 min', a: 'https://images.unsplash.com/photo-1487215078519-e21cc028cb29?q=80&w=500&auto=format&fit=crop' },
    { t: 'Ship It Friday', s: 'Episode 14 \u00b7 55 min', a: 'https://images.unsplash.com/photo-1478737270239-2f02b77fc618?q=80&w=500&auto=format&fit=crop' }
  ];
  document.querySelectorAll('.pp-11__player').forEach((root) => {
    if (root.dataset.wired) return;
    root.dataset.wired = '1';
    const el = (s) => root.querySelector(s);
    let i = 0, playing = false;
    const render = (msg) => {
      el('.pp-11__title').textContent = tracks[i].t;
      el('.pp-11__sub').textContent = tracks[i].s;
      el('.pp-11__art').src = tracks[i].a;
      root.dataset.state = playing ? 'playing' : 'paused';
      el('.pp-11__btn--lg').setAttribute('aria-pressed', String(playing));
      el('.pp-11__sr').textContent = playing ? 'Pause' : 'Play';
      el('.pp-11__live').textContent = msg || (playing ? 'Playing ' : 'Paused ') + tracks[i].t;
    };
    root.addEventListener('click', (e) => {
      const act = e.target.closest('[data-act]')?.dataset.act;
      if (!act) return;
      if (act === 'play') playing = !playing;
      if (act === 'next' || act === 'prev') i = (i + (act === 'next' ? 1 : -1) + tracks.length) % tracks.length;
      render(act === 'back' ? 'Rewound 15 seconds' : act === 'fwd' ? 'Forward 30 seconds' : '');
    });
    render();
  });
})();
```

How this works

Transport rows live or die on optical hierarchy: one primary, two secondary, two tertiary. That is a grid, not a flexbox free-for-all:

.transport{ display:grid; grid-template-columns:repeat(5,auto);
            align-items:center; gap:clamp(10px,4vw,22px); justify-content:center; }
.transport button:nth-child(3){ width:72px; height:72px; }   /* primary */
.transport button:nth-child(2),
.transport button:nth-child(4){ width:52px; height:52px; }    /* seek    */
.transport button:nth-child(1),
.transport button:nth-child(5){ width:44px; height:44px; opacity:.75 } /* skip */

The seek buttons draw their arc as an SVG path with a gap, and the number sits in the middle as real text — one element per button, no icon sprite, and the label is legible at any size because it is text, not a rasterised glyph.

The JS is a tiny state machine over a data array: an index, a playing flag, and one render() that writes the DOM from state. Every button mutates state and calls render — never touches the DOM directly. That is the difference between a demo that survives a sixth feature and one that collapses:

const go = (d) => { i = (i + d + tracks.length) % tracks.length; render(); };
btnNext.onclick = () => go(1);
btnPrev.onclick = () => go(-1);

Make it yours

  • Real seek: wire ±15/30 to audio.currentTime += 30 and clamp to audio.duration; the visuals need no change.
  • Smart previous: podcast convention is 'restart current track unless within the first 3 seconds' — one extra condition inside go(-1).
  • Speed control: add a 1.0× / 1.25× / 1.5× button that cycles audio.playbackRate; keep it in the same grid as a sixth column.
  • Compact mobile row: hide the outer skip buttons under 380px with a container query and keep seek + play.

Gotchas — read before shipping

  • Every icon-only button needs an aria-label — 'Previous track', 'Rewind 15 seconds'. Five unlabelled buttons is five 'button, button, button' announcements.
  • Don't disable Previous on the first track; wrap around or restart. A disabled control that appears mid-list is a confusing dead end.
  • Keep every hit target ≥ 44px even when the icon is small — pad, don't shrink (WCAG 2.5.5).
  • Announce track changes through one polite live region; per-button live regions fire over each other.
  • Icons must point where they act: the rewind arc runs counter-clockwise, the forward arc clockwise. Mirroring one path for both is the classic tell of a rushed build.

Browser support

ChromeSafariFirefoxEdge
111+16.4+113+111+

Grid, SVG and standard DOM events — universal. Only oklch()/color-mix() theming sets the modern floor.

Search CodeFronts

Loading…