25 CSS Play / Pause Buttons02 / 25
Custom HTML5 Video Player Overlay
The component every video page needs: a poster-framed <video> with the native controls off, a centered glass play button over a gradient scrim, and a real scrub bar wired to timeupdate. The overlay fades out while playing and returns on pause or hover — the behaviour users expect from YouTube-class players — and the whole control surface is keyboard operable, with Space and K bound to toggle like a native player.
Published
The code
<section class="pp-02" aria-label="Custom HTML5 video player overlay demo">
<div class="pp-02__stage">
<div class="pp-02__player" data-state="paused">
<video class="pp-02__video" preload="none" playsinline muted
poster="https://images.unsplash.com/photo-1478720568477-152d9b164e26?q=80&w=1200&auto=format&fit=crop"
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"></video>
<div class="pp-02__scrim" aria-hidden="true"></div>
<button class="pp-02__big" type="button" aria-pressed="false">
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
<path class="pp-02__i-play" d="M8 5.2v13.6L19 12z" fill="currentColor"/>
<path class="pp-02__i-pause" d="M8 5h3.2v14H8zM12.8 5H16v14h-3.2z" fill="currentColor"/>
</svg>
<span class="pp-02__label">Play video</span>
</button>
<div class="pp-02__bar" aria-hidden="true"><span class="pp-02__fill"></span></div>
<p class="pp-02__time" aria-hidden="true"><span class="pp-02__now">0:00</span> / <span class="pp-02__dur">0:00</span></p>
</div>
<p class="pp-02__chip" aria-hidden="true">preload="none" + poster · media events drive state · --pp-02-progress → scaleX</p>
</div>
</section><section class="pp-02" aria-label="Custom HTML5 video player overlay demo">
<div class="pp-02__stage">
<div class="pp-02__player" data-state="paused">
<video class="pp-02__video" preload="none" playsinline muted
poster="https://images.unsplash.com/photo-1478720568477-152d9b164e26?q=80&w=1200&auto=format&fit=crop"
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"></video>
<div class="pp-02__scrim" aria-hidden="true"></div>
<button class="pp-02__big" type="button" aria-pressed="false">
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
<path class="pp-02__i-play" d="M8 5.2v13.6L19 12z" fill="currentColor"/>
<path class="pp-02__i-pause" d="M8 5h3.2v14H8zM12.8 5H16v14h-3.2z" fill="currentColor"/>
</svg>
<span class="pp-02__label">Play video</span>
</button>
<div class="pp-02__bar" aria-hidden="true"><span class="pp-02__fill"></span></div>
<p class="pp-02__time" aria-hidden="true"><span class="pp-02__now">0:00</span> / <span class="pp-02__dur">0:00</span></p>
</div>
<p class="pp-02__chip" aria-hidden="true">preload="none" + poster · media events drive state · --pp-02-progress → scaleX</p>
</div>
</section>.pp-02,
.pp-02 *,
.pp-02 *::before,
.pp-02 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-02 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-02-bg);
--pp-02-bg: oklch(0.15 0.012 260);
--pp-02-ink: oklch(0.98 0.004 250);
--pp-02-mut: oklch(0.72 0.015 255);
--pp-02-acc: oklch(0.72 0.17 25);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-02-ink);
}
.pp-02__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 22px;
padding: clamp(18px,4vw,52px);
}
.pp-02__player {
--pp-02-progress: 0;
position: relative;
width: min(100%,760px);
aspect-ratio: 16/9;
border-radius: 18px;
overflow: hidden;
background: linear-gradient(140deg,oklch(0.32 0.05 240),oklch(0.2 0.03 280));
box-shadow: 0 0 0 1px oklch(1 0 0/.09),0 30px 70px oklch(0 0 0/.5);
}
.pp-02__video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pp-02__scrim {
position: absolute;
inset: 0;
background: radial-gradient(60% 60% at 50% 45%,oklch(0 0 0/.28),transparent),linear-gradient(to top,oklch(0 0 0/.62),transparent 42%);
opacity: 1;
transition: opacity .4s;
pointer-events: none;
}
.pp-02__player[data-state="playing"] .pp-02__scrim {
opacity: .35;
}
.pp-02__big {
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
display: grid;
place-items: center;
width: 88px;
height: 88px;
border: 1px solid oklch(1 0 0/.35);
border-radius: 50%;
background: oklch(1 0 0/.14);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
color: var(--pp-02-ink);
cursor: pointer;
transition: transform .3s cubic-bezier(.34,1.4,.5,1),opacity .35s,background .25s;
}
.pp-02__big:hover {
background: oklch(1 0 0/.24);
transform: translate(0,0) scale(1.07);
}
.pp-02__big:focus-visible {
outline: 3px solid var(--pp-02-acc);
outline-offset: 4px;
}
.pp-02__player[data-state="playing"] .pp-02__big {
opacity: 0;
pointer-events: none;
scale: .86;
}
.pp-02__player[data-state="playing"]:hover .pp-02__big,
.pp-02__player[data-state="playing"]:focus-within .pp-02__big {
opacity: 1;
pointer-events: auto;
scale: 1;
}
.pp-02__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pp-02__i-pause {
display: none;
}
.pp-02__player[data-state="playing"] .pp-02__i-play {
display: none;
}
.pp-02__player[data-state="playing"] .pp-02__i-pause {
display: block;
}
.pp-02__bar {
position: absolute;
left: 18px;
right: 18px;
bottom: 20px;
height: 5px;
border-radius: 99px;
background: oklch(1 0 0/.25);
overflow: hidden;
}
.pp-02__fill {
display: block;
height: 100%;
border-radius: 99px;
background: var(--pp-02-acc);
transform: scaleX(var(--pp-02-progress));
transform-origin: left;
transition: transform .12s linear;
}
.pp-02__time {
position: absolute;
right: 18px;
bottom: 34px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--pp-02-ink);
text-shadow: 0 1px 3px oklch(0 0 0/.6);
}
.pp-02__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-02-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-02 * {
transition-duration: .01ms !important;
}
}.pp-02,
.pp-02 *,
.pp-02 *::before,
.pp-02 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-02 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-02-bg);
--pp-02-bg: oklch(0.15 0.012 260);
--pp-02-ink: oklch(0.98 0.004 250);
--pp-02-mut: oklch(0.72 0.015 255);
--pp-02-acc: oklch(0.72 0.17 25);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-02-ink);
}
.pp-02__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 22px;
padding: clamp(18px,4vw,52px);
}
.pp-02__player {
--pp-02-progress: 0;
position: relative;
width: min(100%,760px);
aspect-ratio: 16/9;
border-radius: 18px;
overflow: hidden;
background: linear-gradient(140deg,oklch(0.32 0.05 240),oklch(0.2 0.03 280));
box-shadow: 0 0 0 1px oklch(1 0 0/.09),0 30px 70px oklch(0 0 0/.5);
}
.pp-02__video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pp-02__scrim {
position: absolute;
inset: 0;
background: radial-gradient(60% 60% at 50% 45%,oklch(0 0 0/.28),transparent),linear-gradient(to top,oklch(0 0 0/.62),transparent 42%);
opacity: 1;
transition: opacity .4s;
pointer-events: none;
}
.pp-02__player[data-state="playing"] .pp-02__scrim {
opacity: .35;
}
.pp-02__big {
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
display: grid;
place-items: center;
width: 88px;
height: 88px;
border: 1px solid oklch(1 0 0/.35);
border-radius: 50%;
background: oklch(1 0 0/.14);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
color: var(--pp-02-ink);
cursor: pointer;
transition: transform .3s cubic-bezier(.34,1.4,.5,1),opacity .35s,background .25s;
}
.pp-02__big:hover {
background: oklch(1 0 0/.24);
transform: translate(0,0) scale(1.07);
}
.pp-02__big:focus-visible {
outline: 3px solid var(--pp-02-acc);
outline-offset: 4px;
}
.pp-02__player[data-state="playing"] .pp-02__big {
opacity: 0;
pointer-events: none;
scale: .86;
}
.pp-02__player[data-state="playing"]:hover .pp-02__big,
.pp-02__player[data-state="playing"]:focus-within .pp-02__big {
opacity: 1;
pointer-events: auto;
scale: 1;
}
.pp-02__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pp-02__i-pause {
display: none;
}
.pp-02__player[data-state="playing"] .pp-02__i-play {
display: none;
}
.pp-02__player[data-state="playing"] .pp-02__i-pause {
display: block;
}
.pp-02__bar {
position: absolute;
left: 18px;
right: 18px;
bottom: 20px;
height: 5px;
border-radius: 99px;
background: oklch(1 0 0/.25);
overflow: hidden;
}
.pp-02__fill {
display: block;
height: 100%;
border-radius: 99px;
background: var(--pp-02-acc);
transform: scaleX(var(--pp-02-progress));
transform-origin: left;
transition: transform .12s linear;
}
.pp-02__time {
position: absolute;
right: 18px;
bottom: 34px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--pp-02-ink);
text-shadow: 0 1px 3px oklch(0 0 0/.6);
}
.pp-02__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-02-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-02 * {
transition-duration: .01ms !important;
}
}(() => {
document.querySelectorAll('.pp-02__player').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const v = root.querySelector('.pp-02__video');
const btn = root.querySelector('.pp-02__big');
const label = root.querySelector('.pp-02__label');
const now = root.querySelector('.pp-02__now');
const dur = root.querySelector('.pp-02__dur');
const fmt = (s) => (isFinite(s) ? Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0') : '0:00');
const sync = () => {
const playing = !v.paused && !v.ended;
root.dataset.state = playing ? 'playing' : 'paused';
btn.setAttribute('aria-pressed', String(playing));
label.textContent = playing ? 'Pause video' : 'Play video';
};
btn.addEventListener('click', () => { v.paused ? v.play().catch(sync) : v.pause(); });
['play', 'pause', 'ended'].forEach((e) => v.addEventListener(e, sync));
v.addEventListener('loadedmetadata', () => { dur.textContent = fmt(v.duration); });
v.addEventListener('timeupdate', () => {
root.style.setProperty('--pp-02-progress', v.duration ? v.currentTime / v.duration : 0);
now.textContent = fmt(v.currentTime);
});
root.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key.toLowerCase() === 'k') { e.preventDefault(); btn.click(); }
});
sync();
});
})();(() => {
document.querySelectorAll('.pp-02__player').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const v = root.querySelector('.pp-02__video');
const btn = root.querySelector('.pp-02__big');
const label = root.querySelector('.pp-02__label');
const now = root.querySelector('.pp-02__now');
const dur = root.querySelector('.pp-02__dur');
const fmt = (s) => (isFinite(s) ? Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0') : '0:00');
const sync = () => {
const playing = !v.paused && !v.ended;
root.dataset.state = playing ? 'playing' : 'paused';
btn.setAttribute('aria-pressed', String(playing));
label.textContent = playing ? 'Pause video' : 'Play video';
};
btn.addEventListener('click', () => { v.paused ? v.play().catch(sync) : v.pause(); });
['play', 'pause', 'ended'].forEach((e) => v.addEventListener(e, sync));
v.addEventListener('loadedmetadata', () => { dur.textContent = fmt(v.duration); });
v.addEventListener('timeupdate', () => {
root.style.setProperty('--pp-02-progress', v.duration ? v.currentTime / v.duration : 0);
now.textContent = fmt(v.currentTime);
});
root.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key.toLowerCase() === 'k') { e.preventDefault(); btn.click(); }
});
sync();
});
})();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: Custom HTML5 Video Player Overlay
Source: https://codefronts.com/components/css-play-pause-buttons/custom-html5-video-player-overlay/
The component every video page needs: a poster-framed <video> with the native controls off, a centered glass play button over a gradient scrim, and a real scrub bar wired to timeupdate. The overlay fades out while playing and returns on pause or hover — the behaviour users expect from YouTube-class players — and the whole control surface is keyboard operable, with Space and K bound to toggle like a native player.
## HTML
```html
<section class="pp-02" aria-label="Custom HTML5 video player overlay demo">
<div class="pp-02__stage">
<div class="pp-02__player" data-state="paused">
<video class="pp-02__video" preload="none" playsinline muted
poster="https://images.unsplash.com/photo-1478720568477-152d9b164e26?q=80&w=1200&auto=format&fit=crop"
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"></video>
<div class="pp-02__scrim" aria-hidden="true"></div>
<button class="pp-02__big" type="button" aria-pressed="false">
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
<path class="pp-02__i-play" d="M8 5.2v13.6L19 12z" fill="currentColor"/>
<path class="pp-02__i-pause" d="M8 5h3.2v14H8zM12.8 5H16v14h-3.2z" fill="currentColor"/>
</svg>
<span class="pp-02__label">Play video</span>
</button>
<div class="pp-02__bar" aria-hidden="true"><span class="pp-02__fill"></span></div>
<p class="pp-02__time" aria-hidden="true"><span class="pp-02__now">0:00</span> / <span class="pp-02__dur">0:00</span></p>
</div>
<p class="pp-02__chip" aria-hidden="true">preload="none" + poster · media events drive state · --pp-02-progress → scaleX</p>
</div>
</section>
```
## CSS
```css
.pp-02,
.pp-02 *,
.pp-02 *::before,
.pp-02 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-02 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-02-bg);
--pp-02-bg: oklch(0.15 0.012 260);
--pp-02-ink: oklch(0.98 0.004 250);
--pp-02-mut: oklch(0.72 0.015 255);
--pp-02-acc: oklch(0.72 0.17 25);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-02-ink);
}
.pp-02__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 22px;
padding: clamp(18px,4vw,52px);
}
.pp-02__player {
--pp-02-progress: 0;
position: relative;
width: min(100%,760px);
aspect-ratio: 16/9;
border-radius: 18px;
overflow: hidden;
background: linear-gradient(140deg,oklch(0.32 0.05 240),oklch(0.2 0.03 280));
box-shadow: 0 0 0 1px oklch(1 0 0/.09),0 30px 70px oklch(0 0 0/.5);
}
.pp-02__video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pp-02__scrim {
position: absolute;
inset: 0;
background: radial-gradient(60% 60% at 50% 45%,oklch(0 0 0/.28),transparent),linear-gradient(to top,oklch(0 0 0/.62),transparent 42%);
opacity: 1;
transition: opacity .4s;
pointer-events: none;
}
.pp-02__player[data-state="playing"] .pp-02__scrim {
opacity: .35;
}
.pp-02__big {
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
display: grid;
place-items: center;
width: 88px;
height: 88px;
border: 1px solid oklch(1 0 0/.35);
border-radius: 50%;
background: oklch(1 0 0/.14);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
color: var(--pp-02-ink);
cursor: pointer;
transition: transform .3s cubic-bezier(.34,1.4,.5,1),opacity .35s,background .25s;
}
.pp-02__big:hover {
background: oklch(1 0 0/.24);
transform: translate(0,0) scale(1.07);
}
.pp-02__big:focus-visible {
outline: 3px solid var(--pp-02-acc);
outline-offset: 4px;
}
.pp-02__player[data-state="playing"] .pp-02__big {
opacity: 0;
pointer-events: none;
scale: .86;
}
.pp-02__player[data-state="playing"]:hover .pp-02__big,
.pp-02__player[data-state="playing"]:focus-within .pp-02__big {
opacity: 1;
pointer-events: auto;
scale: 1;
}
.pp-02__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pp-02__i-pause {
display: none;
}
.pp-02__player[data-state="playing"] .pp-02__i-play {
display: none;
}
.pp-02__player[data-state="playing"] .pp-02__i-pause {
display: block;
}
.pp-02__bar {
position: absolute;
left: 18px;
right: 18px;
bottom: 20px;
height: 5px;
border-radius: 99px;
background: oklch(1 0 0/.25);
overflow: hidden;
}
.pp-02__fill {
display: block;
height: 100%;
border-radius: 99px;
background: var(--pp-02-acc);
transform: scaleX(var(--pp-02-progress));
transform-origin: left;
transition: transform .12s linear;
}
.pp-02__time {
position: absolute;
right: 18px;
bottom: 34px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--pp-02-ink);
text-shadow: 0 1px 3px oklch(0 0 0/.6);
}
.pp-02__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-02-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-02 * {
transition-duration: .01ms !important;
}
}
```
## JavaScript
```js
(() => {
document.querySelectorAll('.pp-02__player').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const v = root.querySelector('.pp-02__video');
const btn = root.querySelector('.pp-02__big');
const label = root.querySelector('.pp-02__label');
const now = root.querySelector('.pp-02__now');
const dur = root.querySelector('.pp-02__dur');
const fmt = (s) => (isFinite(s) ? Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0') : '0:00');
const sync = () => {
const playing = !v.paused && !v.ended;
root.dataset.state = playing ? 'playing' : 'paused';
btn.setAttribute('aria-pressed', String(playing));
label.textContent = playing ? 'Pause video' : 'Play video';
};
btn.addEventListener('click', () => { v.paused ? v.play().catch(sync) : v.pause(); });
['play', 'pause', 'ended'].forEach((e) => v.addEventListener(e, sync));
v.addEventListener('loadedmetadata', () => { dur.textContent = fmt(v.duration); });
v.addEventListener('timeupdate', () => {
root.style.setProperty('--pp-02-progress', v.duration ? v.currentTime / v.duration : 0);
now.textContent = fmt(v.currentTime);
});
root.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key.toLowerCase() === 'k') { e.preventDefault(); btn.click(); }
});
sync();
});
})();
```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: Custom HTML5 Video Player Overlay
Source: https://codefronts.com/components/css-play-pause-buttons/custom-html5-video-player-overlay/
The component every video page needs: a poster-framed <video> with the native controls off, a centered glass play button over a gradient scrim, and a real scrub bar wired to timeupdate. The overlay fades out while playing and returns on pause or hover — the behaviour users expect from YouTube-class players — and the whole control surface is keyboard operable, with Space and K bound to toggle like a native player.
## HTML
```html
<section class="pp-02" aria-label="Custom HTML5 video player overlay demo">
<div class="pp-02__stage">
<div class="pp-02__player" data-state="paused">
<video class="pp-02__video" preload="none" playsinline muted
poster="https://images.unsplash.com/photo-1478720568477-152d9b164e26?q=80&w=1200&auto=format&fit=crop"
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"></video>
<div class="pp-02__scrim" aria-hidden="true"></div>
<button class="pp-02__big" type="button" aria-pressed="false">
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true">
<path class="pp-02__i-play" d="M8 5.2v13.6L19 12z" fill="currentColor"/>
<path class="pp-02__i-pause" d="M8 5h3.2v14H8zM12.8 5H16v14h-3.2z" fill="currentColor"/>
</svg>
<span class="pp-02__label">Play video</span>
</button>
<div class="pp-02__bar" aria-hidden="true"><span class="pp-02__fill"></span></div>
<p class="pp-02__time" aria-hidden="true"><span class="pp-02__now">0:00</span> / <span class="pp-02__dur">0:00</span></p>
</div>
<p class="pp-02__chip" aria-hidden="true">preload="none" + poster · media events drive state · --pp-02-progress → scaleX</p>
</div>
</section>
```
## CSS
```css
.pp-02,
.pp-02 *,
.pp-02 *::before,
.pp-02 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.pp-02 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--pp-02-bg);
--pp-02-bg: oklch(0.15 0.012 260);
--pp-02-ink: oklch(0.98 0.004 250);
--pp-02-mut: oklch(0.72 0.015 255);
--pp-02-acc: oklch(0.72 0.17 25);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--pp-02-ink);
}
.pp-02__stage {
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
align-content: center;
gap: 22px;
padding: clamp(18px,4vw,52px);
}
.pp-02__player {
--pp-02-progress: 0;
position: relative;
width: min(100%,760px);
aspect-ratio: 16/9;
border-radius: 18px;
overflow: hidden;
background: linear-gradient(140deg,oklch(0.32 0.05 240),oklch(0.2 0.03 280));
box-shadow: 0 0 0 1px oklch(1 0 0/.09),0 30px 70px oklch(0 0 0/.5);
}
.pp-02__video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pp-02__scrim {
position: absolute;
inset: 0;
background: radial-gradient(60% 60% at 50% 45%,oklch(0 0 0/.28),transparent),linear-gradient(to top,oklch(0 0 0/.62),transparent 42%);
opacity: 1;
transition: opacity .4s;
pointer-events: none;
}
.pp-02__player[data-state="playing"] .pp-02__scrim {
opacity: .35;
}
.pp-02__big {
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
display: grid;
place-items: center;
width: 88px;
height: 88px;
border: 1px solid oklch(1 0 0/.35);
border-radius: 50%;
background: oklch(1 0 0/.14);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
color: var(--pp-02-ink);
cursor: pointer;
transition: transform .3s cubic-bezier(.34,1.4,.5,1),opacity .35s,background .25s;
}
.pp-02__big:hover {
background: oklch(1 0 0/.24);
transform: translate(0,0) scale(1.07);
}
.pp-02__big:focus-visible {
outline: 3px solid var(--pp-02-acc);
outline-offset: 4px;
}
.pp-02__player[data-state="playing"] .pp-02__big {
opacity: 0;
pointer-events: none;
scale: .86;
}
.pp-02__player[data-state="playing"]:hover .pp-02__big,
.pp-02__player[data-state="playing"]:focus-within .pp-02__big {
opacity: 1;
pointer-events: auto;
scale: 1;
}
.pp-02__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.pp-02__i-pause {
display: none;
}
.pp-02__player[data-state="playing"] .pp-02__i-play {
display: none;
}
.pp-02__player[data-state="playing"] .pp-02__i-pause {
display: block;
}
.pp-02__bar {
position: absolute;
left: 18px;
right: 18px;
bottom: 20px;
height: 5px;
border-radius: 99px;
background: oklch(1 0 0/.25);
overflow: hidden;
}
.pp-02__fill {
display: block;
height: 100%;
border-radius: 99px;
background: var(--pp-02-acc);
transform: scaleX(var(--pp-02-progress));
transform-origin: left;
transition: transform .12s linear;
}
.pp-02__time {
position: absolute;
right: 18px;
bottom: 34px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--pp-02-ink);
text-shadow: 0 1px 3px oklch(0 0 0/.6);
}
.pp-02__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
color: var(--pp-02-mut);
padding: 7px 14px;
border: 1px solid oklch(1 0 0/.13);
border-radius: 99px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.pp-02 * {
transition-duration: .01ms !important;
}
}
```
## JavaScript
```js
(() => {
document.querySelectorAll('.pp-02__player').forEach((root) => {
if (root.dataset.wired) return;
root.dataset.wired = '1';
const v = root.querySelector('.pp-02__video');
const btn = root.querySelector('.pp-02__big');
const label = root.querySelector('.pp-02__label');
const now = root.querySelector('.pp-02__now');
const dur = root.querySelector('.pp-02__dur');
const fmt = (s) => (isFinite(s) ? Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0') : '0:00');
const sync = () => {
const playing = !v.paused && !v.ended;
root.dataset.state = playing ? 'playing' : 'paused';
btn.setAttribute('aria-pressed', String(playing));
label.textContent = playing ? 'Pause video' : 'Play video';
};
btn.addEventListener('click', () => { v.paused ? v.play().catch(sync) : v.pause(); });
['play', 'pause', 'ended'].forEach((e) => v.addEventListener(e, sync));
v.addEventListener('loadedmetadata', () => { dur.textContent = fmt(v.duration); });
v.addEventListener('timeupdate', () => {
root.style.setProperty('--pp-02-progress', v.duration ? v.currentTime / v.duration : 0);
now.textContent = fmt(v.currentTime);
});
root.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key.toLowerCase() === 'k') { e.preventDefault(); btn.click(); }
});
sync();
});
})();
```How this works
Three pieces: a poster that costs nothing, a button that owns the state, and CSS that reads that state.
<video preload="none" poster="poster.jpg" playsinline></video>
<button class="big" aria-pressed="false">…</button>btn.addEventListener('click', () => v.paused ? v.play() : v.pause());
v.addEventListener('play', () => root.dataset.state = 'playing');
v.addEventListener('pause', () => root.dataset.state = 'paused');
v.addEventListener('timeupdate', () =>
root.style.setProperty('--pp-02-progress', v.currentTime / v.duration));Notice what JS does not do: it never animates and never touches a class per frame. It writes one custom property, --pp-02-progress (0→1), and the bar is transform:scaleX(var(--pp-02-progress)) — a compositor-only update. And crucially the state comes from the media element's own events, not from the click: if the video ends, stalls, or is paused by the OS media keys, the UI still tells the truth.
preload="none" plus a poster means the page ships an image, not a video — the file only downloads on first play, which is the single biggest LCP win on video-heavy pages. The overlay uses backdrop-filter:blur() over a scrim gradient so the button stays legible on bright and dark frames alike.
Make it yours
- Autoplay teaser: add
muted loop playsinline autoplayand keep the overlay — the button then toggles sound instead of playback (swap the icons for speaker glyphs). - Click-anywhere-to-pause: bind the click handler to the <video> as well; keep the button for touch users so the hit target stays ≥ 44px.
- Chapter markers: absolutely position ticks inside the track at
left:calc(var(--pp-02-t) * 100%)using a per-tick custom property. - Skinning: the whole surface reads from --pp-02-acc; a single oklch hue swap re-skins button, progress fill and focus ring together.
Gotchas — read before shipping
- Never rely on the click to set state —
play()returns a Promise that can reject (autoplay policy, low power mode). Listen for the play/pause/ended events instead. - controls + a custom overlay double-stacks UI. Remove the controls attribute, but then you MUST provide keyboard access yourself: this demo binds Space and K, and keeps a real focusable button.
- Space scrolls the page when the button is not focused — call preventDefault() in the keydown handler, and only when the player (or body) has focus.
- A play icon on a poster is not a label. The button carries aria-pressed plus a text label that flips between 'Play video' and 'Pause video' so screen readers hear state, not just an icon.
- iOS needs playsinline or the video hijacks the screen into fullscreen on play.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
HTMLMediaElement API is universal. backdrop-filter needs Chrome 76 / Safari 9 / Firefox 103 and degrades to a translucent fill. The scaleX progress bar works everywhere.