25 CSS Play / Pause Buttons16 / 25
Minimalist Line-Art Outline Button
Restraint as a technique: a hairline circle, a stroked triangle and a lot of air. On hover the ring draws itself around the button with stroke-dashoffset — the line-art equivalent of a fill animation — and on play the triangle becomes two strokes with rounded caps. Perfect for editorial sites, portfolios and premium brands where a filled button would feel loud.
Published Updated
The code
<section class="pp-16" aria-label="Minimalist line art outline play button demo">
<div class="pp-16__stage">
<figure class="pp-16__unit">
<input class="pp-16-chk" type="checkbox" id="pp-16-toggle" aria-label="Play or pause the essay audio">
<label class="pp-16__btn" for="pp-16-toggle">
<svg viewBox="0 0 120 120" width="132" height="132" aria-hidden="true">
<circle class="pp-16__base" cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="1" opacity=".14"/>
<circle class="pp-16__draw" cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
<path class="pp-16__p" d="M50 43l26 17-26 17z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
<path class="pp-16__q" d="M52 44v32M68 44v32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</label>
<figcaption class="pp-16__cap">
<p class="pp-16__label">Listen to this essay</p>
<p class="pp-16__meta">14 min · read by the author</p>
</figcaption>
</figure>
<p class="pp-16__chip" aria-hidden="true">stroke-dasharray = 2πr · dashoffset draw · hover + focus-visible parity</p>
</div>
</section><section class="pp-16" aria-label="Minimalist line art outline play button demo">
<div class="pp-16__stage">
<figure class="pp-16__unit">
<input class="pp-16-chk" type="checkbox" id="pp-16-toggle" aria-label="Play or pause the essay audio">
<label class="pp-16__btn" for="pp-16-toggle">
<svg viewBox="0 0 120 120" width="132" height="132" aria-hidden="true">
<circle class="pp-16__base" cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="1" opacity=".14"/>
<circle class="pp-16__draw" cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
<path class="pp-16__p" d="M50 43l26 17-26 17z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
<path class="pp-16__q" d="M52 44v32M68 44v32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</label>
<figcaption class="pp-16__cap">
<p class="pp-16__label">Listen to this essay</p>
<p class="pp-16__meta">14 min · read by the author</p>
</figcaption>
</figure>
<p class="pp-16__chip" aria-hidden="true">stroke-dasharray = 2πr · dashoffset draw · hover + focus-visible parity</p>
</div>
</section>.pp-16,
.pp-16 *,
.pp-16 *::before,
.pp-16 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-16 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-16-bg);
--pp-16-bg: oklch(0.98 0.004 85);
--pp-16-ink: oklch(0.22 0.01 80);
--pp-16-mut: oklch(0.55 0.012 80);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-16-ink);
}
.pp-16__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(20px,5vw,56px);
}
.pp-16__unit {
display: grid;
justify-items: center;
gap: 26px;
}
.pp-16-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-16__btn {
display: grid;
place-items: center;
padding: 6px;
border-radius: 50%;
color: var(--pp-16-ink);
cursor: pointer;
}
.pp-16__draw {
stroke-dasharray: 339.3;
stroke-dashoffset: 339.3;
rotate: -90deg;
transform-origin: 60px 60px;
transition: stroke-dashoffset .8s cubic-bezier(.65,0,.35,1);
}
.pp-16__btn:hover .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:focus-visible+.pp-16__btn .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:focus-visible+.pp-16__btn {
outline: 2px solid var(--pp-16-ink);
outline-offset: 6px;
}
.pp-16__p,
.pp-16__q {
transition: opacity .35s,scale .45s cubic-bezier(.34,1.3,.5,1);
transform-origin: 60px 60px;
}
.pp-16__q {
opacity: 0;
scale: .86;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__p {
opacity: 0;
scale: .86;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__q {
opacity: 1;
scale: 1;
}
.pp-16__cap {
text-align: center;
}
.pp-16__label {
font-size: 13px;
font-weight: 600;
letter-spacing: .18em;
text-transform: uppercase;
}
.pp-16__meta {
font-size: 13px;
color: var(--pp-16-mut);
margin-top: 8px;
}
.pp-16__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-16-mut);
padding: 7px 14px;
border: 1px solid oklch(0.22 0.01 80/.16);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-16 * {
transition-duration: .01ms !important;
}
}.pp-16,
.pp-16 *,
.pp-16 *::before,
.pp-16 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-16 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-16-bg);
--pp-16-bg: oklch(0.98 0.004 85);
--pp-16-ink: oklch(0.22 0.01 80);
--pp-16-mut: oklch(0.55 0.012 80);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-16-ink);
}
.pp-16__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(20px,5vw,56px);
}
.pp-16__unit {
display: grid;
justify-items: center;
gap: 26px;
}
.pp-16-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-16__btn {
display: grid;
place-items: center;
padding: 6px;
border-radius: 50%;
color: var(--pp-16-ink);
cursor: pointer;
}
.pp-16__draw {
stroke-dasharray: 339.3;
stroke-dashoffset: 339.3;
rotate: -90deg;
transform-origin: 60px 60px;
transition: stroke-dashoffset .8s cubic-bezier(.65,0,.35,1);
}
.pp-16__btn:hover .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:focus-visible+.pp-16__btn .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:focus-visible+.pp-16__btn {
outline: 2px solid var(--pp-16-ink);
outline-offset: 6px;
}
.pp-16__p,
.pp-16__q {
transition: opacity .35s,scale .45s cubic-bezier(.34,1.3,.5,1);
transform-origin: 60px 60px;
}
.pp-16__q {
opacity: 0;
scale: .86;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__p {
opacity: 0;
scale: .86;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__q {
opacity: 1;
scale: 1;
}
.pp-16__cap {
text-align: center;
}
.pp-16__label {
font-size: 13px;
font-weight: 600;
letter-spacing: .18em;
text-transform: uppercase;
}
.pp-16__meta {
font-size: 13px;
color: var(--pp-16-mut);
margin-top: 8px;
}
.pp-16__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-16-mut);
padding: 7px 14px;
border: 1px solid oklch(0.22 0.01 80/.16);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-16 * {
transition-duration: .01ms !important;
}
}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: Minimalist Line-Art Outline Button
Source: https://codefronts.com/components/css-play-pause-buttons/minimal-outline/
Restraint as a technique: a hairline circle, a stroked triangle and a lot of air. On hover the ring draws itself around the button with stroke-dashoffset — the line-art equivalent of a fill animation — and on play the triangle becomes two strokes with rounded caps. Perfect for editorial sites, portfolios and premium brands where a filled button would feel loud.
## HTML
```html
<section class="pp-16" aria-label="Minimalist line art outline play button demo">
<div class="pp-16__stage">
<figure class="pp-16__unit">
<input class="pp-16-chk" type="checkbox" id="pp-16-toggle" aria-label="Play or pause the essay audio">
<label class="pp-16__btn" for="pp-16-toggle">
<svg viewBox="0 0 120 120" width="132" height="132" aria-hidden="true">
<circle class="pp-16__base" cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="1" opacity=".14"/>
<circle class="pp-16__draw" cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
<path class="pp-16__p" d="M50 43l26 17-26 17z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
<path class="pp-16__q" d="M52 44v32M68 44v32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</label>
<figcaption class="pp-16__cap">
<p class="pp-16__label">Listen to this essay</p>
<p class="pp-16__meta">14 min · read by the author</p>
</figcaption>
</figure>
<p class="pp-16__chip" aria-hidden="true">stroke-dasharray = 2πr · dashoffset draw · hover + focus-visible parity</p>
</div>
</section>
```
## CSS
```css
.pp-16,
.pp-16 *,
.pp-16 *::before,
.pp-16 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-16 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-16-bg);
--pp-16-bg: oklch(0.98 0.004 85);
--pp-16-ink: oklch(0.22 0.01 80);
--pp-16-mut: oklch(0.55 0.012 80);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-16-ink);
}
.pp-16__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(20px,5vw,56px);
}
.pp-16__unit {
display: grid;
justify-items: center;
gap: 26px;
}
.pp-16-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-16__btn {
display: grid;
place-items: center;
padding: 6px;
border-radius: 50%;
color: var(--pp-16-ink);
cursor: pointer;
}
.pp-16__draw {
stroke-dasharray: 339.3;
stroke-dashoffset: 339.3;
rotate: -90deg;
transform-origin: 60px 60px;
transition: stroke-dashoffset .8s cubic-bezier(.65,0,.35,1);
}
.pp-16__btn:hover .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:focus-visible+.pp-16__btn .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:focus-visible+.pp-16__btn {
outline: 2px solid var(--pp-16-ink);
outline-offset: 6px;
}
.pp-16__p,
.pp-16__q {
transition: opacity .35s,scale .45s cubic-bezier(.34,1.3,.5,1);
transform-origin: 60px 60px;
}
.pp-16__q {
opacity: 0;
scale: .86;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__p {
opacity: 0;
scale: .86;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__q {
opacity: 1;
scale: 1;
}
.pp-16__cap {
text-align: center;
}
.pp-16__label {
font-size: 13px;
font-weight: 600;
letter-spacing: .18em;
text-transform: uppercase;
}
.pp-16__meta {
font-size: 13px;
color: var(--pp-16-mut);
margin-top: 8px;
}
.pp-16__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-16-mut);
padding: 7px 14px;
border: 1px solid oklch(0.22 0.01 80/.16);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-16 * {
transition-duration: .01ms !important;
}
}
```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: Minimalist Line-Art Outline Button
Source: https://codefronts.com/components/css-play-pause-buttons/minimal-outline/
Restraint as a technique: a hairline circle, a stroked triangle and a lot of air. On hover the ring draws itself around the button with stroke-dashoffset — the line-art equivalent of a fill animation — and on play the triangle becomes two strokes with rounded caps. Perfect for editorial sites, portfolios and premium brands where a filled button would feel loud.
## HTML
```html
<section class="pp-16" aria-label="Minimalist line art outline play button demo">
<div class="pp-16__stage">
<figure class="pp-16__unit">
<input class="pp-16-chk" type="checkbox" id="pp-16-toggle" aria-label="Play or pause the essay audio">
<label class="pp-16__btn" for="pp-16-toggle">
<svg viewBox="0 0 120 120" width="132" height="132" aria-hidden="true">
<circle class="pp-16__base" cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="1" opacity=".14"/>
<circle class="pp-16__draw" cx="60" cy="60" r="54" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
<path class="pp-16__p" d="M50 43l26 17-26 17z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
<path class="pp-16__q" d="M52 44v32M68 44v32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</label>
<figcaption class="pp-16__cap">
<p class="pp-16__label">Listen to this essay</p>
<p class="pp-16__meta">14 min · read by the author</p>
</figcaption>
</figure>
<p class="pp-16__chip" aria-hidden="true">stroke-dasharray = 2πr · dashoffset draw · hover + focus-visible parity</p>
</div>
</section>
```
## CSS
```css
.pp-16,
.pp-16 *,
.pp-16 *::before,
.pp-16 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-16 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-16-bg);
--pp-16-bg: oklch(0.98 0.004 85);
--pp-16-ink: oklch(0.22 0.01 80);
--pp-16-mut: oklch(0.55 0.012 80);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-16-ink);
}
.pp-16__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: clamp(20px,5vw,56px);
}
.pp-16__unit {
display: grid;
justify-items: center;
gap: 26px;
}
.pp-16-chk {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
clip-path: inset(50%);
}
.pp-16__btn {
display: grid;
place-items: center;
padding: 6px;
border-radius: 50%;
color: var(--pp-16-ink);
cursor: pointer;
}
.pp-16__draw {
stroke-dasharray: 339.3;
stroke-dashoffset: 339.3;
rotate: -90deg;
transform-origin: 60px 60px;
transition: stroke-dashoffset .8s cubic-bezier(.65,0,.35,1);
}
.pp-16__btn:hover .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:focus-visible+.pp-16__btn .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:focus-visible+.pp-16__btn {
outline: 2px solid var(--pp-16-ink);
outline-offset: 6px;
}
.pp-16__p,
.pp-16__q {
transition: opacity .35s,scale .45s cubic-bezier(.34,1.3,.5,1);
transform-origin: 60px 60px;
}
.pp-16__q {
opacity: 0;
scale: .86;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__draw {
stroke-dashoffset: 0;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__p {
opacity: 0;
scale: .86;
}
.pp-16-chk:checked+.pp-16__btn .pp-16__q {
opacity: 1;
scale: 1;
}
.pp-16__cap {
text-align: center;
}
.pp-16__label {
font-size: 13px;
font-weight: 600;
letter-spacing: .18em;
text-transform: uppercase;
}
.pp-16__meta {
font-size: 13px;
color: var(--pp-16-mut);
margin-top: 8px;
}
.pp-16__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-16-mut);
padding: 7px 14px;
border: 1px solid oklch(0.22 0.01 80/.16);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-16 * {
transition-duration: .01ms !important;
}
}
```How this works
The draw-on is the whole interaction, and it is two properties on one SVG circle:
circle{ fill:none; stroke:currentColor; stroke-width:1.25;
stroke-dasharray:340; /* = 2πr, the full circumference */
stroke-dashoffset:340; /* the dash pushed entirely out of view */
transition:stroke-dashoffset .8s cubic-bezier(.65,0,.35,1); }
.btn:hover circle,
.btn:focus-visible circle{ stroke-dashoffset:0; } /* the line arrives */The dash pattern is one dash exactly as long as the path, so stroke-dashoffset slides that single dash around the circle like a curtain. Rotating the circle -90° makes it start at 12 o'clock instead of 3, which is the difference between 'drawn' and 'weirdly off'.
At this weight, every other decision is typographic: 1.25px strokes with stroke-linecap:round, a 1px static ring underneath at 12% opacity so the button has presence before hover, and letterspaced small caps for the label. Minimal designs have nowhere to hide — the only things left are proportion, weight and timing.
Make it yours
- Reverse the draw: animate dashoffset from -340 to 0 and the ring unwinds counter-clockwise — pairs well with 'pause'.
- Progress ring: drop the hover trigger and bind dashoffset to playback (see demo 20) — the same circle becomes a scrubber.
- Two-tone: keep the static ring visible and draw the accent ring on top for a subtler, layered hover.
- Dark mode: this pattern is colour-agnostic; set --pp-16-ink to a light value and the whole component inverts with no other change.
Gotchas — read before shipping
- stroke-dasharray must equal the real circumference (2πr) or the line arrives early/late. For r=54 that is 339.3 — measure with getTotalLength() if the path is not a circle.
- Hover-only reveals exclude keyboards: the transition here fires on :focus-visible too. Never ship one without the other.
- Hairlines can vanish on low-DPI displays under 1px. Keep stroke-width ≥ 1 and avoid fractional widths below 1.
- Thin strokes often fail 1.4.11 (3:1 for UI components) — this demo keeps the icon and ring at high contrast against the page and adds a visible focus outline.
- non-scaling-stroke (vector-effect) keeps the hairline constant if you scale the SVG on hover; without it the line thickens with the transform.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
SVG stroke-dashoffset transitions are universal (IE10+). :focus-visible: Chrome 86, Safari 15.4, Firefox 85.