Video gallery
Press a poster and the film opens over the page. Close it and the player is not paused behind
a display: none — it is gone, taken out of the document, because an embed nobody can pause
is an embed you throw away.
One dialog serves all four films below, and they are not the same kind of thing: two are on
YouTube, one is on Vimeo, one is an .mp4 on somebody's own server with a caption file beside
it. The link says which, the way modally's video landing
worked — and nothing reaches a player until the press: no script, no cookie, no
<iframe> sitting in a closed dialog telling anyone who is reading.
SintelYouTube
Tears of SteelYouTubeBig Buck BunnyVimeo
- Fridayan .mp4, captioned
Open movies by the Blender Foundation, CC BY. The clip and its captions are MDN's, from mdn/shared-assets. The posters are the players' own — what that costs is below.
The markup
A link per film and an empty dialog. The order matters: the links are what the page is made of, the dialog is what the script fills.
<ul class="video-gallery">
<li>
<a href="https://www.youtube.com/watch?v=eRsGyueVLvQ">
<img src="sintel.jpg" alt="" width="480" height="360" loading="lazy" />
<span>Sintel</span>
</a>
<small>YouTube</small>
</li>
<li>
<a href="https://vimeo.com/1084537">…</a>
</li>
<li>
<a href="friday.mp4" data-captions="friday.vtt">…</a>
</li>
</ul>
<modal-elemental closedby="any" close-text="Close the player">
<dialog id="player-dialog" aria-label="Video player">
<div class="player-stage"></div>
</dialog>
</modal-elemental>
The href is the fallback and the data at once. With no script the link goes to the film
and the reader watches it, which is what a link to a film has always done; with script, the id
in it is what the player is built from. There is no data-video to keep in step with the
href, because the href already said it.
The alt is empty on purpose. The link's own text names it, and a poster described as
"Sintel" inside a link that says Sintel is the same name read twice.
Nothing but the name goes inside the link. The label saying where the film is hosted sits
after it, in the <li> — the link's text is what the script hands to the iframe's title and
to the dialog's aria-label, so a "YouTube" tucked in beside the name is a dialog announced
as "Friday an .mp4, captioned, dialog". Found by reading the built page back, which is the
only place that kind of mistake shows up.
The dialog holds a <div>, not the player. <modal-elemental> writes its close button as
the dialog's first child, so a script replacing the dialog's own children would throw that
away with the last film. The stage inside it is the part the script owns.
The glue
// The three shapes a video url comes in — the patterns book-of-spells ships as RE_YOUTUBE,
// RE_VIMEO and RE_VIDEO, which is where modally read them from too.
const YOUTUBE = /(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?/ ]{11})/i;
const VIMEO = /(?:www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/[^/]*\/videos\/|album\/\d+\/video\/|video\/|)(\d+)/i;
const FILE = /\.(?:mp4|m4v|ogv|webm|mov)(?:[?#].*)?$/i;
const gallery = document.querySelector('.video-gallery');
const dialog = document.querySelector('#player-dialog');
const stage = dialog.querySelector('.player-stage');
/** The player a link asks for, or null for a link this knows nothing about. */
function playerFor(link) {
const title = link.textContent.trim();
const youtube = link.href.match(YOUTUBE);
const vimeo = link.href.match(VIMEO);
// Both ids are safe in a url by the shape of the pattern that found them: eleven characters
// with no quote, ampersand or slash among them, and a run of digits. Nothing else from the
// page is interpolated here.
if (youtube) return embed(`https://www.youtube-nocookie.com/embed/${youtube[1]}?autoplay=1`, title);
if (vimeo) return embed(`https://player.vimeo.com/video/${vimeo[1]}?autoplay=1&dnt=1`, title);
return FILE.test(link.href) ? file(link) : null;
}
/** A player on somebody else's origin: an iframe, and a name for it. */
function embed(src, title) {
const player = document.createElement('iframe');
player.src = src;
// Without this an iframe is announced as "frame", and a dialog holding one is a dialog
// holding nothing.
player.title = title;
player.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
player.allowFullscreen = true;
return player;
}
/** A file you host: a real element, which is why this one can still be paused. */
function file(link) {
const player = document.createElement('video');
player.src = link.href;
player.controls = true;
player.playsInline = true;
if (link.dataset.captions) {
const track = document.createElement('track');
track.kind = 'captions';
track.src = link.dataset.captions;
track.srclang = 'en';
track.label = 'English';
track.default = true;
// A caption file from another origin is fetched under CORS. Without this the track loads
// as nothing at all, and says nothing about it.
player.crossOrigin = 'anonymous';
player.append(track);
}
return player;
}
gallery.addEventListener('click', (e) => {
const link = e.target.closest('a[href]');
if (!link) return;
const player = playerFor(link);
// Not a film this knows how to play: leave the link alone and let the browser follow it.
if (!player) return;
e.preventDefault();
stage.replaceChildren(player);
// Named after what is in it, so a screen reader says "Sintel, dialog" rather than "video
// player, dialog" four films running.
dialog.setAttribute('aria-label', link.textContent.trim());
dialog.showModal();
// Autoplay is a url parameter for the embeds, because their frame is built inside this same
// click; a `<video>` is asked directly, and a rejected promise is the browser's autoplay
// policy rather than a bug.
if (player.localName === 'video') player.play().catch(() => {});
});
// Gone rather than paused, whichever kind it was. `<modal-elemental>` pauses a `<video>` and
// parks an `<iframe>` that is still there at `about:blank` on close — this is the version
// with nothing left to stop.
document.addEventListener('modal-toggle', (e) => {
if (!e.detail.open) stage.replaceChildren();
});
dialog.showModal() is the platform's own call on the platform's own element, and the element
notices: it animates the open, counts the backdrop, locks the page's scroll and returns focus
to the poster afterwards, exactly as though an invoker had opened it. Nothing here talks to
<modal-elemental> at all except that last listener, which is the element saying a modal
closed.
What modally did, and what this does instead
modally had a video landing: one modal, a
data-video attribute on the trigger, three url shapes told apart by regex, autoplay on by
default, and the embed unmounted on close. This is the same feature, as page code — which is
why <modal-elemental> has no video option and is not getting one.
| modally | here |
|---|---|
new Modally({ video: true }) and data-video="…" on the trigger |
a link to the film: the href is the data, and the fallback |
YouTube, Vimeo or a file, matched by RE_YOUTUBE, RE_VIMEO, RE_VIDEO |
the same three, the same patterns |
hidden <iframe> templates in the library, cloned and filled |
built in the page, in playerFor |
autoplay: true by default |
autoplay=1 on the embeds, play() on a <video> |
| the embed unmounted on close | the stage emptied on close |
| — | youtube-nocookie.com, and dnt=1 on Vimeo |
| — | data-captions on the link becomes a <track> |
| an image landing as well | that is the lightbox example |
| a focus trap it wrote itself | showModal(), so the browser's inert does it |
The two rows with nothing on the left are the reason this is worth writing down rather than
porting. A library's template has to be one string for everybody; a builder in your own page
can know that this film has captions and that player has a do-not-track parameter
(dnt=1, which stops Vimeo's session cookie
and third-party tracking).
Why the frame is built and not left there
Measured in Chrome by counting requests — the first two rows against a stand-in frame, the last one on this page itself:
| How | Before the press | On the press | What it costs |
|---|---|---|---|
an <iframe> per film in the markup |
every player loads with the page — a closed <dialog> is display: none, and that has never stopped a fetch |
already there | every reader is announced to Google and Vimeo, including the ones who watch nothing |
<iframe loading="lazy"> per film |
nothing: the frame is not being rendered, so the lazy load never resolves | that player loads, then the film | one frame in the markup per film, and one dialog each |
| a poster, and the player built on open | the poster only | the player loads, then the film | one dialog for the whole gallery, and the script above |
The middle row is enough for a single video and is what the
modal page shows. This page is the last one,
which is the row that survives a gallery: four films are four <iframe>s and four dialogs
against one <div> here, and at forty the difference is the page.
What it still costs
The posters belong to the players. i.ytimg.com and i.vimeocdn.com are Google's and
Vimeo's, and asking them for a still is a request with the reader's IP on it — quieter than a
player, but not silence. Counted in Chrome on this page: three requests for the three stills
on load, against eighteen to the player's own host the moment one film is opened — and that
last number moves, because what a player fetches is the player's business. The honest
version self-hosts the stills: download each one once, serve it from your own origin, and the
gallery says nothing to anybody until it is pressed. This page does not, because a docs page
that ships four JPEGs to make a point about privacy has made a different point.
A <video> loses its place; an embed always did. The element pauses a video on close and
it carries on where the reader left it — but this gallery empties the stage instead, so all
four films restart. That is the price of one dialog serving every kind: keeping the position
means keeping the element, and keeping the element means a dialog per film. Reach for
the modal page's version when there is one video and the
position matters.
Captions are yours to provide. WCAG 2.2 SC 1.2.2
asks for them on anything prerecorded with audio, and a film in a modal is still a film.
YouTube and Vimeo carry their own; a file you host has whatever you put beside it, which is
what data-captions is for. One language here — a page with several carries the pair of
srclang and label on the link too.
What the element is already doing
| Part | What it already does |
|---|---|
<dialog> + showModal() |
The top layer, the page behind going inert, Escape, and focus returning to the poster you opened from |
<modal-elemental> |
The animated open and close, the close button, the click on the backdrop that closedby="any" asks for, the page's scroll, and modal-toggle |
| This page | What a video gallery is: which film, which player it needs, and throwing it away afterwards |