Skip to content
Interaction

Dropdowns without JavaScript using popover and anchor positioning

How the popover attribute takes over the layer, the outside click and the Escape key, how anchor positioning ties the panel to its button, why the transition needs two extra lines and what still stays your job.

11 min readUpdated: 21 September 2026
PopoverCSSInteractionAccessibility
Button and panel01
Series 04
Light that stays all day
Six lamps from one workshop in Utrecht.
See the rangeShowroom

A dropdown teaches you how much work hides inside something small. A panel that opens, sticks to its button, disappears on a click outside, closes on Escape and does not get clipped by the nearest overflow: hidden: for years that meant a few hundred lines or a library. Both halves of the problem now sit in the platform, the behaviour since 2024 in the popover attribute, the placement since early 2026 in anchor positioning. This guide shows what actually falls away, what does not, and how the whole thing looks in a Tailwind project.

The short version
  • popover takes over the behaviour: top layer, click outside closes, Escape closes and hands focus back, and only one is ever open. No state in your code, no listener on the document.
  • Anchor positioning takes over the placement: anchor-name on the button, position-anchor on the panel, position-area puts it in one of nine cells around the button. At the edge of the screen, position-try-fallbacks flips it to the other side.
  • The transition needs two extra pieces, because display and overlay have no in-between values: allow-discrete in the transition and a @starting-style for the starting value. Without both, the panel appears hard.
  • A popover is never modal. For a flow that holds the page still, dialog with showModal remains the answer, and a menu with arrow keys is still yours to build.
01

Why does a dropdown cost so much code?

The requirements for a dropdown are short, and they are the same in every project. The panel should sit above everything, stick to its button, disappear on a click outside, close on Escape and hand focus back where it came from. Five sentences that regularly turn into several hundred lines.

The reason is that each of those sentences works against a different property of the document. Sitting above everything means beating every z-index handed out higher up the tree. Sticking to the button means recomputing the position on every scroll and every resize. Click outside means putting a listener on the document and reliably taking it off again. And an overflow: hidden on some ancestor clips the finished panel anyway.

So most people reach for a library, and for a long time that was the right call. The ground has shifted, though. The two halves of the problem now sit separately in the platform: the behaviour in the popover attribute, the placement in anchor positioning. You can use one without the other, and in practice that separation is what lets the move happen in two small steps instead of one large one.

A dropdown is not a layout problem. It is a layering problem with a layout part.

02

What does the popover attribute bring?

An element carrying the popover attribute starts out invisible, the browser stylesheet sets display: none. A button with popovertarget and the matching id opens it. There is no further wiring, and no state you have to keep anywhere.

On opening, the element moves into the top layer. That is not a particularly high z-index step, it is a surface of its own above the entire document. Nothing there gets clipped by an overflow: hidden, and no stacking context higher up can beat it. It is exactly the property you otherwise rebuild with a portal onto the body, minus the move through the tree.

Then there is the behaviour. A click outside closes it, Escape closes it and hands focus back to the button, the Tab key leads into the panel next, and a second popover closes the first. Those rules apply to popover="auto", the default. popover="manual" drops them and waits for your command, popover="hint" is meant for short hints that should not close an open menu next to them.

<button popovertarget="konto">Konto</button>   <div id="konto" popover>    <a href="/konto">Profil</a>    <a href="/abmelden">Abmelden</a>  </div> <!-- und von innen wieder zu -->  <button popovertarget="konto" popovertargetaction="hide">    Schliessen  </button>
Button, panel, done. No state, no listener.
The default sits in the middle of the screenA fresh popover shows up centred, with a border and a little padding. That comes from the browser stylesheet, which sets position: fixed, inset: 0 and margin: auto. So if you want to tie the panel to its button later, reset inset: auto and margin: 0 first. Without those two lines you will spend a while wondering why your placement rules do nothing.
03

How does the panel get to its button?

Anchor positioning connects two elements. The button gets a name through anchor-name, the panel points at it with position-anchor. Both names start with two hyphens, like a custom property. They do not have to share a parent, and that is the whole point: the anchor sits in the document, the panel sits in the top layer, and the connection holds regardless.

After that you describe the placement in cells rather than pixels. position-area lays a three by three grid around the anchor, with the anchor itself as the centre. bottom center puts the panel centred underneath, bottom span-inline-start hangs it under the start edge and lets it run inward. The logical keywords turn around on their own in languages that read the other way, the physical ones stay put.

