Menus, toasts and more with the Popover API, the dialog element, invokers, anchor positioning and @starting-style

Dropdowns, menus, tooltips, comboboxes, toasts — the popover attribute will make building a large variety of UI components easier. The popover attribute can be used on any HTML element, so you have the flexibility to choose whichever element is most appropriate semantically for each particular use case. Unlike a dialog, a popover is always non-modal — meaning they don’t block interaction with anything else on the page. To toggle a popover open and closed, a button element needs to include an invoketarget attribute with a value that matches the id of the popover.

<button invoketarget="foobar">Toggle popover</button>

<div id="foobar" popover>
  Popover content goes here...
</div>Code language: HTML, XML (xml)

A <button> with an invoketarget attribute is called an invoker. Invokers might eventually bring all sorts of power to HTML markup, but in its first iteration it’s limited to opening and closing popovers and dialogs. You don’t need onclick= or addEventListener, it’ll just work.

The fact that popovers work without JavaScript is nice, but toggling display: none on an element using JS was never challenging. Popovers do, however, bring far more to the table:

  • Popovers make use of the top layer.
  • Light-dismiss functionality: clicking outside of the popover will close the popover.
  • Hitting the escape key will close the popover.
  • Focus management: when you open a popover, the next tab stop will be the first focusable element inside the popover. If you’ve focused an element within the popover and then close the popover, focus is returned to the correct place (this was tricky to get right with JavaScript).

Browser support

The popover attribute is supported in Chrome, Safari, and Firefox 125. The popovertarget attribute currently has better browser support than invoketarget. popovertarget is popover-specific, offering a declarative way to toggle popovers open and closed. popovertarget will likely eventually be deprecated and replaced by the more flexible invoketarget. After popovers shipped in Chrome, some smart people realised it would also be handy to have a declarative way for buttons to open dialogs and perform other tasks, which is why there are two ways to do the same thing. A polyfill for invokers is available.

Light dismiss

The popover attribute can be set to either auto (the default) or manual. When set to auto, the popover has light dismiss functionality: if the user clicks outside of the popover, the popover is closed. Pressing the escape key will also close the popover. Only one auto popover is ever open at a time.

When set to manual, there is no light dismiss functionality and the escape key does not close the popover. The popover must be explicitly closed by pressing the button again (or by calling hidePopover() in JavaScript). Multiple manual popovers can be open at the same time.

<button invoketarget="foobar">Toggle popover</button>

<div id="foobar" popover="manual">
  Popover content goes here...
</div>Code language: HTML, XML (xml)

Invoker actions

Along with the invoketarget attribute, a button can also optionally include an invokeaction attribute. The different actions are listed below.

ActionDescription
showpopoverShow a popover.
hidepopoverClose a popover.
showmodalOpen a dialog element as modal.
closeClose a dialog element.

If you omit the invokeaction attribute, the default behaviour depends on the context: If the target set by invoketarget is a popover it will call .togglePopover(). If the target is a dialog it will call showModal() if the dialog is closed and will close the dialog if the dialog is open.

Using invokers for the dialog element looks much the same as the popover example:

<button invoketarget="my-dialog">Open Dialog</button>

<dialog id="my-dialog">
  Dialog content goes here.
  <button invoketarget="my-dialog" invokeaction="close">Close dialog</button>
</dialog>Code language: HTML, XML (xml)

Along with built-in actions, developers can write custom actions. This is outside the scope of this article as a custom action could do anything — it need not be related to dialogs or popovers.

While a selling point of invokers is forgoing JavaScript, they also provide a new JavaScript invoke event should you need more than the default behaviour. This event is fired on the popover or dialog, not the button.

document.querySelector("[popover]").addEventListener("invoke", function(event) {
    console.log(event.action);
    console.log(event.invoker);
    // do something useful here...
  });Code language: JavaScript (javascript)

Within the event handler you can get a reference to whichever button triggered the invocation with event.invoker and determine the action specified by invokeaction with event.action.

Popover methods and events

For many use cases, the popover API doesn’t require JavaScript. What if we want to display a toast notification without a user first interacting with a button, for example?

There are methods to show, hide, or toggle a popover element: .showPopover(), .hidePopover() and .togglePopover(), respectively.

document.getElementById('toast').showPopover();Code language: JavaScript (javascript)

There is a toggle event that fires on the popover both when the popover gets shown and when it gets hidden (there are no separate open or close events). This would be useful for a toast alert that automatically disappears after a set amount of time, for example, as there’s no markup or CSS-based way to do that.

Its worth checking that the popover isn’t already hidden before calling hidePopover(). We can do that with either .matches(':popover-open'), .checkVisibility(), or event.newState === 'open', all of which will return true if the popover is open.

toast.addEventListener("toggle", function (event) {
  if (event.target.matches(":popover-open")) {
    setTimeout(function () {
      toast.hidePopover();
    }, 3000);
  }
});Code language: JavaScript (javascript)

There’s also a beforetoggle method, which is similar but lets you call event.preventDefault() inside the event handler, should you need to — and it might come in useful for animations. The toggle event, by contrast, isn’t cancellable.

Default popover styles

By default a popover is set to position: fixed and displayed in the center of the viewport with a solid black border but you’re free to style it however you like. The styles the browser applies to a popover look something like this:

[popover] {
    position: fixed;
    width: fit-content;
    height: fit-content;
    inset: 0px;
    margin: auto;
    border: solid;
    padding: 0.25em;
}
Code language: CSS (css)

If I wanted to position a popover in the bottom left, for example, I’d need to set top and right to either auto, initial or unset.

.toast {
    inset: unset;
    bottom: 12px;
    left: 12px;
}
Code language: CSS (css)

