Skip to main content
Version: 11.13.0

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.

ControlPlayer API
Play and pauseplay(), pause(), paused, and the play, pause and ended events
Scrubber and timecurrentTime, duration, buffered, and the timeupdate and progress events
Buffering spinnerthe waiting and playing events
SubtitlestextTracks, with mode = 'showing' for the selected track
AudioaudioTracks, with enabled = true for the selected track
Mutemuted and the volumechange event
FullscreenrequestFullscreen() 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
demo.html
<!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>