David Sojevic

Playing Sound With CSS

A short experiment of how to play sound (effects and/or music) without using JS.

Start At The End

This is the end result at the start, so you can see how it looks. Please be aware that the user must interact with the page in some manner to trigger it, but these experiments make it feel less like interacting with an audio player so they can be used in CSS-only games and such to enhance them.

Playing Sound (and Saving State)

Click the "Sound Effect" icon to play a sound effect on loop. Click it again to stop it.

Double-click the "Sound Effect" icon to store some state and play the sound effect. Single-click just to store the state.

State:

Reset the state for the store state and play sound demo:

Backstory

Many moons ago I wanted to see if I could make a game out of pure HTML/CSS only and ended up building a simple "memory game" -- you know the type -- flip a card, flip another card, if they match they both remain face up, if they don't, they both go back to being face down. At the time I thought it could be cool to add sound to the game to make it feel that little bit nicer, but after burning through a few hours, there was no decent solution that I could find and nothing I could come up with that might allow me to play sounds as part of actions within the game.

Over the years there have been various other articles, blog posts, and code snippets that have achieved this by doing things with autoplay or embeds but they were either from times where browsers didn't restrict the usage so heavily or they'd work, but only allow a sound to be played once.

Fast forward to early April, 2026 and for whatever reason the idea of making a sound play as part of a HTML/CSS only game came to mind again and I've once again searched, found no decent solutions, but with a bit of brainstorming, I came up with a relatively simple mechanism to play optional sounds as part of the game. But wait, isn't it now mid July, 2026? Astute observation! I took some notes at the time with the expectation that I'd write a really short post the following weekend. Turns out it wasn't the next weekend, it was just a subsubsent weekend. Moving on now...

What's The Problem?

