The HTML video tag: attributes, captions and background video

One tag with controls gives you a working player. A handful of attributes turn the same element into a silent background loop, and a track element adds captions.

The HTML <video> tag plays a video file in the page. Write <video controls src="clip.mp4"></video> and the browser draws a player with play, a progress bar, volume and full screen. Every other attribute changes how that player starts, loops and looks.

Try them below. Tick a box and the element is rebuilt from the HTML line underneath, so each attribute acts the way it would in your own page.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Video tag attribute playground</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .stage { max-width: 480px; aspect-ratio: 16 / 9; background: #11151c; border-radius: 10px; overflow: hidden; }
  .stage video { display: block; width: 100%; height: 100%; }
  .opts { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 12px 0 8px; font-size: 14px; }
  .opts label { display: flex; align-items: center; gap: 5px; }
  code { display: block; padding: 8px 10px; border-radius: 8px; background: #fff; border: 1px solid #e1e4ea;
         font: 12.5px/1.45 ui-monospace, Consolas, monospace; overflow-wrap: anywhere; }
  #state { font-size: 13.5px; color: #4b5563; margin: 8px 0 0; }
</style>
</head>
<body>
<div class="stage" id="stage"></div>

<div class="opts" id="opts">
  <label><input type="checkbox" value="controls" checked> controls</label>
  <label><input type="checkbox" value="poster" checked> poster</label>
  <label><input type="checkbox" value="muted" checked> muted</label>
  <label><input type="checkbox" value="loop"> loop</label>
  <label><input type="checkbox" value="autoplay"> autoplay</label>
</div>
<code id="code"></code>
<p id="state">Recording a 4-second test clip in your browser...</p>

<script>
  // Test clip maker: records a few seconds of canvas drawing into a video file (a Blob).
  // On your own page you skip all of this and write src="clip.mp4".
  function makeClip(seconds, paint) {
    const canvas = document.createElement('canvas');
    canvas.width = 640; canvas.height = 360;
    const ctx = canvas.getContext('2d');
    const type = ['video/webm', 'video/mp4'].find((t) => MediaRecorder.isTypeSupported(t));
    const rec = new MediaRecorder(canvas.captureStream(30), { mimeType: type });
    const parts = [];
    rec.ondataavailable = (e) => parts.push(e.data);
    const start = performance.now();
    const timer = setInterval(() => paint(ctx, (performance.now() - start) / 1000), 33);
    paint(ctx, 0);
    rec.start();
    return new Promise((done) => {
      rec.onstop = () => { clearInterval(timer); done(URL.createObjectURL(new Blob(parts, { type }))); };
      setTimeout(() => rec.stop(), seconds * 1000);
    });
  }

  // One frame of the clip: a ball crossing the screen and a clock
  function paint(ctx, t) {
    ctx.fillStyle = `hsl(${200 + t * 25} 70% 45%)`;
    ctx.fillRect(0, 0, 640, 360);
    ctx.fillStyle = '#fff';
    ctx.beginPath(); ctx.arc(60 + t * 130, 180, 40, 0, 7); ctx.fill();
    ctx.font = 'bold 44px system-ui, sans-serif';
    ctx.fillText(t.toFixed(1) + ' s', 24, 64);
  }

  // The poster: a still image drawn once and turned into a data: URL
  function makePoster() {
    const c = document.createElement('canvas');
    c.width = 640; c.height = 360;
    const ctx = c.getContext('2d');
    ctx.fillStyle = '#1f2937'; ctx.fillRect(0, 0, 640, 360);
    ctx.fillStyle = '#fbbf24'; ctx.font = 'bold 56px system-ui, sans-serif';
    ctx.fillText('POSTER', 40, 200);
    ctx.fillStyle = '#d1d5db'; ctx.font = '26px system-ui, sans-serif';
    ctx.fillText('shown until playback starts', 40, 250);
    return c.toDataURL('image/png');
  }

  const stage = document.getElementById('stage');
  const code = document.getElementById('code');
  const state = document.getElementById('state');
  const boxes = document.querySelectorAll('#opts input');
  const poster = makePoster();
  let clip = '';

  // Rebuild the element from HTML, so each attribute acts exactly as it would in a page
  function render() {
    const on = [...boxes].filter((b) => b.checked).map((b) => b.value);
    const attrs = on.filter((a) => a !== 'poster').concat('playsinline');
    const tag = `<video ${attrs.join(' ')} preload="metadata"` +
      (on.includes('poster') ? ' poster="poster.png"' : '') + ' src="clip.webm"></video>';
    code.textContent = tag;
    if (!clip) return;
    stage.innerHTML = tag.replace('poster.png', poster).replace('clip.webm', clip);
    const video = stage.querySelector('video');
    const show = () => {
      const len = isFinite(video.duration) ? video.duration.toFixed(1) + ' s' : '? s';
      const what = video.ended ? 'ended' : video.paused ? 'paused' : 'playing';
      state.textContent = `${what} · ${video.currentTime.toFixed(1)} of ${len} · ` +
        (video.muted ? 'muted' : 'sound on') + (video.loop ? ' · loops' : '');
    };
    ['play', 'pause', 'ended', 'timeupdate', 'loadedmetadata', 'volumechange'].forEach((ev) => video.addEventListener(ev, show));
    show();
  }

  boxes.forEach((b) => b.addEventListener('change', render));
  render();
  makeClip(4.2, paint).then((url) => { clip = url; render(); });
</script>
</body>
</html>
The five attributes you will use most, on a clip made in your browser. Edit the code and the example reruns.

The example has no video file. Its script draws frames on a <canvas>, records four seconds of them with MediaRecorder, and hands the result to the element as a blob: URL. With a real file, src="clip.mp4" replaces all of that.

The clip has no sound track, and your clicks in the box count as interaction with the page. So here, autoplay may start even with muted unticked. On a page nobody has touched yet, a video with sound needs muted to autoplay.

The attributes of the video tag

Attribute What it does
controls Shows the browser's own player. Without it, nothing to click
src The address of one video file
poster An image shown until playback starts
width, height Size in pixels, and the shape of the box before the file loads
muted Starts with the sound off
autoplay Asks to start on load. Usually refused unless muted
loop Starts again from the beginning at the end
playsinline Plays inside the page instead of full screen on phones
preload A hint: none, metadata or auto

Set width and height even when CSS resizes the video. Browsers use the two numbers as the aspect ratio of the box, so with width: 100%; height: auto the space is reserved before the file arrives and the text below does not jump.

The poster disappears once playback starts or someone seeks. With autoplay, it shows only for a moment. Without a poster, the box stays empty until the first frame loads, and with preload="none" that may not happen until play.

The muted attribute only sets the starting state. To mute or unmute from a script, set video.muted = true. Changing the attribute afterwards does not change the sound.

Several formats with source

src points at a single file. To offer a choice, leave out src and put <source> elements inside the tag. The browser goes down the list and uses the first one it can play.

The type attribute lets the browser skip a format it cannot play without downloading it.
The type attribute lets the browser skip a format it cannot play without downloading it.
<video controls width="640" height="360" poster="still.jpg">
  <source src="clip.webm" type="video/webm">
  <source src="clip.mp4" type="video/mp4">
  Your browser cannot play this video.
</video>

Put your preferred format first and MP4 last. The text inside only shows in a browser that does not know the <video> element at all.

From a script, video.canPlayType('video/webm') answers "probably", "maybe" or an empty string. When a path is wrong, the problem is not the format at all: HTML video file not found walks through that case.

A video as a CSS-like background

CSS background-image only takes images. For a moving background, put a real <video> element behind the content and let CSS make it act like a background.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Background video hero</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .hero {
    position: relative; overflow: hidden;   /* the video is cropped to this box */
    height: 290px; border-radius: 12px; background: #0f172a;
    display: grid; place-items: center; text-align: center;
  }
  .hero video {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    object-fit: cover;                      /* fill the box, crop the overflow */
  }
  .hero .shade { position: absolute; inset: 0; background: rgba(15, 23, 42, .45); }  /* keeps the text readable */
  .hero .copy { position: relative; color: #fff; padding: 0 20px; }
  .hero h2 { margin: 0 0 8px; font-size: clamp(24px, 6vw, 36px); }
  .hero p { margin: 0; font-size: 16px; opacity: .9; }
  .pause {
    position: absolute; right: 12px; bottom: 12px;
    padding: 7px 12px; border: 0; border-radius: 99px;
    background: rgba(255, 255, 255, .9); font: 600 14px system-ui, sans-serif; cursor: pointer;
  }
  .fit { margin-top: 10px; font-size: 14px; }
</style>
</head>
<body>
<section class="hero">
  <video id="bg" autoplay muted loop playsinline aria-hidden="true"></video>
  <div class="shade"></div>
  <div class="copy">
    <h2>Pages people can try</h2>
    <p>Text on top of a moving background.</p>
  </div>
  <button class="pause" id="toggle" type="button">Pause video</button>
</section>
<label class="fit">object-fit:
  <select id="fit"><option>cover</option><option>contain</option><option>fill</option></select>
</label>

<script>
  const video = document.getElementById('bg');
  const toggle = document.getElementById('toggle');

  // Respect "reduce motion": do not start moving on its own
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
    video.autoplay = false;
    toggle.textContent = 'Play video';
  }

  // Stand-in for a real file: a live canvas animation fed straight into the video.
  // On your own page: <video src="bg.mp4" autoplay muted loop playsinline>
  const canvas = document.createElement('canvas');
  canvas.width = 640; canvas.height = 360;          // a 16:9 "video"
  const ctx = canvas.getContext('2d');
  (function draw(now) {
    const t = (now || 0) / 1000;
    ctx.fillStyle = '#1e3a8a'; ctx.fillRect(0, 0, 640, 360);
    for (let i = 0; i < 5; i++) {                    // round circles show any stretching
      ctx.fillStyle = `hsl(${180 + i * 30} 80% 60% / .8)`;
      ctx.beginPath();
      ctx.arc(320 + Math.cos(t * .6 + i * 1.3) * 230, 180 + Math.sin(t * .8 + i) * 110, 55, 0, 7);
      ctx.fill();
    }
    requestAnimationFrame(draw);
  })();
  video.srcObject = canvas.captureStream(30);

  toggle.addEventListener('click', () => {
    if (video.paused) video.play(); else video.pause();
  });
  video.addEventListener('play', () => { toggle.textContent = 'Pause video'; });
  video.addEventListener('pause', () => { toggle.textContent = 'Play video'; });

  document.getElementById('fit').addEventListener('change', (e) => {
    video.style.objectFit = e.target.value;
  });
</script>
</body>
</html>
A muted, looping background with text on top and a pause button. Switch object-fit to see why cover is the one to use.
The video, a dark overlay and the text sit in one positioned box. cover fills it without stretching.
The video, a dark overlay and the text sit in one positioned box. cover fills it without stretching.
.hero { position: relative; overflow: hidden; height: 60vh; }
.hero video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}
.hero .content { position: relative; }
<video src="bg.mp4" autoplay muted loop playsinline aria-hidden="true"></video>
  1. object-fit: cover fills the box and crops what does not fit. The default for a video is contain, which leaves bars. CSS object-fit covers all five values.
  2. autoplay muted loop playsinline – all four. Leave out muted and the browser will usually refuse to start. Without playsinline, Safari on iPhone may switch to full screen.
  3. An overlay – a see-through dark layer keeps white text readable on light frames.