Beyond z-index: The top layer

Some JavaScript frameworks have something called portals for rendering things like tooltips and dialogs. I always found portals difficult to work with. The React docs describe portals like so:

“Portals let your components render some of their children into a different place in the DOM. This lets a part of your component “escape” from whatever containers it may be in. For example, a component can display a modal dialog or a tooltip that appears above and outside of the rest of the page… You can use a portal to create a modal dialog that floats above the rest of the page, even if the component that summons the dialog is inside a container with overflow: hidden.”

When working with either the <dialog> element (rather than crafting one out of divs) or the popover attribute, you can avoid this issue entirely — no portals required. Their location in the DOM doesn’t matter. Its often convenient to collocate the markup for a popover or <dialog> together with the button that opens it. They can appear anywhere in your markup and won’t get cropped by overflow: hidden on a parent element. They make use of the top layer, which is a native web solution for rendering content above the rest of the document. The top layer sits above the document and always trumps z-index. An element in the top layer can also make use of a styleable ::backdrop pseudo-element.

Animate an element into and out of the top layer

By default, when a popover or dialog is opened, it instantly appears. You might want to add an entry animation — perhaps a quick opacity fade-in, for example. @starting-style is used to animate an element into view with a CSS transition (you don’t need @starting-style when working with @keyframes). @starting-style works both when you’re adding a new element to the DOM and when an element is already in the DOM but is being made visible by changing its display value from display: none. When in a closed state, both the popover attribute and the <dialog> element make use of display: none under the hood, so @starting-style can be used to animate them onto the page.

The following transition will fade and spin the popover into view, and scale down the size of the popover for the exit transition.

/*  Transition to these styles on entry, and from these styles on exit   */
[popover]:popover-open {
  opacity: 1;
  rotate: 0turn;
  transition: rotate .5s, opacity .5s, display .5s allow-discrete, overlay .5s allow-discrete;
}

/*   Entry transition starts with these styles  */
@starting-style {
  [popover]:popover-open {
    opacity: 0;
    rotate: 1turn;
  }
}

/*  Exit transition ends with these styles  */
[popover]:not(:popover-open) {
  scale: 0;
  transition: scale .3s, display .3s allow-discrete, overlay .3s allow-discrete;
}
Code language: CSS (css)

The popover will transition from its @starting-style styles to its [popover]:popover-open styles every time it’s opened.

The overlay transition is necessary boilerplate when transitioning an element in or out of the top layer. The overlay property was added to CSS purely for this use case and has no other practical application. It is an unusual property to the extent that, outside of transitions, it can only be specified by the browser — you can’t set it with your own CSS. By default, a dialog or popover is instantly removed from the top layer when closed. This will lead to the element getting clipped and obscured. By transitioning overlay, the element stays in the top layer until the transition has finished.

transition-behavior is a new CSS property that can be set to either normal or allow-discrete. In the above code example I’m using the shorthand.

Similarly for the display property, by including it in the transition and specifying transition-behavior: allow-discrete we ensure that a change from display: none happens at the very start of the entrance transition and that a change to display: none happens at the very end of the exit transition.

@starting-style has some useful applications outside of working with popovers and dialogs, but that’s a topic for a different article.

You can transition the ::backdrop pseudo-element in a similar way.

e.g.

@starting-style {
  [popover]:popover-open::backdrop {
    opacity: 0;
  }
}Code language: CSS (css)

Now let’s look at doing the same transition with a <dialog> element:

/*  Transition to these styles on entry, and from these styles on exit   */
dialog:open {
  opacity: 1;
  rotate: 0turn;
  transition: rotate .5s, opacity .5s, display .5s allow-discrete, overlay .5s allow-discrete;
}

/*   Entry transition starts with these styles  */
@starting-style {
  dialog:open {
    opacity: 0;
    rotate: 1turn;
  }
}

/*  Exit transition ends with these styles.  */
dialog:closed {
  scale: 0;
  transition: scale .3s, display .3s allow-discrete, overlay .3s allow-discrete;
}Code language: CSS (css)

The :open and :closed selectors are new pseudo-selectors. They work for details, dialog, and select elements — but not for popovers. You can use dialog[open] and dialog:not([open]) for the time being for better browser support.

These examples all work in Chrome. @starting-style and transition-behavior are part of Interop 2024, meaning they’ll likely be fully supported by the end of the year. Safari 17.4 added support for transition-behavior: allow-discrete. Safari Technology Preview 189 added support for @starting-style. WebKit have yet to declare a position on the overlay property.

Anchor positioning

With a component like a toast or a dialog, we generally want to position the element in relation to the viewport. We typically display a dialog in the center of the screen, and a toast at the bottom. That’s easy to do. There are other times when you need to position an element in relation to another element on the page. For a dropdown menu, for example, we want to place the popover in relation to the button that opened it. This is more challenging.

Screenshot of the ... three dot menu on YouTube opened up showing a menu of three options: Clip, Save, and Report.

This sort of behaviour usually requires JavaScript and led to the creation of the popular JavaScript libraries Popper, Floating UI and Tether. With the addition of anchor positioning to CSS, we’ll no longer need to reach for JavaScript. The anchor() function allows developers to tether an absolutely positioned element to one or more other elements on the page. Unfortunately, it’s a work-in-progress so I’ll revisit the topic when the spec and implementation are more solid.

Conclusion

I covered a lot in this article but there’s more to come. The popover attribute can be useful all by itself but some forthcoming web APIs will help cover more use cases. Anchor positioning looks set to be the most useful CSS feature since grid. Stay tuned.

Leave a Reply

Your email address will not be published. Required fields are marked *