The user opened /inspection at 9:14 in the morning and heard a voice. The voice said, Welcome to the walkthrough. The user had not clicked anything. The page had finished loading two seconds earlier. There was a heading, a paragraph of intake copy, and below that a closed gray triangle next to the words Watch the walkthrough. The triangle pointed right. The section was collapsed. The narration kept going.
The user closed the tab. The user filed a ticket. The ticket said: audio plays on page load from a section I did not open.
What the markup said
The template was straightforward. A details element wrapping a summary and an iframe. The iframe pointed at a hosted walkthrough on a third-party domain — an animated explainer with a baked-in voiceover and autoplay in the embed URL. The author of the template had reasoned, plausibly, that details without the open attribute meant the contents were not shown. Not shown meant not active. Not active meant not playing.
The browser disagreed. The browser had always disagreed. The HTML spec is precise on this point and the precision had been ignored because it had never had to be confronted. details is a visibility toggle. The contents are part of the document. They are parsed. The iframe is mounted. The iframe issues its request. The third-party page loads. The third-party page reads autoplay=1 from its own query string. The audio element on that page begins to play. The audio reaches the user's speakers through a closed gray triangle.
What the developer meant
The developer meant inert. The developer meant do not run until I say. There is no attribute in HTML that means this for an iframe inside a details. There is loading="lazy", which defers the fetch until the iframe is near the viewport, and the iframe inside a closed details is in the viewport — it has a position, it has a size of zero or near zero, and the lazy heuristic does not save you here in any browser tested. There is hidden, which also does not unmount. There is CSS display: none on a parent, which in some browsers will prevent the iframe from loading and in others will not, and which is not what details applies anyway.
The only thing that means do not run until I say is: do not put the iframe in the DOM. Add it on the toggle event when open becomes true. Remove it when open becomes false, if you care about that direction. The mount is the switch. Nothing else is the switch.
The same shape, elsewhere
A tab UI with all four panels pre-rendered, three of them hidden by CSS. The video in tab two is buffering. The analytics ping in tab three has already fired. The form in tab four has registered its beforeunload handler and will now ask the user, on every navigation, whether they really want to leave a form they never saw.
An accordion of FAQ entries, each one containing a YouTube embed. The page has opened twelve connections to youtube.com before the user has scrolled. The Network tab shows them. The user does not.
A modal that pre-renders its contents for a faster open animation. The modal contains a map. The map's tile server is being hit on every page load by every user who will never click the button that reveals the map.
In each case the author has used a visibility primitive — hidden, display: none, details without open, an aria-hidden panel — and has reasoned about it as if it were a mount primitive. The browser does not share the reasoning. The browser mounts what is in the document. The document is the contract. Whatever is in it, runs.
The fix on the page
The template now renders a placeholder inside the closed details — a still image and a caption. A short script listens for the toggle event on the element, checks this.open, and on the first true transition replaces the placeholder with the iframe. The iframe mounts at the moment of intent. The audio plays at the moment of intent. The closed section is silent because the closed section is, finally, empty.
The user who filed the ticket has not come back. The ones who did not file the ticket are the ones the change was for.
References: the HTML Living Standard on the details element and the iframe element, which together describe the behavior this post ran into.
Joan Didion wrote that we tell ourselves stories in order to live. The story the developer told himself was that a closed section was a quiet section. The browser, which tells itself no stories, played the audio anyway.