The example feeds a live canvas animation into the element with canvas.captureStream() and video.srcObject, so there is no file to loop. On your page, the file and loop do that job.

Motion that starts on its own needs a way to stop it. The pause button is ordinary video.pause(). The example also checks prefers-reduced-motion and does not start the video for visitors who asked for less motion.

When autoplay still fails, HTML video not autoplaying has the full rule. For a looping clip in an article rather than a background, see embedding a video that autoplays and loops.

Captions with track and WebVTT

Captions live in a separate text file in WebVTT format. A <track> element inside the video points at it, and the browser draws the lines over the picture at the right times.

<video controls src="clip.mp4">
  <track src="captions.vtt" kind="captions" srclang="en" label="English" default>
</video>
An SRT file renamed to .vtt does not work. WebVTT needs its own header and dots in the timestamps.
An SRT file renamed to .vtt does not work. WebVTT needs its own header and dots in the timestamps.
kind Used for Drawn on the video
subtitles Translation of the dialogue Yes
captions Dialogue plus sounds, for viewers who cannot hear it Yes
descriptions Text description of what is on screen No
chapters Chapter titles for navigation No
metadata Data for your scripts No

srclang is the language code and label is the name shown in the player's captions menu. default switches the track on. Without it, the track stays off until the viewer picks it, or your code sets track.mode = 'showing'.

