← All writing
Craft · · 10 min

The popover attribute

On the six behaviors that are now two attributes, the top layer ending an entire category of bug, and the half of this that hasn't turned up yet.

HTML Accessibility

“It sends me back to the beginning.”

That’s a line from a support email a client forwarded me with a question mark after it and nothing else. The person who wrote it works in the office at the back of a warehouse, processing orders on an internal dashboard I built for them. They do not use a mouse.

So: press the Filter button in the toolbar, pick a status, close the panel, press Tab, and land on the skip link at the top of the document. Every filter cost them the whole header again. They filter constantly, because that is the job.

When my panel closes it gets display: none, and the browser then does the only thing it can do with a focused element that has stopped existing, which is drop focus onto the body and start counting from the top. That’s the piece of focus management I wrote a whole post about in 2020, on a component I have now built in some form on nearly every project for most of my career.

A button, and a panel that appears when you press it. It closes when you click outside it. It closes when you press Escape. It takes focus when it opens. It hands focus back to the button when it shuts. It sits above everything else on the page. And the button says aria-expanded, so somebody who isn’t looking at the screen knows what just happened.

That’s a menu. It’s also a tooltip, a filter panel, a share sheet, a notification, and every custom select I have ever been asked for. 6 behaviors, about 180 lines once the edge cases are in. I have gotten at least 2 of them wrong every single time.

Nothing up my sleeve

Two attributes, and I’d count the lines.

filters.html
<button popovertarget="filters">Filter</button>

<div id="filters" popover></div>
HTML

All six. No JavaScript.

With one asterisk, which I’ll pay now instead of letting somebody find it later. Focus doesn’t move into the panel by itself. You put autofocus on whatever inside it should have focus, and then it does. Without asking for it you get the half I’d broken, which is handing focus back to the button when the panel closes.

The aria-expanded comes along with popovertarget too, without you writing it. I’d still go and check that with a real screenreader instead of taking my word for it, because implicit ARIA has a long history of being a promise the mapping hasn’t caught up to yet.

The panel I deleted was 180 lines and it came out on a maintenance retainer, which meant having the conversation about billing for a subtraction again. It went better than usual. “The filters work for the person who emailed you” is an easier sentence to say out loud than “the codebase is smaller now.”

Upstairs

The one I’d single out, because it ends a category of bug instead of saving typing.

A popover renders in the top layer, which is a separate rendering surface sitting above the whole document. Not a very large z-index. Above it, outside the stacking order entirely.

Which means the thing I have debugged more times than almost anything else in this job cannot happen to it. The dropdown that renders behind the header. The tooltip clipped by an ancestor with overflow: hidden six levels up. The panel that works everywhere except inside the carousel. The arms race where every component’s number is one higher than the last one somebody noticed, until one of them says 9999 and the next person to arrive types 10000.

All of that is a consequence of positioned elements living in the document’s stacking order, and a thing in the top layer isn’t in it. I have not had to think about layering on one of these since, which after nine years of thinking about it more or less constantly took a while to trust.

Who gets to close it?

Two behaviors, and the default is the one you want.

popover on its own, which is the same as popover="auto", gets you light dismiss. Clicking outside closes it, Escape closes it, and opening another auto popover closes this one. That last part is a stack instead of a flat rule, so a popover nested inside another popover stays open along with its parent, which is what menus need and is exactly the kind of thing I would have gotten wrong on my own.

popover="manual" opts out of all of it. Nothing closes it but you, which is what a toast wants, or a notification, or a panel that’s meant to stay put while somebody works next to it.

The button can be more specific than a toggle, too.

actions.html
<button popovertarget="panel" popovertargetaction="show">Open</button>
<button popovertarget="panel" popovertargetaction="hide">Close</button>
HTML

And there’s a script API for when you genuinely need one. showPopover(), hidePopover() and togglePopover(), a beforetoggle and a toggle event, and a :popover-open selector so the open state gets styled in CSS instead of by a class somebody has to remember to take off again.

Can they keep going without it?

The mistake I expect to see most. I nearly made it in the first two weeks.

