Introduction
This tutorial builds a responsive, themeable Play/Pause button drawn entirely with CSS. No images or SVGs, just one checkbox, one label, and two pseudo-elements. By the end, you’ll have a circular media control that toggles between a right-pointing triangle and two pause bars, complete with hover, focus, and motion-aware animation.
Why a Pure CSS Play/Pause Button Matters
Replacing image icons with CSS gives you full control over sizing, color, and states without asset swaps. You can scale the icon with a single custom property and match it to any theme. CSS-only shapes also avoid extra network requests and compress neatly with your styles. That makes them easy to ship and maintain.
SVGs are a solid choice for complex illustrations. For simple UI glyphs like media controls, CSS shapes are quick to build, predictable across modern browsers, and straightforward to animate with transitions and keyframes. A triangle for “Play” and two rectangles for “Pause” are classics, and both map nicely to CSS primitives.
Prerequisites
You don’t need a framework or build step. You just need a text editor and a browser that supports modern CSS. If you’re comfortable with custom properties and pseudo-elements, you’ll be right at home.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1 , The HTML Structure
The markup keeps semantics simple while giving CSS the hooks it needs. A visually hidden checkbox drives the state, a label provides the click target, and a span inside the label holds the icon. Two screen-reader-only spans announce “Play” and “Pause” based on the checked state.
<div class="demo">
  <input
    type="checkbox"
    id="toggle"
    class="sr-only"
    aria-controls="media-toggle"
    aria-labelledby="state-play state-pause"
  />
  <label id="media-toggle" for="toggle" class="media-toggle">
    <span class="icon" aria-hidden="true"></span>
    <span id="state-play" class="sr-only state state--play">Play</span>
    <span id="state-pause" class="sr-only state state--pause">Pause</span>
  </label>
