Building your own controls for an embedded video player
What the browser lets you reach across an iframe boundary, and how to drive a player you cannot style.
9 minute readAssumes you write code
"Can I restyle the YouTube player?" has a short answer and a useful one. The short answer is no. The useful one is that you can hide the player's own controls and drive playback from a bar you built, which gets you most of what people actually want: your colours, your icons, your typography, your watermark, and chapter buttons that jump without a reload.
Why the short answer is no
A YouTube or Vimeo embed is a cross-origin iframe. Same-origin policy means your page cannot read from it, write to it, style it, or script into it. Not difficult: prevented by the browser, deliberately, and no amount of CSS specificity changes that. The play button, the scrubber, the timestamp, the settings menu and the logo are all on the other side of a wall.
What crosses that wall is a message channel the platform chooses to expose. YouTube and Vimeo both publish a player API built onpostMessage. You cannot touch their pixels, but you can ask them to play, pause, seek, change volume and change speed, and you can ask where they are up to. That is enough to build a control surfacebeside the player rather than inside it.
Two very different situations
A video file you host
If the video is an MP4 on your own server or CDN, none of the above applies. A <video> element is part of your document. Setcontrols off, render whatever you like, and drive it directly:
video.play();
video.pause();
video.currentTime = 90;
video.volume = 0.4;
video.playbackRate = 1.5;Events arrive natively: timeupdate, play,pause, volumechange, ratechange,loadedmetadata. Control here is exact. The trade is that you are paying for bandwidth and doing your own transcoding, which is precisely what YouTube is for.
A platform iframe
Everything else in this guide is about this case. The shape is:
- Add the platform's parameter that hides its controls.
- Load the platform's player API.
- Attach it to the iframe and get back a controller object.
- Render your own bar and wire it to that controller.
- Have a plan for when the API does not load.
Driving YouTube
YouTube needs enablejsapi=1 and an originparameter matching your page. Without both, the API refuses to attach and your bar is inert.
const url = new URL('https://www.youtube-nocookie.com/embed/VIDEO_ID');
url.searchParams.set('controls', '0');
url.searchParams.set('enablejsapi', '1');
url.searchParams.set('origin', location.origin);Then load https://www.youtube.com/iframe_api and construct a player on the existing iframe element, which is what lets you keep the frame you already inserted rather than having the API build its own:
const player = new YT.Player(iframeElement, {
events: {
onReady: () => {
setInterval(() => {
update({
time: player.getCurrentTime(),
duration: player.getDuration(),
playing: player.getPlayerState() === 1,
muted: player.isMuted(),
volume: player.getVolume() / 100,
rate: player.getPlaybackRate(),
});
}, 250);
},
},
});Note the polling. YouTube fires state-change events but does not stream the current time, so a scrubber has to ask. A quarter of a second is smooth enough for a progress bar and cheap enough to ignore. Volume is on a 0 to 100 scale here, unlike everywhere else, which is the kind of detail that costs an afternoon if you assume otherwise.
Driving Vimeo
Vimeo is event-driven rather than polled, and its volume runs 0 to 1. Loadhttps://player.vimeo.com/api/player.js, then:
const player = new Vimeo.Player(iframeElement);
player.on('timeupdate', (d) => update({ time: d.seconds, duration: d.duration }));
player.on('play', () => { playing = true; });
player.on('pause', () => { playing = false; });
player.on('volumechange', (d) => { volume = d.volume; });Because the two APIs have such different shapes, it is worth writing a thin adapter that returns the same object either way: something withplay, pause, seek,setMuted, setVolume and setRate, plus a single callback that pushes state out. Your bar then knows nothing about which platform it is driving, and adding a third platform touches one file.
The problem nobody warns you about
controls=0 suppresses the YouTube bar during playback. The moment the video is paused or the pointer moves over it, YouTube redraws its own header, logo, timestamp and suggestion grid inside the frame. If your bar is overlaid on the bottom of the video, it lands on top of theirs, and the result looks broken in exactly the way that makes people give up on the idea.
Two things fix it, and they are worth doing together.
A transparent shield
Cover the iframe with an empty element that swallows pointer events. Hover never reaches YouTube, so the chrome is never triggered, and the shield gives you click-to-pause routed through your own controller:
<div class="shield" aria-hidden="true"></div>
.shield { position: absolute; inset: 0; cursor: pointer; background: transparent; }Mark it aria-hidden. It is a pointer-handling device, not a control, and the real controls in your bar are what assistive technology should find.
Put the bar under the video
Overlaying looks more like a native player, and it is the right choice when you are driving a file you host. On a platform iframe, a bar sitting directly underneath the video cannot collide with chrome you do not control, because it is not sharing the same pixels. It is the boring answer and it is the one that never breaks.
Plan for the API failing
Ad blockers, corporate proxies and content policies all block player API scripts. If yours does not load, you are left with a chromeless video and a bar that does nothing, which is worse than having shipped no bar at all. Detect it and rebuild:
createPlayer(...)
.then((controller) => { bar.hidden = false; })
.catch(() => {
const url = new URL(iframe.src);
url.searchParams.set('controls', '1');
url.searchParams.delete('enablejsapi');
iframe.src = url.toString();
bar.remove();
});Keeping the bar hidden until the controller resolves is the other half of this. A visible control bar that does not respond to clicks reads as a bug in your site, not a blocked third-party script.
Building the bar properly
A control bar made of divs is the most common way this goes wrong. Use the elements that already have the behaviour:
- Buttons are
<button>, with anaria-labelthat changes between Play and Pause as the state does, not a static one. - The scrubber and the volume slider are
<input type="range">. You get arrow keys, Home and End, and a correct role for free. Style the track and thumb with::-webkit-slider-runnable-trackand::-moz-range-track. - Set
aria-valuetexton the scrubber. A screen reader reading "47 percent" is useless; "2:14 of 4:45" is not. - Keep a visible focus ring. A control bar is a place where keyboard users get stranded fastest.
One structural tip: delegate events from a single ancestor rather than binding each button. If your bar is ever re-rendered, in a settings panel or a live preview, handlers bound to the old nodes go with them, and the bar looks fine but does nothing. Delegation survives replacing the whole subtree.
Chapters are the payoff
Once you hold a controller, a named timestamp becomes a one-line seek that happens instantly, with no reload and no buffering from scratch:
button.addEventListener('click', () => {
controller.seek(Number(button.dataset.time));
controller.play();
});Without an API loaded the same button still works, it just has to open the player at that offset instead, which means a reload. Slower, but it means chapters are not silently unavailable in the lighter loading modes described in how to embed video without wrecking your page speed.
One thing to read before shipping
YouTube's API terms of service prohibit obscuring or interfering with the player, and specifically with anything shown during an ad. They also set a minimum player size. A control bar under the video and a small corner watermark are ordinary; covering the frame or hiding ad UI is not. Worth ten minutes with the actual terms before this goes on a commercial site. The list of what the player itself still supports is inevery YouTube embed parameter.