The HTML audio tag, from controls to a custom player

One tag with the controls attribute gives you a working player. The same element also has a JavaScript API, which is how you build your own buttons and handle autoplay rules.

The HTML <audio> tag plays sound in a page. Write <audio controls src="song.mp3"></audio> and the browser draws a player with play, a progress bar and volume. The controls attribute is what makes it visible. Without it, the element takes up no space at all.

Try the player below. The buttons call the same JavaScript methods the built-in controls use, and the box shows what the element reports back.

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>Audio tag basics</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  audio { width: 100%; max-width: 420px; display: block; }
  .row { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 10px; }
  button {
    font: 600 14px system-ui, sans-serif; padding: 9px 14px; border: 0; border-radius: 8px;
    background: #1d4ed8; color: #fff; cursor: pointer;
  }
  button.alt { background: #e5e7eb; color: #1d2330; }
  pre {
    margin: 0; padding: 10px 12px; border-radius: 8px; background: #fff;
    font: 13px/1.6 ui-monospace, Consolas, monospace; white-space: pre-wrap;
  }
</style>
</head>
<body>
<!-- With a real file you would write: <audio controls src="song.mp3"></audio> -->
<audio id="player" controls preload="metadata"></audio>

<div class="row">
  <button id="play">play()</button>
  <button id="pause" class="alt">pause()</button>
  <button id="jump" class="alt">currentTime = 2</button>
</div>
<pre id="out">loading...</pre>

<script>
  // Build a short WAV file in memory, so the example needs no audio file.
  function makeWav(seconds, sampleAt) {
    const rate = 22050, n = Math.floor(seconds * rate);
    const v = new DataView(new ArrayBuffer(44 + n * 2));
    const text = (at, s) => [...s].forEach((c, i) => v.setUint8(at + i, c.charCodeAt(0)));
    text(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); text(8, 'WAVE');
    text(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
    v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
    text(36, 'data'); v.setUint32(40, n * 2, true);
    for (let i = 0; i < n; i++) v.setInt16(44 + i * 2, sampleAt(i / rate) * 32767, true);
    return URL.createObjectURL(new Blob([v.buffer], { type: 'audio/wav' }));
  }

  // Four soft notes, 1 second each
  const notes = [523, 659, 784, 1047];
  const src = makeWav(4, (t) => {
    const f = notes[Math.floor(t)], local = t % 1;
    return 0.3 * Math.exp(-3 * local) * Math.sin(2 * Math.PI * f * t);
  });

  const audio = document.getElementById('player');
  const out = document.getElementById('out');
  audio.src = src;

  function show() {
    out.textContent =
      'currentTime: ' + audio.currentTime.toFixed(2) + ' s\n' +
      'duration:    ' + audio.duration.toFixed(2) + ' s\n' +
      'paused:      ' + audio.paused;
  }

  document.getElementById('play').addEventListener('click', () => {
    audio.play().catch((err) => { out.textContent = 'play() failed: ' + err.name; });
  });
  document.getElementById('pause').addEventListener('click', () => audio.pause());
  document.getElementById('jump').addEventListener('click', () => { audio.currentTime = 2; });

  // The element reports its own state through events
  ['loadedmetadata', 'timeupdate', 'play', 'pause', 'seeked', 'ended']
    .forEach((type) => audio.addEventListener(type, show));
</script>
</body>
</html>
Native controls plus three buttons that call play(), pause() and set currentTime. Edit the code and the example reruns.

The example has no audio file. Its script writes a few seconds of sound samples and a WAV header into memory, wraps them in a Blob, and hands URL.createObjectURL(blob) to the element as its src.

That keeps it self-contained. With a real file, src="song.mp3" is all you need.

The attributes that matter

Attribute What it does
controls Shows the browser's own player. Without it, nothing is drawn
src The address of one audio file
autoplay Asks to start on load. Usually refused when the audio has sound
loop Starts again from the beginning at the end
muted Starts with the sound off
preload A hint: none, metadata or auto

preload is only a hint, and the default differs between browsers. metadata is a good middle ground: the browser fetches enough to know the length, without downloading the whole file up front. none saves data when a page lists many tracks.

With loop set, the audio never reaches its end, so the ended event does not fire. If your code waits for ended, it waits forever.

One file or several: src vs source

src points at a single file. If the browser cannot play that format, you get silence. To offer alternatives, leave out src and put <source> elements inside the tag instead.

The browser takes the first source whose type it can play. The type attribute lets it skip the others without downloading them.
The browser takes the first source whose type it can play. The type attribute lets it skip the others without downloading them.
<audio controls>
  <source src="song.opus" type="audio/ogg; codecs=opus">
  <source src="song.mp3" type="audio/mpeg">
  Your browser cannot play this audio.
</audio>

Order matters: put the format you prefer first and the widely supported one, such as MP3, last.

The text inside only shows in a browser that does not know the <audio> element at all. To check a format from code, audio.canPlayType('audio/mpeg') returns "probably", "maybe" or an empty string.

Why autoplay does not work

Browsers generally block audio with sound until the visitor has interacted with the page, for example with a click or a key press. The autoplay attribute is then ignored, and calling play() from a script is refused.

play() returns a promise. When playback is blocked, the promise is rejected with a NotAllowedError. The example below tries to play on load, then again from a button.

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>Autoplay test</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .line {
    display: flex; gap: 10px; align-items: baseline; padding: 10px 12px; margin-bottom: 8px;
    border-radius: 8px; background: #fff; font-size: 14px; line-height: 1.4;
  }
  .line b { flex: 0 0 auto; min-width: 170px; font-weight: 600; }
  .ok { color: #0f5132; } .no { color: #9a3412; } .wait { color: #6b7280; }
  button {
    font: 600 14px system-ui, sans-serif; padding: 10px 16px; margin-top: 6px;
    border: 0; border-radius: 8px; background: #1d4ed8; color: #fff; cursor: pointer;
  }
  @media (max-width: 420px) { .line { flex-direction: column; gap: 2px; } }
</style>
</head>
<body>
<div class="line"><b>1. Sound, no click</b><span id="r1" class="wait">trying...</span></div>
<div class="line"><b>2. Muted audio, no click</b><span id="r2" class="wait">trying...</span></div>
<div class="line"><b>3. Muted video, no click</b><span id="r3" class="wait">trying...</span></div>
<div class="line"><b>4. Sound, after click</b><span id="r4" class="wait">press the button</span></div>
<button id="btn">Click to play with sound</button>

<script>
  // Same WAV builder as the first example: 16-bit mono PCM in a Blob
  function makeWav(seconds, sampleAt) {
    const rate = 22050, n = Math.floor(seconds * rate);
    const v = new DataView(new ArrayBuffer(44 + n * 2));
    const text = (at, s) => [...s].forEach((c, i) => v.setUint8(at + i, c.charCodeAt(0)));
    text(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); text(8, 'WAVE');
    text(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
    v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
    text(36, 'data'); v.setUint32(40, n * 2, true);
    for (let i = 0; i < n; i++) v.setInt16(44 + i * 2, sampleAt(i / rate) * 32767, true);
    return URL.createObjectURL(new Blob([v.buffer], { type: 'audio/wav' }));
  }
  const src = makeWav(1.5, (t) => 0.3 * Math.exp(-2.5 * t) * Math.sin(2 * Math.PI * 660 * t));

  function attempt(media, el, label) {
    media.play()
      .then(() => { el.textContent = 'playing (' + label + ')'; el.className = 'ok'; })
      .catch((err) => { el.textContent = 'blocked: ' + err.name; el.className = 'no'; });
  }

  // 1-3 run as soon as the page loads, before anyone clicks
  const loud = new Audio(src);
  attempt(loud, document.getElementById('r1'), 'allowed here');

  const quiet = new Audio(src);
  quiet.muted = true;
  attempt(quiet, document.getElementById('r2'), 'muted');

  // The same sound in a <video> element, for comparison
  const video = document.createElement('video');
  video.muted = true;
  video.src = src;
  attempt(video, document.getElementById('r3'), 'muted');

  // 4 runs inside a click handler, which counts as a user gesture
  document.getElementById('btn').addEventListener('click', () => {
    const afterClick = new Audio(src);
    attempt(afterClick, document.getElementById('r4'), 'with sound');
  });
</script>
</body>
</html>
Rows 1-3 run on page load. Row 4 runs from the click. The results come from your own browser.
The same play() call is refused on load and allowed inside a click handler.
The same play() call is refused on load and allowed inside a click handler.

Row 3 is there for contrast. Muted autoplay is an exception browsers make for <video>, and the same sound in a muted video element starts.

Row 2 shows what your browser does with a muted <audio> element. Do not build on it: muted audio is silent either way, so for sound, start playback from a click.

The dependable pattern is to start sound from a click and handle the rejection:

button.addEventListener('click', () => {
  audio.play().catch((err) => {
    status.textContent = 'Could not play: ' + err.name;
  });
});

Video follows the same sound rule, plus the muted exception. HTML video not autoplaying covers that side.

The JavaScript API

The element you get from getElementById is a full player you can drive from code.

Member What it does
play() Starts playback. Returns a promise
pause() Pauses. There is no stop method: pause and set currentTime = 0
currentTime Position in seconds. Set it to jump
duration Length in seconds. NaN until metadata has loaded
volume From 0 to 1
paused, ended true or false

Changes come back as events. Four cover most players:

Read duration after loadedmetadata, update the display on timeupdate, and reset the button on ended.
Read duration after loadedmetadata, update the display on timeupdate, and reset the button on ended.
  • loadedmetadata: the length is known. Set up the seek bar here.
  • timeupdate: fires repeatedly while playing. Move the seek bar and the time label.
  • play and pause: switch the play button icon.
  • ended: playback reached the end.

Styling the native controls

You can size and place the <audio> element like any other box: width, margin, display: block. What you cannot do with standard CSS is restyle the buttons and slider inside it. The browser draws them, and each browser draws them its own way.

For a player that matches your design, leave out controls. The element then stays hidden, and your own buttons call the API. That is all a custom player is.

A finished example: a custom player

This player has a play and pause button, a seek bar, a time label and a volume slider. The <audio> element has no controls attribute, so everything you see is ordinary HTML and CSS.

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 audio player</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #eceef1; color: #1d2330; }
  .player {
    max-width: 440px; padding: 16px 18px; border-radius: 14px;
    background: linear-gradient(135deg, #1e293b, #334155); color: #f8fafc;
    box-shadow: 0 8px 24px rgba(0, 0, 0, .18);
  }
  .title { font-weight: 700; font-size: 15px; } .sub { font-size: 12.5px; color: #cbd5e1; margin-top: 2px; }
  .main { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
  .toggle {
    flex: 0 0 44px; height: 44px; border: 0; border-radius: 50%;
    background: #22c55e; color: #052e16; font-size: 18px; cursor: pointer;
  }
  .toggle:focus-visible, input:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
  .seek { flex: 1; min-width: 0; accent-color: #22c55e; }
  .time { font: 13px ui-monospace, Consolas, monospace; min-width: 84px; text-align: right; }
  .vol { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 13px; color: #cbd5e1; }
  .vol input { width: 130px; accent-color: #e2e8f0; }
</style>
</head>
<body>
<div class="player">
  <div class="title">Generated melody</div>
  <div class="sub">Built in the page, no audio file</div>

  <!-- No controls attribute: the element stays hidden and our UI drives it -->
  <audio id="audio" preload="metadata"></audio>

  <div class="main">
    <button class="toggle" id="toggle" aria-label="Play">&#9654;</button>
    <input class="seek" id="seek" type="range" min="0" max="0" step="0.01" value="0" aria-label="Seek">
    <span class="time" id="time">0:00 / 0:00</span>
  </div>
  <label class="vol">Volume <input id="vol" type="range" min="0" max="1" step="0.05" value="0.8"></label>
</div>

<script>
  // WAV builder from the first example
  function makeWav(seconds, sampleAt) {
    const rate = 22050, n = Math.floor(seconds * rate);
    const v = new DataView(new ArrayBuffer(44 + n * 2));
    const text = (at, s) => [...s].forEach((c, i) => v.setUint8(at + i, c.charCodeAt(0)));
    text(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); text(8, 'WAVE');
    text(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
    v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
    text(36, 'data'); v.setUint32(40, n * 2, true);
    for (let i = 0; i < n; i++) v.setInt16(44 + i * 2, sampleAt(i / rate) * 32767, true);
    return URL.createObjectURL(new Blob([v.buffer], { type: 'audio/wav' }));
  }
  const tune = [392, 440, 494, 587, 523, 494, 440, 392, 440, 494, 440, 392, 330, 392, 440, 392];
  const step = 0.5;  // seconds per note
  const audio = document.getElementById('audio');
  audio.src = makeWav(tune.length * step, (t) => {
    const f = tune[Math.floor(t / step)], local = t % step;
    return 0.28 * Math.exp(-4 * local) * Math.sin(2 * Math.PI * f * t);
  });

  const toggle = document.getElementById('toggle');
  const seek = document.getElementById('seek');
  const time = document.getElementById('time');
  const vol = document.getElementById('vol');
  const fmt = (s) => Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');

  function render() {
    const d = isFinite(audio.duration) ? audio.duration : 0;  // NaN until metadata loads
    seek.max = d;
    seek.value = audio.currentTime;
    time.textContent = fmt(audio.currentTime) + ' / ' + fmt(d);
    toggle.innerHTML = audio.paused ? '&#9654;' : '&#10074;&#10074;';
    toggle.setAttribute('aria-label', audio.paused ? 'Play' : 'Pause');
  }

  toggle.addEventListener('click', () => {
    if (audio.paused) audio.play().catch((err) => { time.textContent = err.name; });
    else audio.pause();
  });
  seek.addEventListener('input', () => { audio.currentTime = seek.value; });
  vol.addEventListener('input', () => { audio.volume = vol.value; });

  audio.volume = vol.value;
  ['loadedmetadata', 'timeupdate', 'play', 'pause', 'ended'].forEach((type) => audio.addEventListener(type, render));
</script>
</body>
</html>
A custom player built on the audio API. The audio element itself stays hidden.
  1. Seek bar: an <input type="range">. Its max comes from duration, and dragging it sets currentTime.
  2. Time label: redrawn on every timeupdate, with seconds formatted as m:ss.
  3. Play button: calls play() or pause() depending on paused, and its icon follows the play and pause events.
  4. Volume: a second range input from 0 to 1, copied into audio.volume.

The render function guards against NaN with isFinite(audio.duration), so the label reads 0:00 instead of NaN:NaN while the file loads.

When it does not work

What you see Cause Fix
Nothing on the page No controls attribute Add controls, or build your own buttons
Silent on load, console shows NotAllowedError Autoplay with sound was blocked Call play() from a click and .catch() the promise
The player shows but will not play The browser cannot decode that format, or the server sends the wrong type Offer MP3 as a <source>, check canPlayType and the Content-Type header
The player never starts, 404 in the Network tab The src path is wrong Check the path relative to the page, and the file name's case
Duration shows NaN Read before metadata loaded Read it in a loadedmetadata listener
ended never fires loop is set Remove loop, or do not rely on ended

When no <source> works, the error event fires on the <source> elements, not on the audio element. Listen there, or open the browser's Network tab to see what was requested. For missing video files the same checks apply, as HTML video file not found shows.

A sound page is hard to describe in 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 press play and drag the seek bar themselves. If you change the code later, the same link shows the new version.

Questions people ask

Why does my audio tag show nothing on the page?

The controls attribute is missing. Browsers do not render an audio element without controls, so the sound can still play from JavaScript but there is nothing to see or click. Add controls, or build your own buttons.

Can I autoplay audio with sound on page load?

Not reliably. Browsers generally block playback with sound until the visitor has interacted with the page, and play() is rejected with NotAllowedError. Start the sound from a click handler instead, and catch the rejection so the page can show a play button.

Does muted autoplay work for audio?

Browsers describe the muted-autoplay exception mainly for video, and a muted audio element makes no sound anyway, so it rarely helps. The autoplay example on this page shows what your own browser does. For audio, start playback from a click.

Which audio format should I use?

MP3 is widely supported and a safe single choice. If you also offer a smaller format such as Opus, list it first in a source element with a type attribute and keep MP3 as the second source.

Can I style the native audio controls with CSS?

Only from the outside. You can set width, margin and similar box properties, but the buttons and slider inside are drawn by the browser and look different in each one. For a consistent look, hide the controls and build a custom player on the JavaScript API.

Keep reading