Chromeless UI
Build player controls from scratch with plain HTML, CSS and JavaScript on top of a ChromelessPlayer.
Tip: open your browser's developer console and use the player and THEOplayer variables to interact with this demo.
A ChromelessPlayer plays the video and renders subtitles, but has no controls.
This example doesn't load a UI library: every control is a regular HTML element that calls the player API and listens to player events to stay up to date.
| Control | Player API |
|---|---|
| Play and pause | play(), pause(), paused, and the play, pause and ended events |
| Scrubber and time | currentTime, duration, buffered, and the timeupdate and progress events |
| Buffering spinner | the waiting and playing events |
| Subtitles | textTracks, with mode = 'showing' for the selected track |
| Audio | audioTracks, with enabled = true for the selected track |
| Mute | muted and the volumechange event |
| Fullscreen | requestFullscreen() on the container, presentation.requestMode('fullscreen') as a fallback |
const container = document.querySelector('.player');
const player = new THEOplayer.ChromelessPlayer(container, {
libraryLocation: 'https://cdn.theoplayer.com/dash/theoplayer/',
license: 'your_license_here',
});
playButton.addEventListener('click', () => (player.paused ? player.play() : player.pause()));
player.addEventListener(['play', 'pause'], () => {
playButton.setAttribute('aria-label', player.paused ? 'Play' : 'Pause');
});
The controls are placed inside the player's container, so they stay visible in fullscreen. They hide after 3 seconds without activity while playing. The player can also be used with the keyboard: Tab moves between the controls, Space or Enter plays and pauses, and the left and right arrows skip 10 seconds.
See Getting started with a chromeless UI for a step-by-step guide.
Code
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Chromeless UI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" />
<style>
html,
body {
margin: 0;
padding: 0;
background: #000;
}
.player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #000;
color: #fff;
font-family: 'Noto Sans', sans-serif;
outline: none;
}
.player:fullscreen {
aspect-ratio: auto;
}
.player video {
width: 100%;
height: 100%;
}
/* The player hides other elements in its container and shows its poster on top of the video:
make our own controls visible and put them above the poster. */
.player > .center-icon,
.player > .spinner,
.player > .controls {
z-index: 2;
visibility: visible;
}
.player.idle {
cursor: none;
}
/* Big play/pause icon that flashes when the playback state changes, and the buffering spinner. */
.center-icon,
.spinner {
position: absolute;
top: 50%;
left: 50%;
width: 72px;
height: 72px;
margin: -36px 0 0 -36px;
pointer-events: none;
}
.center-icon {
border-radius: 50%;
background: rgb(0 0 0 / 50%);
opacity: 0;
}
.center-icon.flash {
animation: flash 1.8s ease-out;
}
@keyframes flash {
0%,
45% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.3);
}
}
.center-icon svg {
width: 100%;
height: 100%;
}
.spinner {
display: none;
box-sizing: border-box;
border: 6px solid rgb(255 255 255 / 25%);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
.player.buffering .spinner {
display: block;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Bottom panel with the title, buttons, scrubber and times. */
.controls {
position: absolute;
inset: auto 0 0 0;
padding: 48px 4% 3%;
background: linear-gradient(transparent, rgb(0 0 0 / 75%));
transition: opacity 0.4s;
}
.player.idle .controls {
opacity: 0;
pointer-events: none;
}
.top-row {
display: flex;
align-items: flex-end;
gap: 8px;
margin-bottom: 10px;
}
.title {
flex: 1;
margin: 0;
overflow: hidden;
font-size: clamp(14px, 3vw, 24px);
font-weight: 700;
white-space: nowrap;
text-overflow: ellipsis;
}
.controls button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 8px;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 18%);
color: #fff;
cursor: pointer;
backdrop-filter: blur(12px);
}
.controls button:hover,
.controls button:focus-visible {
background: #fff;
color: #000;
outline: none;
}
.controls button[hidden] {
display: none;
}
.controls button svg {
width: 100%;
height: 100%;
fill: currentColor;
}
.controls button[aria-pressed='true'] {
box-shadow: inset 0 0 0 2px #fff;
}
.scrubber {
position: relative;
height: 20px;
}
.track,
.buffered,
.played {
position: absolute;
top: 7px;
left: 0;
height: 6px;
border-radius: 3px;
pointer-events: none;
}
.track {
right: 0;
background: rgb(255 255 255 / 25%);
}
.buffered {
background: rgb(255 255 255 / 45%);
}
.played {
background: #fff;
}
.scrubber input {
position: absolute;
inset: 0;
width: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.scrubber:focus-within .track {
outline: 2px solid #fff;
outline-offset: 3px;
}
.times {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: clamp(11px, 2vw, 15px);
font-variant-numeric: tabular-nums;
}
/* Subtitle and audio selection pop-ups, above the buttons. */
.track-menu {
position: absolute;
right: 4%;
bottom: calc(100% - 40px);
min-width: 180px;
max-height: 60vh;
overflow-y: auto;
padding: 6px;
border-radius: 12px;
background: rgb(30 30 30 / 85%);
backdrop-filter: blur(12px);
}
.track-menu[hidden] {
display: none;
}
.track-menu p {
margin: 4px 10px 6px;
font-size: 13px;
font-weight: 700;
opacity: 0.7;
}
.controls .track-menu button {
display: flex;
justify-content: flex-start;
gap: 8px;
width: 100%;
height: auto;
padding: 8px 10px;
border-radius: 8px;
background: none;
font: inherit;
font-size: 15px;
text-align: left;
backdrop-filter: none;
}
.controls .track-menu button:hover,
.controls .track-menu button:focus-visible {
background: rgb(255 255 255 / 10%);
color: rgb(255 255 255 / 70%);
}
.controls .track-menu button::before {
content: '';
width: 1em;
}
.controls .track-menu button[aria-checked='true']::before {
content: '\2713';
}
@media (max-width: 480px) {
.controls button {
width: 32px;
height: 32px;
padding: 6px;
}
}
</style>
<!-- For production use, host the THEOplayer library yourself and update this URL and the libraryLocation below. -->
<script src="https://cdn.theoplayer.com/dash/theoplayer/THEOplayer.chromeless.js"></script>
</head>
<body>
<!-- Keyboard: Space or Enter plays and pauses, the left and right arrows skip 10 seconds. -->
<div class="player" tabindex="0" aria-label="Video player">
<div class="center-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="#fff"><path class="center-path" d="M8 5v14l11-7z" /></svg>
</div>
<div class="spinner" aria-hidden="true"></div>
<div class="controls">
<div class="top-row">
<p class="title">Tears of Steel</p>
<button type="button" class="play-button" aria-label="Play">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
</button>
<button
type="button"
class="subtitles-button"
aria-label="Subtitles: off"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="subtitles-menu"
hidden
>
<svg viewBox="0 0 24 24">
<path
d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
/>
</svg>
</button>
<button type="button" class="audio-button" aria-label="Audio" aria-haspopup="menu" aria-expanded="false" aria-controls="audio-menu" hidden>
<svg viewBox="0 0 24 24"><path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z" /></svg>
</button>
<button type="button" class="mute-button" aria-label="Mute">
<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.5 4.5 0 0 0 2.5-4z" /></svg>
</button>
<button type="button" class="fullscreen-button" aria-label="Fullscreen">
<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" /></svg>
</button>
</div>
<div class="track-menu" id="subtitles-menu" role="menu" aria-label="Subtitles" hidden></div>
<div class="track-menu" id="audio-menu" role="menu" aria-label="Audio" hidden></div>
<div class="scrubber">
<div class="track"></div>
<div class="buffered"></div>
<div class="played"></div>
<input type="range" min="0" max="0" step="0.1" value="0" aria-label="Seek" />
</div>
<div class="times">
<span class="elapsed">0:00</span>
<span class="remaining">-0:00</span>
</div>
</div>
</div>
<script>
const PLAY_ICON = 'M8 5v14l11-7z';
const PAUSE_ICON = 'M6 19h4V5H6v14zm8-14v14h4V5h-4z';
const MUTED_ICON = 'M3 9v6h4l5 5V4L7 9H3zm13.6 3 2.7-2.7-1.4-1.4-2.7 2.7-2.7-2.7-1.4 1.4 2.7 2.7-2.7 2.7 1.4 1.4 2.7-2.7 2.7 2.7 1.4-1.4z';
const UNMUTED_ICON = 'M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8v8a4.5 4.5 0 0 0 2.5-4z';
const container = document.querySelector('.player');
const playButton = container.querySelector('.play-button');
const subtitlesButton = container.querySelector('.subtitles-button');
const audioButton = container.querySelector('.audio-button');
const muteButton = container.querySelector('.mute-button');
const fullscreenButton = container.querySelector('.fullscreen-button');
const seekInput = container.querySelector('.scrubber input');
const bufferedBar = container.querySelector('.buffered');
const playedBar = container.querySelector('.played');
const elapsedLabel = container.querySelector('.elapsed');
const remainingLabel = container.querySelector('.remaining');
const centerIcon = container.querySelector('.center-icon');
// A ChromelessPlayer renders only the video (and subtitles): every control on top of it is our own HTML.
// libraryLocation: For demonstration purposes, we use the THEOplayer CDN.
// licenseUrl: Point this to your license file, or replace it with a license property.
const player = new THEOplayer.ChromelessPlayer(container, {
libraryLocation: 'https://cdn.theoplayer.com/dash/theoplayer/',
licenseUrl: '../../../../theoplayer-license.txt',
});
window.player = player;
player.source = {
sources: { src: 'https://cdn.theoplayer.com/video/tears_of_steel/index.m3u8', type: 'application/x-mpegurl' },
poster: 'https://cdn.theoplayer.com/video/posters/tears_of_steel.jpg',
};
// Keep our controls on top of the video elements the player added to the container.
container.append(centerIcon, container.querySelector('.spinner'), container.querySelector('.controls'));
function formatTime(seconds) {
if (!Number.isFinite(seconds)) return '0:00';
seconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = String(seconds % 60).padStart(2, '0');
return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${secs}` : `${minutes}:${secs}`;
}
function setIcon(button, path) {
button.querySelector('path').setAttribute('d', path);
}
// Play and pause
function togglePlay() {
if (player.paused) {
player.play();
} else {
player.pause();
}
}
function updatePlayState() {
setIcon(playButton, player.paused ? PLAY_ICON : PAUSE_ICON);
playButton.setAttribute('aria-label', player.paused ? 'Play' : 'Pause');
updateIdle();
}
function flashCenterIcon() {
centerIcon.querySelector('path').setAttribute('d', player.paused ? PAUSE_ICON : PLAY_ICON);
// Restart the animation, also when it is still running.
centerIcon.classList.remove('flash');
void centerIcon.offsetWidth;
centerIcon.classList.add('flash');
}
centerIcon.addEventListener('animationend', () => centerIcon.classList.remove('flash'));
playButton.addEventListener('click', togglePlay);
player.addEventListener(['play', 'pause', 'ended'], updatePlayState);
player.addEventListener(['play', 'pause'], flashCenterIcon);
// Clicking the video plays or pauses, like on a native player.
// While the controls are hidden, the first tap only shows them again.
let controlsWereHidden = false;
container.addEventListener('pointerdown', () => {
controlsWereHidden = container.classList.contains('idle');
});
container.addEventListener('click', (event) => {
if (event.target.closest('.controls') || controlsWereHidden) return;
// A click outside an open menu only closes it.
if (isAnyMenuOpen()) {
closeMenus();
return;
}
togglePlay();
});
// Buffering spinner
player.addEventListener('waiting', () => container.classList.add('buffering'));
player.addEventListener(['playing', 'pause', 'ended', 'error'], () => container.classList.remove('buffering'));
// Scrubber and times
let seeking = false;
function updateTime() {
const duration = player.duration;
const time = seeking ? Number(seekInput.value) : player.currentTime;
if (Number.isFinite(duration) && duration > 0) {
seekInput.max = String(duration);
if (!seeking) seekInput.value = String(time);
playedBar.style.width = `${(time / duration) * 100}%`;
seekInput.setAttribute('aria-valuetext', `${formatTime(time)} of ${formatTime(duration)}`);
}
elapsedLabel.textContent = formatTime(time);
remainingLabel.textContent = `-${formatTime(duration - time)}`;
}
function updateBuffered() {
const duration = player.duration;
const buffered = player.buffered;
let end = 0;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= player.currentTime + 0.5) end = Math.max(end, buffered.end(i));
}
bufferedBar.style.width = Number.isFinite(duration) && duration > 0 ? `${(end / duration) * 100}%` : '0';
}
seekInput.addEventListener('input', () => {
seeking = true;
updateTime();
});
seekInput.addEventListener('change', () => {
player.currentTime = Number(seekInput.value);
seeking = false;
});
player.addEventListener(['timeupdate', 'durationchange', 'seeking'], updateTime);
player.addEventListener(['progress', 'timeupdate', 'durationchange'], updateBuffered);
// Subtitles and audio: each button opens a pop-up menu to pick a track.
const trackMenus = [];
function createTrackMenu({ button, heading, options }) {
const menu = document.getElementById(button.getAttribute('aria-controls'));
// options() returns one { label, checked, select } entry per menu item.
function render() {
const title = document.createElement('p');
title.textContent = heading;
const items = options().map((option) => {
const item = document.createElement('button');
item.type = 'button';
item.setAttribute('role', 'menuitemradio');
item.setAttribute('aria-checked', String(option.checked));
item.textContent = option.label;
item.addEventListener('click', () => {
option.select();
close({ focusButton: true });
});
return item;
});
menu.replaceChildren(title, ...items);
}
function open() {
trackMenus.forEach((other) => other.close());
render();
menu.hidden = false;
button.setAttribute('aria-expanded', 'true');
(menu.querySelector('[aria-checked="true"]') ?? menu.querySelector('button')).focus();
updateIdle();
}
function close({ focusButton = false } = {}) {
if (menu.hidden) return;
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
if (focusButton) button.focus();
updateIdle();
}
button.addEventListener('click', () => (menu.hidden ? open() : close()));
// Up and down move through the menu, Escape closes it.
menu.addEventListener('keydown', (event) => {
const items = Array.from(menu.querySelectorAll('button'));
const index = items.indexOf(document.activeElement);
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const next = (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
items[next].focus();
} else if (event.key === 'Escape') {
event.preventDefault();
close({ focusButton: true });
}
});
const trackMenu = {
close,
isOpen: () => !menu.hidden,
refresh: () => {
if (!menu.hidden) render();
},
};
trackMenus.push(trackMenu);
return trackMenu;
}
function isAnyMenuOpen() {
return trackMenus.some((trackMenu) => trackMenu.isOpen());
}
function closeMenus() {
trackMenus.forEach((trackMenu) => trackMenu.close());
}
// Close the menus when a click goes outside the player.
document.addEventListener('pointerdown', (event) => {
if (!event.target.closest('.player')) closeMenus();
});
// Subtitles: "Off" and each subtitle track.
function subtitleTracks() {
return Array.from(player.textTracks).filter((track) => track.kind === 'subtitles' || track.kind === 'captions');
}
const subtitlesMenu = createTrackMenu({
button: subtitlesButton,
heading: 'Subtitles',
options: () => {
const tracks = subtitleTracks();
const showing = tracks.find((track) => track.mode === 'showing') ?? null;
return [null, ...tracks].map((selected) => ({
label: selected ? selected.label || selected.language : 'Off',
checked: selected === showing,
select: () =>
tracks.forEach((track) => {
track.mode = track === selected ? 'showing' : 'disabled';
}),
}));
},
});
function updateSubtitlesButton() {
const tracks = subtitleTracks();
const showing = tracks.find((track) => track.mode === 'showing');
subtitlesButton.hidden = tracks.length === 0;
subtitlesButton.setAttribute('aria-label', `Subtitles: ${showing ? showing.label || showing.language : 'off'}`);
subtitlesMenu.refresh();
}
player.textTracks.addEventListener(['addtrack', 'removetrack', 'change'], updateSubtitlesButton);
// Audio: each audio track, if there are several.
const audioMenu = createTrackMenu({
button: audioButton,
heading: 'Audio',
options: () =>
Array.from(player.audioTracks).map((track) => ({
label: track.label || track.language,
checked: track.enabled,
select: () => {
track.enabled = true;
},
})),
});
function updateAudioButton() {
const tracks = Array.from(player.audioTracks);
const enabled = tracks.find((track) => track.enabled);
audioButton.hidden = tracks.length < 2;
audioButton.setAttribute('aria-label', `Audio: ${enabled ? enabled.label || enabled.language : 'default'}`);
audioMenu.refresh();
}
player.audioTracks.addEventListener(['addtrack', 'removetrack', 'change'], updateAudioButton);
// Mute
function updateMuteButton() {
setIcon(muteButton, player.muted ? MUTED_ICON : UNMUTED_ICON);
muteButton.setAttribute('aria-label', player.muted ? 'Unmute' : 'Mute');
}
muteButton.addEventListener('click', () => {
player.muted = !player.muted;
});
player.addEventListener('volumechange', updateMuteButton);
// Fullscreen
// Put the whole container in fullscreen, so our own controls stay visible.
// Where an element can't go fullscreen (iPhone), let the player show its video in fullscreen instead.
const fullscreenElement = () => document.fullscreenElement ?? document.webkitFullscreenElement;
const isFullscreen = () => fullscreenElement() === container || player.presentation.currentMode === 'fullscreen';
fullscreenButton.addEventListener('click', () => {
if (fullscreenElement() === container) {
(document.exitFullscreen ?? document.webkitExitFullscreen).call(document);
} else if (player.presentation.currentMode === 'fullscreen') {
player.presentation.requestMode('inline');
} else if (container.requestFullscreen) {
container.requestFullscreen();
} else if (container.webkitRequestFullscreen) {
container.webkitRequestFullscreen();
} else {
player.presentation.requestMode('fullscreen');
}
});
function updateFullscreenButton() {
fullscreenButton.setAttribute('aria-label', isFullscreen() ? 'Exit fullscreen' : 'Fullscreen');
}
document.addEventListener('fullscreenchange', updateFullscreenButton);
document.addEventListener('webkitfullscreenchange', updateFullscreenButton);
player.presentation.addEventListener('presentationmodechange', updateFullscreenButton);
// Hide the controls after 3 seconds without activity while playing.
let idleTimer;
function updateIdle() {
clearTimeout(idleTimer);
container.classList.remove('idle');
// Keep the controls visible while paused or while a menu is open.
if (!player.paused && !isAnyMenuOpen()) {
idleTimer = setTimeout(() => container.classList.add('idle'), 3000);
}
}
container.addEventListener('pointermove', updateIdle);
container.addEventListener('pointerup', updateIdle);
container.addEventListener('focusin', updateIdle);
// Keyboard, like a remote: Space or Enter plays and pauses, the arrows skip 10 seconds.
container.addEventListener('keydown', (event) => {
updateIdle();
if (event.target !== container && event.target !== seekInput) return;
if (event.key === ' ' || (event.key === 'Enter' && event.target === container)) {
event.preventDefault();
togglePlay();
} else if (event.target === container && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
event.preventDefault();
player.currentTime += event.key === 'ArrowLeft' ? -10 : 10;
}
});
updatePlayState();
updateMuteButton();
updateTime();
</script>
</body>
</html>