The real gain is in the fallback. position-try-fallbacks takes a list the browser walks through in order as soon as the first choice would spill past the visible edge. flip-block mirrors upward, flip-inline to the other side, and a @position-try block of your own describes any further spot with its own values. This is exactly the computation a library otherwise runs on the main thread on every scroll.

.ausloeser {  anchor-name: --konto;} #konto {  position-anchor: --konto;  position-area: bottom span-inline-start;  inset: auto;  margin: 0;  margin-block-start: 0.5rem;  position-try-fallbacks: flip-block, flip-inline;  min-width: anchor-size(width);}
Anchor, placement, width and two fallbacks.
Computed by hand
  • Recomputing the position on every scroll and resize event. That runs on the main thread and stutters exactly when a lot is going on anyway.
  • Portalling the panel onto the body so no overflow: hidden can clip it. That takes it out of the document, and the order for keyboard and screen readers no longer matches what you see.
  • Hard-coding the panel width so it matches the button. The first longer label breaks it.
  • Picking a z-index that is high enough. It is never high enough, it is only higher than whatever you last tested.
Computed by the browser
  • position-area describes the intended spot once, and the browser keeps it there while scrolling.
  • The top layer replaces the portal. The element stays in the document where it belongs and still sits above everything.
  • min-width: anchor-size(width) ties the width to the trigger, however long the label gets.
  • position-try-fallbacks names the permitted fallbacks. Which one is needed is decided by the browser at the moment of opening.
An anchor needs both sidesanchor-name and position-anchor belong together, and both have to be set. If one side is missing or a name has a typo, the panel silently falls back to its default and sits in the middle of the screen again. There is no console warning for it. So when a popover turns up centred out of nowhere, check the spelling of those two names first.
04

Why does the panel not fade in smoothly?

On opening, a popover switches from display: none to display: block and lands in the top layer at the same time. Both properties, display and overlay, have no in-between values, they jump. A transition on opacity therefore goes nowhere: at the moment it would have to start, the element is not rendered yet.

Two declarations cover this, and they belong together. allow-discrete after the duration tells the browser to put those jumps at the end of the transition instead of the start. And a @starting-style block describes the value an element enters its very first rendering with. Without the second part there is no starting value to animate from, and the panel appears hard while the closing already runs smoothly.

Closing runs the same machinery in reverse, and there allow-discrete on its own is enough. What matters is that display and overlay are both in the list. Forget overlay and the element leaves the top layer before the transition has finished, so you see the last frames showing through behind the rest of the content.

[popover] {  opacity: 0;  translate: 0 -0.35rem;  transition:    opacity 180ms ease,    translate 180ms ease,    display 180ms allow-discrete,    overlay 180ms allow-discrete;} [popover]:popover-open {  opacity: 1;  translate: 0 0;}   @starting-style {    [popover]:popover-open { opacity: 0; translate: 0 -0.35rem; }  }
Four lines that turn a jump into a movement.
Stay under 200 millisecondsA panel hanging off a button travels a very short distance. Anything longer feels like the interface is hesitating, because click and result come apart. A gentle ease-out and a shift of a few pixels from the direction the panel comes from is plenty. If you honour prefers-reduced-motion, drop the shift there and keep the fade.

Two properties jump, three can flow. Only together do they add up to a movement.

Three curves cover everyday work.

A page with three curves feels ordered, one with twelve feels random. These three are almost always enough.

Soft0.22, 1, 0.36, 1Anything entering
Symmetric0.4, 0, 0.2, 1Open and close, back and forth
Calm0.32, 0.72, 0, 1Long distances across the page
05

What does popover not handle?

The gain is real, and it is bounded. The popover attribute describes a layer and an opening behaviour, not a finished component. Everything beyond that stays work, and it is exactly the work people overlook when moving over, because at first glance the result already looks right.

  1. A popover is never modal. The rest of the page stays operable and focus is not trapped. For a flow that should hold the page still, such as a confirmation before a delete, dialog with showModal remains the right element.
  2. A menu is more than a list. If you need real menu semantics, meaning the menu role with arrow keys and a roving tabindex, that is still yours to build. For navigation made of links it is often not wanted anyway: a list of a elements inside the popover is operable with the Tab key and needs no menu role.
  3. A tooltip needs a connection. For a hint to be announced together with the element it describes, it has to hang off it through aria-describedby. The layer alone does not create that connection, it only puts the text on top.
  4. Document order still counts. The top layer changes the rendering, not the tree. If the panel sits far from its button in the source, focus visibly jumps across the page. It stays calmest when the panel comes directly after its trigger.