</div>
The checkbox is the source of truth for “playing” or “paused.” The label becomes the big tap target and contains everything visual. The icon span gets pseudo-elements for drawing the shapes. Two state spans give assistive tech the right label without JavaScript.
Step 2 , The Basic CSS & Styling
Start with theme variables, center the demo on the page, and create a circular button. The circle is the base; the icon sits inside and scales with a single size variable.
/* CSS */
:root {
  --btn: 96px;           /* Button diameter */
  --fg: #ffffff;         /* Icon color */
  --bg-1: #0b1220;       /* Page background */
  --bg-2: #070b14;
  --ring: #22c55e;
  --shadow: 0 6px 16px rgba(0,0,0,.30);
}
*,
*::before,
*::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
  margin: 0;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
  color: #e2e8f0;
  background:
    radial-gradient(1200px 600px at 50% -200px, var(--bg-1), var(--bg-2) 60%),
    linear-gradient(var(--bg-1), var(--bg-2));
  min-height: 100svh;
  display: grid;
  place-items: center;
}
.demo { display: grid; gap: 16px; }
/* Screen-reader-only utility */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
/* Button shell */
.media-toggle {
  width: var(--btn);
  height: var(--btn);
  border-radius: 50%;
  display: grid;
  place-items: center;
  cursor: pointer;
  border: 1px solid #1b2436;
  background:
    radial-gradient(120% 120% at 30% 30%, #1f2a44, #0e1626 70%);
  box-shadow: var(--shadow), inset 0 2px 8px rgba(255,255,255,.06);
  transition: transform .2s ease, box-shadow .2s ease, background .3s ease;
  position: relative;
}
/* Focus ring for keyboard users */
.media-toggle:focus-visible {
  outline: 3px solid var(--ring);
  outline-offset: 3px;
}
/* Hover/press affordances */
.media-toggle:hover {
  transform: translateY(-1px);
  box-shadow: 0 10px 24px rgba(0,0,0,.35), inset 0 2px 8px rgba(255,255,255,.06);
}
.media-toggle:active { transform: translateY(0); }
/* Icon stage */
.icon {
  position: relative;
  width: calc(var(--btn) * .5);
  height: calc(var(--btn) * .5);
  display: block;
}
/* SR text defaults: show "Play" by default, switch via :checked */
.state--pause { display: none; }
#toggle:checked + .media-toggle .state--play { display: none; }
#toggle:checked + .media-toggle .state--pause { display: inline; }
Pro Tip: Set a single –btn size and express inner dimensions as a fraction of it. That keeps the triangle and pause bars balanced at any scale, from 48px to 160px, without recalculating border widths or gradients by hand.
Step 3 , Building the Play Icon
The play triangle uses the classic border trick. It’s the default state. The triangle lives in ::before; opacity and transforms let it animate out when the control flips to Pause.
/* CSS */
/* Triangle for "Play" */
.icon::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  /* center-ish and nudge right so the triangle feels balanced */
  transform: translate(-30%, -50%);
  width: 0;
  height: 0;
  /* Right-pointing triangle via borders */
  border-top: calc(var(--btn) * .17) solid transparent;
  border-bottom: calc(var(--btn) * .17) solid transparent;
  border-left: calc(var(--btn) * .28) solid var(--fg);
  transition: opacity .25s ease, transform .25s ease;
  opacity: 1;
}
/* Hide the triangle when toggled to Pause */
#toggle:checked + .media-toggle .icon::before {
  opacity: 0;
  transform: translate(-20%, -50%) scale(.85);
}
How This Works (Code Breakdown)
The icon span sets up a local stage with position: relative. The triangle draws in ::before, which keeps the DOM clean. We set width and height to zero and build the shape with borders. Transparent top and bottom borders give the slanted edges, and the solid left border becomes the filled triangle. This is the well-known border triangle technique; if you need a refresher, see how to make a right‑pointing triangle with CSS.
Centering a zero-size triangle is tricky, so top: 50% and left: 50% put the anchor point in the middle. translate(-30%, -50%) shifts the triangle so it sits optically centered inside the circle. You can fine-tune that -30% if your eye says the tip looks off by a pixel or two.
The button shell is a perfect circle thanks to border-radius: 50% on a square box. If you want to compare techniques or drop a standalone circle elsewhere, this guide covers the basics: how to make a circle with CSS. A radial gradient and subtle inset shadow add depth without extra markup.
When the checkbox flips to checked, the sibling combinator targets the label and the icon inside it. We fade the triangle out and nudge it slightly for a smoother state change. Transitions on opacity and transform are cheap on the main thread and feel responsive on mobile.
Step 4 , Building the Pause Icon
The pause state uses a background gradient on the icon container to draw two vertical bars. This avoids extra elements and lines up perfectly with the triangle’s footprint. The triangle fades away while the bars fade in.
/* CSS */
/* Default: no pause bars in "Play" state */
.icon { background: none; transition: background .25s ease, transform .25s ease; }
/* Two pause bars for "Pause" state */
#toggle:checked + .media-toggle .icon {
  background:
    linear-gradient(
      to right,
      var(--fg) 0 38%,
      transparent 38% 62%,
      var(--fg) 62% 100%
    );
}
/* Optional: subtle visual feedback when paused */
#toggle:checked + .media-toggle {
  background:
    radial-gradient(120% 120% at 30% 30%, #22304a, #0e1626 70%),
    linear-gradient(to bottom right, rgba(34,197,94,.12), rgba(34,197,94,0));
}
How This Works (Code Breakdown)
Two rectangles are all we need for Pause. A linear-gradient splits the icon’s width into three lanes: bar, gap, bar. Using percentages keeps the spacing proportional to the overall button size. If you want to build standalone blocks or need the basics, here’s how to make a rectangle with CSS.
The bars sit inside the same icon box the triangle used. That ensures the switch feels like a morph instead of a snap. Since the triangle lives in a pseudo-element and the pause bars live in the background layer, both can blend during the transition without fighting for z-index.
The color system remains consistent by referencing –fg for the glyph and accent tints on the shell. If your design system swaps themes, you can adjust fg and background hues in one place and the icon will follow.
Advanced Techniques: Animations & Hover Effects
State changes feel better with small movement and a quick pulse. Add a “ping” ring that fires on the pause state, a gentle hover lift, and respect user motion preferences. These touches help users notice the state change without strain.
/* CSS */
/* Pulse ring when toggled to Pause */
.media-toggle::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  box-shadow: 0 0 0 0 rgba(34,197,94,.45);
  opacity: 0;
  pointer-events: none;
}
#toggle:checked + .media-toggle::after {
  animation: ping .6s ease-out;
}
@keyframes ping {
  0%   { box-shadow: 0 0 0 0 rgba(34,197,94,.45); opacity: 1; }
  100% { box-shadow: 0 0 0 16px rgba(34,197,94,0); opacity: 0; }
}
/* Motion preferences */
@media (prefers-reduced-motion: reduce) {
  .media-toggle,
  .icon,
  .icon::before { transition: none; }
  #toggle:checked + .media-toggle::after { animation: none; }
}
The ::after ring uses box-shadow expansion to fake a ripple. Because it’s on a separate layer from the icon, it doesn’t interfere with triangle or bar transitions. prefers-reduced-motion switches off the animation and transitions for users who request less motion.
Accessibility & Performance
Visual polish matters, but only if everyone can use the control quickly and comfortably. Keep assistive tech support and motion settings in mind, and favor properties that animate on the compositor for smoothness.
Accessibility
The checkbox trick keeps this demo self-contained, though a production control should be a real <button> with aria-pressed toggled via JavaScript to express state. The icon glyph itself is purely decorative, so keep aria-hidden=”true” on it to avoid double announcements. The screen reader labels switch between “Play” and “Pause” by toggling display on the respective spans, which works across mainstream screen readers.
Keyboard users need a clear focus indicator. The :focus-visible ring on the label ensures that. Match your brand color in –ring and make sure the outline offset doesn’t get clipped by surrounding layouts. The large circular hit area also helps touch users, especially on dense toolbars.
Performance
Everything here runs on opacity, transform, and background layers, which are friendly to the compositor. The triangle is a border shape and the bars are a simple linear-gradient, so the browser has very little to paint. Even on low-end devices, the transitions remain responsive.
If you build a cluster of these controls, share the same custom property scale to keep all icons in lockstep. That avoids recalculating layout when theme switches happen and keeps your CSS easy to audit.
Final Thoughts
You built a Play/Pause control that uses a circle, a border triangle, and two gradient bars, no images required. The result is scalable, themeable, accessible, and easy to drop into any UI. Use the same patterns to craft the rest of your media icon set and keep your interface sharp without extra assets.
