You can build a tooltip or small menu without a library by combining two browser features. The Popover API puts an element in the top layer and handles closing on Escape or an outside click. CSS anchor positioning then pins that element to its trigger and flips it when it would run off screen.
Every project page on this site lists the technologies the project runs on as small buttons. Click one and a panel opens beside it listing the other projects that use the same thing. On the CoverageCobra case page, which has the longest stack list in our project data, there are twelve of them.
A few years ago this meant a positioning library: measure the button, measure the panel, measure the viewport, flip if it does not fit, and repeat on scroll and resize. We wrote about twenty-five lines of JavaScript and a handful of CSS rules instead, and the browser does the measuring.
Here is the code, and where it still needs a fallback.
What does the Popover API do for you?
The Popover API turns any element with a popover attribute into a panel the browser manages. It is hidden until shown, it renders in the top layer above everything else, and it closes by itself when the visitor presses Escape or clicks outside it. No z-index or click-outside listener is needed.
Our page shell contains exactly one popover, written once by the build script at the end of every page:
<div class="also" popover id="also"></div>
One panel serves every chip on the page. That keeps the HTML small, and it means the content is filled in at the moment of the click instead of being printed many times over. The cost is that we open it from script with showPopover() instead of using the declarative popovertarget attribute, because both the content and the anchor depend on which chip was clicked.
How do you attach one popover to whichever button was clicked?
To share one popover among many triggers, give the clicked button an anchor-name at click time and remove it from the previous one. The popover's CSS always refers to the same name, so whichever element currently holds that name becomes the thing the popover is positioned against.
This is the handler from our site script, with the HTML string shortened:
document.addEventListener('click', async (e) => {
const chip = e.target.closest('.chip');
if (!chip) return;
const tech = chip.dataset.tech;
const here = chip.dataset.here || '';
const list = (await load()).filter((p) => p.tech.includes(tech) && p.slug !== here);
openChip?.style.removeProperty('anchor-name');
openChip?.classList.remove('is-open');
chip.style.setProperty('anchor-name', '--chip');
chip.classList.add('is-open');
openChip = chip;
also.innerHTML = /* heading and a list of links */;
also.showPopover();
});
Three details are worth copying. The listener is on the document, so it works for chips anywhere on the page. load() fetches /projects.json once and reuses the same promise after that. And cleanup lives in the popover's toggle event, so the highlighted chip resets however the panel was closed:
also.addEventListener('toggle', (e) => {
if (e.newState === 'closed') {
openChip?.classList.remove('is-open');
openChip?.style.removeProperty('anchor-name');
openChip = null;
}
});
How does CSS anchor positioning place the popover?
CSS anchor positioning lets one element take its coordinates from another. You name the anchor with anchor-name, point the positioned element at it with position-anchor, and use the anchor() function inside left, top, and the other inset properties. The browser keeps the two attached through scrolling and resizing.
Our rule sits inside a feature query, because not every browser has this yet:
@supports (position-anchor: --a) {
.also {
position-anchor: --chip;
left: anchor(left);
top: calc(anchor(bottom) + 0.5rem);
translate: none;
position-try-fallbacks: flip-block, flip-inline;
}
}
Read it as: line my left edge up with the chip's left edge, and put my top edge half a rem below the chip's bottom. position-try-fallbacks is the part that used to need a library. If the panel would overflow, the browser tries the mirrored placement on the block axis, which puts it above the chip, and then the mirrored placement on the inline axis.
There is one line outside the query that is easy to miss. Browsers centre a popover by default with their own inset and margin styles, and those fight your anchor coordinates. Our base rule sets inset: auto and margin: 0 before positioning anything.
What happens in browsers without anchor positioning?
In a browser without anchor positioning, the @supports block is skipped and the popover falls back to whatever the base rule says. Ours places it in the middle of the viewport as a small fixed dialog. It is less elegant than sitting under the chip, but it is readable and it closes the same way.
.also {
inset: auto;
position: fixed;
left: 50%;
top: 50%;
translate: -50% -50%;
}
This is the honest state of things. The Popover API is available in all the current major browsers. Anchor positioning is newer and has been arriving one engine at a time, so some of your visitors will be on a browser that lacks it. Write the fallback first and treat the anchored version as the upgrade.
| Feature | What it replaces | If it is missing |
|---|---|---|
popover attribute | Click-outside and Escape handlers, z-index stacking | Our panel would not open, a risk we accept because support is broad |
| Anchor positioning | A JavaScript positioning library | Our panel opens centred in the viewport |
position-try-fallbacks | Collision detection and flipping code | Covered by the same centred fallback |
@starting-style | A class toggled a frame after insertion | The panel appears with no fade |
How do you animate a popover opening and closing?
A popover switches between display: none and visible, which ordinary transitions cannot animate. Two newer pieces fix that: @starting-style gives the browser a starting value for the first frame an element is shown, and allow-discrete lets display and overlay take part in a transition so the exit can play.
.also:popover-open { opacity: 1; transform: none; }
.also {
opacity: 0;
transform: translateY(-0.4rem);
transition: opacity 0.18s, transform 0.18s,
overlay 0.18s allow-discrete, display 0.18s allow-discrete;
}
@starting-style {
.also:popover-open { opacity: 0; transform: translateY(-0.4rem); }
}
The closed state is transparent and nudged up. The open state is opaque and in place. The @starting-style block repeats the closed values so the browser has something to animate from on entry. Where a browser does not support these, the panel still opens and closes, just without the fade.
What are the steps to build one yourself?
Building an anchored popover takes a trigger, a panel with the popover attribute, and a small amount of CSS. Start with the version that works everywhere, then layer on anchoring and animation behind feature checks, so no visitor is left with a panel in the wrong place.
- Make the trigger a real
buttonso it is focusable and works from the keyboard. - Add one element with the
popoverattribute and style its fallback position. - Reset the browser's default
insetandmarginon the popover. - Give the trigger an
anchor-nameand the popover a matchingposition-anchor, inside@supports. - Add
position-try-fallbacksso it flips near the edges. - Add the
@starting-stylefade last, and test with the panel near every edge of the screen.
The same thinking runs through the rest of this site: page-to-page morphs with the View Transitions API and a pinned horizontal section in pure CSS both replaced code we used to ship.
Frequently asked questions
Is a popover the same thing as a dialog element?
No. A modal dialog blocks the rest of the page until it is closed, which suits forms and confirmations. A popover is lighter: the page behind it stays usable and it dismisses when you click elsewhere. Use a popover for menus, hints, and small panels of links.
Can anchor positioning be used without the Popover API?
Yes. Any absolutely or fixed positioned element can be tied to an anchor. The two features are separate and happen to work well together, because a popover lives in the top layer, away from its trigger in the document, and needs some way to find it again.
Do popovers work for hover tooltips?
The basic popover attribute is built around click and keyboard activation. A tooltip that opens on hover or focus needs extra event handling or newer additions to the platform that are not available everywhere yet. Our chips open on click, which also works on touch screens.
Is a script-opened popover accessible?
It can be. Ours is opened by a real button, receives the browser's built-in Escape handling, and contains ordinary links. A declarative popovertarget button gives the browser more information about the relationship, so prefer it when the panel's content does not depend on which button was pressed.
Should we remove our positioning library now?
Only if the centred or otherwise simple fallback is acceptable for visitors on browsers without anchor positioning. For a small panel of links it was for us. For a complex app, keep the library until support catches up.
If you have an interface carrying more JavaScript than it needs, send us a short brief and we will take a look.