What browsers do take over on their own is the connection between button and panel. A button with popovertarget tells assistive technology that it expands and collapses something, and which element that is. It needs no aria-expanded of your own, the kind you forget in the next refactor and that afterwards claims the opposite of the actual state.

NameEmailSubmit
Tabmoves the ring, focus-visible makes it visible.
Escape closes the panel and hands focus back to the trigger. That way back is otherwise something you build by hand.

popover handles the layer. The semantics of the component stay with you.

06

How does this look in a Tailwind project?

Tailwind v4 already knows the new states. The open variant matches :popover-open alongside an open details element, the starting variant writes into a @starting-style block, and transition-discrete sets transition-behavior: allow-discrete. That puts the whole transition from the previous section into a class list without adding a CSS file of your own.

There are no dedicated utilities for the two anchor properties yet, and that is fine: they come along in square brackets as arbitrary properties. The moment the same anchor name shows up for the third time, though, it is time to move it into a small component class instead of copying the bracket onward.

In React the state disappears. No useState for open and closed, no useEffect for the outside click, no ref on the panel. What is left is markup. Only when you want to open it from elsewhere, say after a successful response from the server, do you reach for showPopover, hidePopover or togglePopover through a ref.

<button  popoverTarget="konto"  className="[anchor-name:--konto] rounded-lg px-4 py-2"   Konto</button> <div  id="konto"  popover=""  className="    [position-anchor:--konto] [position-area:bottom_span-inline-start]    inset-auto m-0 mt-2 w-56 rounded-xl bg-white p-2 shadow-lg    transition-all transition-discrete duration-200    opacity-0 open:opacity-100 starting:open:opacity-0  " 
The same button and panel, this time as a class list.
@supports separates the two halves cleanlyThe behaviour is considerably older than the placement, and the two can be guarded separately. A query on @supports (anchor-name: --a) carries the modern branch, while the block before it keeps the old absolute positioning with its familiar limits. The popover itself, meaning the layer, the outside click and Escape, works the same in both branches. So the worst case is a panel in a less elegant spot, not a component that fails to open.
07

Where is the move worth it?

Not every component that looks like a dropdown gets simpler by moving over. The gain is largest where the panel lives briefly, does little and still dragged a library along. Four groups stand clearly apart.

  1. Worth it right away. Account menus, filter panels, share menus, hints next to a form field, short pick lists. Anything that opens, does one thing and closes again.
  2. Worth it with some work. A navigation menu with submenus. The layer, the closing and the fallback at the edge come for free, arrow key operation is what you add.
  3. Stays with dialog. Anything that should hold the page still: confirmations, multi-step forms, flows you must not be able to click out of by accident.
  4. Stays with a library. A combobox with typing and filtering, a date picker, a multiselect. There the work sits in the keyboard and the states, not in the layer, and that is precisely the part popover does not take off your hands.

A usable test is how many lines the JavaScript part still has after the rewrite. Zero left means the move is done. Twenty left for the arrow keys means it is done too. Two hundred left means the component has a different problem, and no attribute solves that one.

The layer is solved. The keyboard is not, and it was always the harder part.

Share
Written by
Amelie RoesmannCreative directionLeonie RoesmannDesign engineering

We build websites and web apps at Systra Studios in Münster and put the building blocks from that work here. What you read comes from real projects, not from a brochure.

Systra Studios: web design from Münster

You know the curves.
You do not have to guess the values.

A collection with previews: look at the curve, play it, copy the value. The same value for CSS and for framer-motion.

  • Every curve plays before you take it.
  • One click copies the cubic-bezier value.
  • The same values sit in every component.
Browse easings
Soft0.22, 1, 0.36, 1
Symmetric0.4, 0, 0.2, 1
Calm0.32, 0.72, 0, 1
No easing0, 0, 1, 1
FAQ

Frequently asked questions

The two parts are of different ages, so the answer differs twice. The popover attribute has been available in all major browsers since April 2024 and counts as settled. Anchor positioning is much younger: Chromium has shipped it since mid 2024, Safari since version 26, Firefox joined most recently, and position-area counts as newly available since early 2026. Anyone still serving older browsers gets a popover that shows up in the middle of the screen instead of at the button. That is ugly but usable, and a @supports block with the old absolute positioning tidies it up.

Didn't find your question? We're happy to help.

Get in touch