When you remove the ability to use JS to trigger a sound, your only other options are autoplay (which, when relating to audio, generally isn't an option until your site is trusted or has "sticky activation") or to get the user to explicitly click the play button on the audio player.

This is great default behaviour because it stops pages from assaulting your (and others') ears without you doing something to trigger it. Prior to these restrictions, there used to be many examples of sites autoplaying videos with sound or music (I'm looking at you, MySpace profiles) when you'd least expect it or when you'd forgetten to turn down your sound from something else and you'd either get a bit of a jump-scare or embarassment from a whole lot of attention you'd just drawn to yourself.

Let's Start Playing (Sound)

So, without any JS, how do we get this sound effect to play then?

Vanilla Audio Element
HTML
<audio controls>
  <source src="/projects/playing-sound-with-css/sound-effect.mp3"
          type="audio/mp3">
</audio>

Well, we know we have to get the user (that's you) to click the play button. So let's start by reducing the surface area of this audio component so it doesn't feel like you're just clicking play on a dedicated audio player.

We can take away a few controls (mainly in Chromium-based browsers, the above and below examples might look identical to you on other browsers) on the audio element itself using the controlslist attribute, but not enough to make any real impact. Noting that if we take away the controls attribute, we can't actually click anything to make the sound play, so it has to stay.

Reduced Controls
HTML
<audio controls controlslist="nodownload noplaybackrate novolume">
  <source src="/projects/playing-sound-with-css/sound-effect.mp3"
          type="audio/mp3">
</audio>

Let's sprinkle in some CSS to try and cut it down to "just" a play button if we can.

We're pretty much going to wrap it in a container with overflow: hidden and then size and position the audio element within to centre on the play button.

Only The Play Button
HTML
<div class="audio-container">
  <audio controls controlslist="nodownload noplaybackrate" tabindex="-1">
    <source src="/projects/playing-sound-with-css/sound-effect.mp3"
            type="audio/mp3">
  </audio>
</div>
CSS
.audio-container {
  width: 32px;
  height: 32px;
  border-radius: 16px;
  overflow: hidden;
  position: relative;
  display: flex;
}
.audio-container audio {
  position: absolute;
  top: -10px;
  left: -10px;
  width: 300px;
  height: 50px;
}

If you're on a Chromium-based browser, you'll notice that it looks just like a nice and simple play button and it does the job and if you're on something like Firefox, you might notice that it's still a standalone play button, but that the play arrow isn't perfectly centred.

However, the important part is that, in both cases, clicking anywhere in the area will trigger the play/pause action depending on where in the play cycle the audio is at.

The next step to using it in a CSS-only game or toy is to crank the opacity down to 0 on the contained audio element so that it's still the subject of the interaction events but it's just no longer the focus of your trigger. You can use something fun and thematically relevant to whatever it is you're making instead, whether it be via background-image or layering other elements within it and ensuring that they're either using pointer-events: none or just have a lower z-index than the audio element, it's up to you.

What's All This About Saving State?

Going back to the backstory, this whole experiment started because I wanted to add some sound to a CSS-only game I was tinkering with -- the trouble being that you need an interaction for changing the state (e.g. checking a checkbox, selecting a radio input, etc) and another interaction for triggering the audio to play. So until we get some wider support for some other audio related selectors, this was the best thing I could come up with that actually felt quite nice to use.

So my thought was to use the invisible audio element as a layer in the triggering element for state-change and have it rapidly fade out so that if you double click, you'll get your state change plus your audio playing, but if you single click you still get the state change but without the audio.

The core flow of behaviour for a component you'd like to save state and trigger are sound is as follows:

  1. First click occurs on the label (or similar) to change the state (e.g. check the checkbox)
  2. The change in state (e.g. :has(:checked)) triggers an animation to play that rapidly reduces opacity to 0 and then sets display: none on the final frame
  3. In addition to the animation trigger, the label (or similar) should then be set to pointer-events: none (or z-index shifted) to allow the audio element to now be the subject of the next click
  4. If a second click occurs before the animation concludes, it now lands on the audio element's play button, triggering the sound effect to play

A simple example of the CSS for this could be as follows (the CSS to setup the audio element as play button only is left out of below for the sake of brevity):

/* ... */
.audio-container {
  animation: none;
}
.audio-container:has(:checked) {
  animation: vanish-audio 0.3s forwards;
}
.audio-container:has(:checked) label {
  pointer-events: none;
  opacity: 0.5;
  transition: opacity 0.3s;
}
@keyframes vanish-audio {
  0% { opacity: 1; }
  99.9% { opacity: 0; }
  100% {
    opacity: 0;
    display: none;
  }
}
/* ... */

What does this look like in action? Take a look at the demo below to see how it would be if it were un-styled (or rather under-styled) and slowed down a bit so you can see the effect a bit better:

Double-click to play


You can then style this to suit your needs and adjust how you trigger the state and all that fun stuff until you have something that plays sound without any JS but doesn't look like a standard audio control!

Here's a truncated version of the demo from the start of the article:

Playing Sound (and Saving State)

Simple play/pause:

Save state, double-click to play sound.

State:

HTML
<form>
<p>
  Simple play/pause:
<p>
<div class="audio-container">
  <audio controls controlslist="nodownload noplaybackrate" loop>
    <source src="/projects/playing-sound-with-css/sound-effect.mp3"
            type="audio/mp3">
  </audio>
</div>
<p>
  Save state, double-click to play sound.
<p>
<div style="height: 32px">
  <div class="audio-container">
    <label><input type="checkbox" id="result-checkbox"></label>
    <audio controls controlslist="nodownload noplaybackrate">
      <source src="/projects/playing-sound-with-css/sound-effect.mp3"
              type="audio/mp3">
    </audio>
  </div>
</div>
<p>
  <b>State:</b> <span data-store-and-save-state></span>
</p>
<p>
  <button type="reset">Reset</button>
</p>
</form>
CSS
.audio-container {
  width: 32px;
  height: 32px;
  overflow: hidden;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  animation: none;
}
.audio-container:has(:checked) {
  animation: vanish-audio 0.3s forwards;
}
.audio-container:has(:checked) label {
  pointer-events: none;
  opacity: 0.5;
  transition: opacity 0.3s;
}
.audio-container label,
.audio-container::before {
  width: 32px;
  height: 32px;
  background: url("/projects/playing-sound-with-css/sound-effect.gif") no-repeat center center;
  background-size: contain;
  position: absolute;
  top: 0;
  left: 0;
  cursor: pointer;
  z-index: 2;
}
.audio-container:not(:has(label))::before {
  content: "";
  pointer-events: none;
}
.audio-container input {
  display: none;
}
.audio-container audio {
  position: absolute;
  top: -10px;
  left: -10px;
  width: 300px;
  height: 50px;
  opacity: 0;
  cursor: pointer;
  z-index: 1;
}
body [data-store-and-save-state]:before {
  content: 'Button not clicked';
}
body:has(#result-checkbox:checked) [data-store-and-save-state]:before {
  content: 'Button clicked';
}

@keyframes vanish-audio {
  0% {
    opacity: 1;
  }
  99.9% {
    opacity: 0;
  }
  100% {
    opacity: 0;
    display: none;
  }
}

Future?

I haven't tried it out yet, but if the :playing pseudo-class selector gets wider adoption, I think we might be able to lean on using an animation and setting a custom property/variable to drive game state while also triggering a sound.

For example:

  1. The audio element is hidden in the same way as in the method above
  2. When it is triggered to play by the click, then a selector targeting :playing on the audio element causes an animation to fire
  3. That animation sets the variable to the new state that we're after

If someone gives it a shot, get in touch and let me know how it goes!