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.
- 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.
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.
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>
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);}
- 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.
- 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.
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; } }
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.
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.
- 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.
- 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.
- 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.
- 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.
popover handles the layer. The semantics of the component stay with you.
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 "
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.
- 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.
- 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.
- 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.
- 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.