A popover is non-modal. The rest of the page stays interactive, focus is allowed to leave it, nothing is blocked. That’s correct for a menu or a tooltip, where the whole point is that the page underneath is still live and the panel is a thing you consult while you use it.

A modal is a different animal. The rest of the page should go inert, focus should be trapped, and Escape means cancel instead of dismiss. There’s a separate element for that, <dialog> opened with showModal(). It has its own set of things people get wrong, so I’ll write that one up properly in a few months.

The test I’ve settled on is whether the person can sensibly keep working with the page behind it. If they can, popover. If they have to deal with this before anything else happens, dialog.

Three ways to make it look broken

All three of these fail by looking like the attribute isn’t supported, which is the worst possible way for a thing to fail.

Don’t set display on it. The browser’s own styles hide a closed popover with display: none and drop that when it opens, so a display: flex on the element itself overrules the closed state and you get a panel that is permanently visible with no way to shut it. Layout goes on a wrapper inside.

filters.css
/* overrules the closed state, so it never hides */
#filters {
  display: flex;
}

/* put the layout on a wrapper instead */
#filters .filters__inner {
  display: flex;
  gap: 1rem;
}
CSS

Animating it is fiddly. Moving something in and out of display: none has always been the awkward corner of CSS, and the two pieces that make it work properly, a rule for the starting state and permission to transition a discrete property, are both newer than the popover itself and both in one engine right now. A fade in is fine. A fade out is more work than a fade has any business being, and on client work I currently don’t animate the close at all.

One auto popover at a time. That’s usually what you want and occasionally surprising, if you’d assumed two unrelated panels could sit open together. Use manual for those.

Where does it go?

Nowhere useful, which is the one real gap and the reason I’d hold off planning a quarter around this.

The attribute puts the panel in the top layer. It does not put the panel under the button. Left alone, the browser’s own styles park it dead center in the viewport, margin: auto inside inset: 0, which is a sensible default for a command palette and is nobody’s idea of a dropdown.

So a panel that should hang directly beneath its trigger, and flip above when there’s no room below, still needs either CSS you worked out by hand for that one component or the same positioning library that was already in the bundle. Which was the part I was most hoping to delete.

There is a companion feature for exactly this, letting an element anchor itself to another one and repick its position when it would overflow. It launches in one engine soon and is nowhere else. So it’s a thing to read about and not a thing to use. When both halves are everywhere this entire category of component becomes markup and a handful of declarations. Until then you have deleted the state management and kept the math.

Nobody files that bug

I keep writing a version of this paragraph and here it is again. The platform absorbed a thing that used to be a reason to install something.

This time it isn’t only saving me code. Every one of those six behaviors is a place where the hand-rolled version gets accessibility wrong, and every one of them gets it wrong quietly. There’s no console warning for focus landing on the body. Nothing looks off in a screenshot. It’s the same shape as the white flash I wrote about in February, which I couldn’t reproduce until I stopped looking at the site in daylight. It would have taken about a minute of the 15 minutes I keep telling people to spend to find, and nobody spent them, me very much included.

And the person it happens to doesn’t file a bug. They close the tab, or they finish the task in a way that takes three times as long and never mention it, or in this case they put up with it for the better part of a year and then say something to their manager in a sentence I had to read twice before I understood what it was describing 😬

Focus not going back to the trigger is on approximately every custom dropdown I have ever tested, several of them mine. I only found this one because somebody eventually put it in writing.

So this isn’t a convenience feature with an accessibility benefit attached to it. It’s an accessibility fix that also deletes 180 lines. I’d rather it were described in that order. The positioning didn’t arrive with it. That’s worth sitting with for a second, because if somebody had offered me one half of this I would have picked the wrong one without hesitating. The half that turned up first is the half that was costing somebody else their whole morning, every morning. The half I actually wanted is the one that was only ever costing me an afternoon.

Read similar posts
9 min

h4 was the right size

I pulled up the list of headings on a client's page and got the company name and three things at level four, with the actual subject of the page missing from the list entirely because somebody had built it out of a div.

7 min

Printing an accordion

A customer saved a client's returns page as a PDF and got back a column of questions with white space under every one of them, which is what an accordion does when it meets a printer.