A caption file on another domain needs the crossorigin attribute on the video and CORS headers on that server. Otherwise the file is blocked.

The JavaScript API: a custom player

Leave out controls and the element shows only the picture. Your own buttons then call the same methods the built-in player uses.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Custom video player with captions</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .player { max-width: 560px; border-radius: 12px; overflow: hidden; background: #11151c; }
  .player video { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; cursor: pointer; }
  video::cue { background: rgba(0, 0, 0, .75); color: #fff; }
  .bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: #1f2937; color: #e5e7eb; }
  .bar button {
    min-width: 40px; height: 34px; border: 0; border-radius: 8px;
    background: #374151; color: #fff; font: 700 14px system-ui, sans-serif; cursor: pointer;
  }
  .bar button[aria-pressed="true"] { background: #16a34a; }
  .bar input { flex: 1; min-width: 0; accent-color: #22c55e; }
  #time { font: 13px ui-monospace, Consolas, monospace; white-space: nowrap; }
  #note { font-size: 13.5px; color: #4b5563; margin: 8px 0 0; }
</style>
</head>
<body>
<div class="player">
  <video id="video" playsinline preload="metadata" width="640" height="360">
    <track kind="captions" srclang="en" label="English" default>
  </video>
  <div class="bar">
    <button id="play" type="button" aria-label="Play">&#9654;</button>
    <input id="seek" type="range" min="0" max="0" step="0.1" value="0" aria-label="Seek">
    <span id="time">0:00 / 0:00</span>
    <button id="cc" type="button" aria-pressed="true" aria-label="Captions">CC</button>
  </div>
</div>
<p id="note">Recording a 6-second test clip in your browser...</p>

<script>
  const video = document.getElementById('video');
  const playBtn = document.getElementById('play');
  const seek = document.getElementById('seek');
  const time = document.getElementById('time');
  const cc = document.getElementById('cc');

  // Captions in WebVTT format. On your own page: <track src="captions.vtt" ...>
  const vtt = `WEBVTT

00:00.000 --> 00:02.000
A square enters from the left.

00:02.000 --> 00:04.000
It turns into a circle.

00:04.000 --> 00:06.000
And stops at the finish line.`;
  video.querySelector('track').src = URL.createObjectURL(new Blob([vtt], { type: 'text/vtt' }));

  // Test clip maker: records canvas drawing into a video Blob (stands in for src="clip.mp4")
  function makeClip(seconds, paint) {
    const canvas = document.createElement('canvas');
    canvas.width = 640; canvas.height = 360;
    const ctx = canvas.getContext('2d');
    const type = ['video/webm', 'video/mp4'].find((t) => MediaRecorder.isTypeSupported(t));
    const rec = new MediaRecorder(canvas.captureStream(30), { mimeType: type });
    const parts = [];
    rec.ondataavailable = (e) => parts.push(e.data);
    const start = performance.now();
    const timer = setInterval(() => paint(ctx, (performance.now() - start) / 1000), 33);
    paint(ctx, 0);
    rec.start();
    return new Promise((done) => {
      rec.onstop = () => { clearInterval(timer); done(URL.createObjectURL(new Blob(parts, { type }))); };
      setTimeout(() => rec.stop(), seconds * 1000);
    });
  }
  makeClip(6.2, (ctx, t) => {
    ctx.fillStyle = '#e0f2fe'; ctx.fillRect(0, 0, 640, 360);
    ctx.fillStyle = '#0f172a'; ctx.fillRect(560, 60, 6, 240);            // finish line
    const x = Math.min(40 + t * 110, 490);
    const r = Math.min(Math.max(t - 2, 0) / 2, 1) * 35;                   // corners round off from 2 s to 4 s
    ctx.fillStyle = '#ea580c';
    ctx.beginPath(); ctx.roundRect(x, 145, 70, 70, r); ctx.fill();
  }).then((url) => {
    video.src = url;
    document.getElementById('note').textContent = 'Ready. Click the video or the play button.';
  });

  // --- The player: everything below works the same with a real file ---
  const fmt = (s) => Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');

  function render() {
    const len = isFinite(video.duration) ? video.duration : 0;
    seek.max = len;
    seek.value = video.currentTime;
    time.textContent = fmt(video.currentTime) + ' / ' + fmt(len);
  }

  function togglePlay() {
    if (video.paused || video.ended) video.play(); else video.pause();
  }

  playBtn.addEventListener('click', togglePlay);
  video.addEventListener('click', togglePlay);
  video.addEventListener('play', () => { playBtn.innerHTML = '&#10074;&#10074;'; playBtn.setAttribute('aria-label', 'Pause'); });
  video.addEventListener('pause', () => { playBtn.innerHTML = '&#9654;'; playBtn.setAttribute('aria-label', 'Play'); });
  video.addEventListener('loadedmetadata', render);
  video.addEventListener('timeupdate', render);
  seek.addEventListener('input', () => { video.currentTime = seek.value; });

  // Captions on and off: the track stays loaded, only its mode changes
  cc.addEventListener('click', () => {
    const track = video.textTracks[0];
    track.mode = track.mode === 'showing' ? 'hidden' : 'showing';
    cc.setAttribute('aria-pressed', track.mode === 'showing');
  });
</script>
</body>
</html>
Play, seek, a time label and a captions switch, built on the video API. The captions come from a WebVTT file made in the page.
Member What it does
play() Starts playback. Returns a promise that rejects if the browser refuses
pause() Pauses. To stop, pause and set currentTime = 0
currentTime Position in seconds. Set it to jump
duration Length in seconds. NaN until metadata has loaded
paused, ended true or false
textTracks The loaded tracks. Each one has a mode

The player listens for four events and writes the page from them:

  • loadedmetadata: the length is known. Set the seek bar's max.
  • timeupdate: fires repeatedly while playing. Move the seek bar and the time label.
  • play and pause: switch the button icon and its aria-label.
  • input on the range: set currentTime from its value.

The captions button flips textTracks[0].mode between showing and hidden. The track stays loaded, so switching back is instant. The button's aria-pressed tells screen readers whether captions are on.

When it does not work

What you see Cause Fix
Video stays still, no error Autoplay with sound was blocked Add muted, or start from a click
iPhone jumps to full screen No playsinline Add playsinline
Blank player, or error in the console Format or codec the browser cannot play, or wrong server type Offer an MP4 <source>, check canPlayType and the Content-Type header
Black bars around the picture Default object-fit: contain object-fit: cover
Picture stretched object-fit: fill in your CSS cover or contain
Captions never appear Missing WEBVTT line, commas in times, or no default Fix the file, add default or set mode
Poster never shows Wrong image path, or autoplay starts at once Check the path in the Network tab, remove autoplay to test
Page jumps when the video loads No width and height Add both attributes

If a track file loads but no line ever appears, open it and look at the timestamps first. A file converted from SRT often keeps the commas.

A page with video is hard to review from a screenshot. Nobody can press play in a picture, and an .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can play, seek and switch captions themselves. If you change the code later, the same link shows the new version.

Questions people ask

Why does my video not autoplay?

Browsers generally block autoplay when the video has sound and the visitor has not interacted with the page yet. Add muted, and playsinline for iPhone, next to autoplay. If it still does not start, see the full checklist in the article on videos not autoplaying.

Can I use a video as a CSS background?

Not with background-image, which only takes images. Put a video element inside the section instead, give it position: absolute, full width and height and object-fit: cover, and place the content above it. The background video example on this page shows the whole pattern.

Which video format should I use?

MP4 with H.264 video is widely supported and a safe single choice. To offer a smaller file as well, list it first in a source element with a type attribute and keep the MP4 source last as the fallback.

Why are my captions not showing?

Check four things: the file starts with WEBVTT, the timestamps use a dot before the milliseconds, the track has kind="captions" or "subtitles", and it has the default attribute or your code sets its mode to showing. A caption file on another domain also needs the crossorigin attribute and CORS headers.

Why does my video show black bars, or look stretched?

When the box has a different shape from the video, the browser fits the whole frame inside it and leaves empty bars. That is the default, object-fit: contain. Use object-fit: cover to fill the box by cropping. A stretched picture means object-fit: fill is set somewhere in your CSS.

Keep reading