Removing Popper.js with CSS Anchor Positioning — Floating UI Handled Directly by the Browser
Anyone who has done frontend work eventually thinks to themselves: "Why is attaching a dropdown below a button this complicated?" When I first built my team's design system a few years ago, I lost an entire day to setting up Floating UI and debugging viewport flip logic. Looking back now, that complexity was never inevitable.
The root cause of that complexity was CSS itself. CSS had no way to directly express the relationship of "attach this element right below that button." That's why libraries like Popper.js stepped in — they calculated positions using JavaScript, listened for scroll and resize events with ResizeObserver to recalculate on the fly, and flipped elements to the opposite side whenever they hit the edge of the screen. They were necessary tools, but the weight they added to bundles and their runtime cost were not trivial.
As of 2026, CSS Anchor Positioning has achieved Baseline 2026, with native support in the latest versions of Chrome, Firefox, and Safari. In an environment covering 83–91% of global browser traffic, the era has arrived where the browser's layout engine directly handles what JavaScript used to calculate. In this article, we'll walk through how CSS Anchor Positioning works, how it can replace what Popper.js was doing, and what cases still require JavaScript — all with real code.
Core Concepts
The Problem CSS Anchor Positioning Solves
Let's start with the essence of the problem. The classic CSS position: absolute positions elements relative to the "nearest positioned ancestor." position: fixed uses the viewport as its reference. Neither provides a way to "position an element relative to a specific element that is far away in the DOM tree."
That's why Popper.js and Floating UI had to go through this process at runtime:
- Calculate screen coordinates by calling
getBoundingClientRect()on the trigger element - Position the floating element using
position: fixed+transformwith the calculated values - Detect scroll and resize events with
ResizeObserverand recalculate each time - Flip the floating element to the opposite side if it goes outside the viewport (flip)
CSS Anchor Positioning expresses this entire process in declarative CSS, with the browser's layout engine handling the calculations.
CSS Anchor Positioning: A native CSS API that allows you to position an element relative to another specific element (an anchor). You can implement floating UIs like tooltips, dropdowns, and popovers with declarative CSS alone — no JavaScript position calculations required.
Core Properties
Honestly, the property names felt unfamiliar when I first read the spec, but their intent is clear enough that they become natural quickly.
| Property / Function | Role |
|---|---|
anchor-name |
Registers an element as an anchor (uses a dashed-ident value like --my-btn) |
position-anchor |
Specifies the anchor that the floating element should reference |
position-area |
Declares which area in the 9-grid around the anchor to place the element |
anchor() function |
Precisely references a specific edge of the anchor, like top: anchor(--btn bottom) |
anchor-size() |
References the anchor's dimensions directly, like width: anchor-size(width) |
position-try-fallbacks |
A list of fallback placements to try when the element goes outside the viewport |
@position-try |
A rule that defines each fallback placement |
position-visibility |
Automatically hides the floating element when its anchor scrolls out of view |
anchor-scope |
An isolation property that prevents anchor name collisions in repeated components |
The -- prefix on anchor-name follows the same naming convention as CSS custom properties (--my-color: blue). It's a design choice to avoid collisions in the global namespace, and once you get used to it, it feels natural.
The 9-Grid Model of position-area
position-area places the anchor in the center and declares the placement position from the surrounding 9-cell grid.
┌─────────────┬───────────────┬─────────────┐
│ top left │ top center │ top right │
├─────────────┼───────────────┼─────────────┤
│ left │ (anchor) │ right │
├─────────────┼───────────────┼─────────────┤
│ bottom left │ bottom center │ bottom right│
└─────────────┴───────────────┴─────────────┘Writing position-area: top center places the element directly above and centered on the anchor. It's intuitive enough that you might wonder "why did this take so long to exist?"
You can also use the anchor() function instead of position-area for more precise control. It directly references the value of a specific edge of the anchor.
.tooltip {
position: absolute;
/* Starts at anchor's bottom edge, aligns to anchor's left edge */
top: anchor(--my-btn bottom);
left: anchor(--my-btn left);
margin-top: 8px;
}position-area is sufficient for most cases, but anchor() shines when you need asymmetric placement or custom offsets.
Constraints Worth Knowing
Before looking at code examples, there's one thing to clarify upfront. CSS Anchor Positioning is not a silver bullet. There is one constraint you'll encounter most often in practice.
If the anchor is inside a parent element with overflow: hidden or clip-path, the floating element will also be clipped by that parent. This is the same behavior as traditional position: absolute — unlike Popper.js, which worked around this by hoisting the element to the top of the DOM with position: fixed. Pay special attention to this if your anchor is inside a scrollable container.
Pros and Cons
Advantages
Here are the advantages I noticed firsthand while using it.
| Item | Detail |
|---|---|
| Performance | Handled by the browser's layout engine — no JS execution cost. No recalculation loop on scroll |
| Bundle size | 0KB extra for modern browsers. ~8KB polyfill only for legacy browsers |
| Dependencies | No npm packages needed, no configuration code |
| Declarativeness | Placement intent is clearly expressed in CSS, making maintenance easier |
| Accessibility | When combined with the popover attribute, focus management and aria handling are provided by default |
| Layer management | Using the popover attribute automatically places the element in the top-layer*, eliminating z-index conflicts |
* top-layer: The topmost rendering layer managed by the browser. It always displays above everything else regardless of z-index, and is where modals, dialogs, and popover elements live.
Disadvantages and Caveats
Here's an honest summary of the friction points I actually encountered.
| Item | Description | Workaround |
|---|---|---|
overflow clipping |
An anchor inside a clipped parent will also clip the floating element | Use position: fixed-based Floating UI alongside |
| Partial Safari support | Safari 18.2–18.3 supports basic placement but not @position-try |
Ensure Safari 26+ environment or conditionally apply OddBird polyfill |
| Hover tooltip cross-browser | popover="hint" (a popover that opens on mouse hover) is only supported in Chrome |
Hover tooltips still require JS |
| Shadow DOM* boundary | Referencing an external anchor from inside a Shadow DOM is limited | Partially solvable with OddBird polyfill |
| Virtualized lists | Virtual scroll environments where the anchor element unmounts from the DOM | Recommended to keep Floating UI in this case |
Duplicate anchor-name |
When components are repeatedly rendered with the same name, only the last element is recognized as the anchor | Use anchor-scope or inject a dynamically unique name |
| Multi-level submenus | Complex multi-level structures like lazy-loaded submenus | Requires JS coordination alongside |
* Shadow DOM: One of the web component technologies that creates an isolated DOM tree, shielding it from external styles and scripts. Custom elements and built-in HTML elements like <video> use Shadow DOM internally.
position-visibility: When set toposition-visibility: anchors-visible, the floating element is automatically hidden when its anchor scrolls out of the viewport. This is the CSS native equivalent of Popper.js'shidemiddleware.
anchor-scope: A property that isolates the anchor reference scope when multiple elements share the sameanchor-name. It solves the name collision problem that occurs when repeatedly rendering components in React or Vue. It is included in the CSS Anchor Positioning Level 2 spec.
The Most Common Mistakes in Practice
-
Duplicate
anchor-name: When repeatedly rendering components with Vue'sv-foror React's.map(), hardcodinganchor-name: --tooltipin a CSS class means only the last rendered element is recognized as an anchor. It's better to inject an index or unique ID via inline styles, or to useanchor-scope. -
Omitting
position: absolute: If the floating element doesn't haveposition: absolute(orfixed),position-anchorandposition-areado nothing. Many people spend a long time debugging why things aren't working during initial setup, only to find it was just this one missing line. -
Expecting
@position-tryon Safari 18.x: Versions prior to Safari 26+ silently ignore@position-try. This can lead to iPhone users reporting "the dropdown is going off screen" in production. It's recommended to conditionally load the OddBird polyfill or to use Floating UI as a fallback alongside.
Practical Application
Example 1: Basic Tooltip — Replacing Popper.js Code with 6 Lines of CSS
The difference becomes immediately obvious when you compare the two side by side.
The old Popper.js way:
import { createPopper } from '@popperjs/core';
const button = document.querySelector('#btn');
const tooltip = document.querySelector('#tooltip');
const popperInstance = createPopper(button, tooltip, {
placement: 'top',
modifiers: [
{
name: 'offset',
options: { offset: [0, 8] },
},
],
});The CSS Anchor Positioning way:
/* Register the anchor */
.btn {
anchor-name: --my-btn;
}
/* Position the floating element */
.tooltip {
position: absolute;
position-anchor: --my-btn;
position-area: top center;
margin-bottom: 8px;
}| Item | Popper.js | CSS Anchor Positioning |
|---|---|---|
| Lines of code | 10+ JS lines | 6 CSS lines |
| Bundle inclusion | Always (~12KB) | Not needed |
| Runtime calculation | On every render | Browser layout engine |
| Dependencies | npm package | None |
Example 2: Automatic Viewport Boundary Flipping — @position-try
Honestly, this was the most impressive feature. @position-try handles in pure CSS what Popper.js's flip middleware used to do.
/* Default placement: center above the button */
.dropdown {
position: absolute;
position-anchor: --my-trigger;
position-area: top center;
margin-bottom: 8px;
position-try-fallbacks: --flip-below, --flip-left;
}
/* If there's no space above, flip below */
@position-try --flip-below {
position-area: bottom center;
margin-bottom: 0; /* Reset margin-bottom from the default placement */
margin-top: 8px;
}
/* If below doesn't work either, flip to the left */
@position-try --flip-left {
position-area: left span-bottom;
margin-bottom: 0;
margin-right: 8px;
}It's easy to forget to reset margin-bottom to 0 inside each @position-try block, but without that line, the spacing from the previous rule carries over and misaligns the placement. Because the browser tries each fallback in order and automatically selects the one that fits within the viewport, it's safest to define the margin for each case completely independently.
position-try-fallbacks: A list of alternative placements the browser tries in order when the default placement goes outside the viewport. This is the CSS native equivalent of Popper.js'sflipmiddleware.
Example 3: Combining with the Popover API — A Fully JS-Free Dropdown
When combined with the HTML popover attribute, you get open/close toggling, light dismiss (closing on outside click), and Escape key closing — all with zero lines of JavaScript.
<button popovertarget="menu" class="menu-btn">
Open Menu
</button>
<ul id="menu" popover>
<li>Profile</li>
<li>Settings</li>
<li>Log Out</li>
</ul>/* Register the anchor via CSS class */
.menu-btn {
anchor-name: --menu-btn;
}
#menu {
position: absolute;
position-anchor: --menu-btn;
position-area: bottom span-right;
/* Same width as the button — replaces Popper.js's sameWidth middleware */
width: anchor-size(width);
/* Automatically hide the popover when the anchor scrolls out of view */
position-visibility: anchors-visible;
margin-top: 4px;
list-style: none;
padding: 8px 0;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
}An element with the popover attribute automatically goes into the top-layer, so z-index management is unnecessary. Registering the anchor name via a CSS class rather than an inline style is also an intentional choice — for a single menu button like in this example, the CSS class approach is cleaner. The inline style approach is reserved for situations where you need to dynamically inject a unique name when rendering components repeatedly.
Example 4: Entrance Animations — Combining with @starting-style (Bonus)
This example isn't directly related to replacing Popper.js, but it's a bonus you get when combining with the Popover API. Adding @starting-style lets you handle entrance animations without any JS.
#menu {
/* ... anchor positioning code ... */
opacity: 1;
transform: translateY(0);
transition:
opacity 0.15s ease,
transform 0.15s ease,
display 0.15s allow-discrete, /* ① */
overlay 0.15s allow-discrete; /* ② */
}
/* Initial state before appearing */
@starting-style {
#menu:popover-open {
opacity: 0;
transform: translateY(-4px);
}
}
/* When disappearing */
#menu:not(:popover-open) {
opacity: 0;
transform: translateY(-4px);
}① display: 0.15s allow-discrete — display is normally a discrete value that doesn't support transitions, but adding allow-discrete allows its value to change at the start and end of the transition. Without this line, the popover appears abruptly with no animation.
② overlay: 0.15s allow-discrete — Delays the removal of the popover from the top-layer until after the transition ends. Without this line, the disappearing animation doesn't play on close — it's hidden immediately.
Closing Thoughts
CSS Anchor Positioning has become a practical alternative that simultaneously reduces npm dependencies and runtime calculation costs, as the browser's layout engine directly handles most of what Popper.js used to solve.
When I first encountered this API, I thought "does this actually work?" — and after using it, I felt the same way I did when I first adopted Popper.js: "Why did I only find out about this now?"
Three steps you can take right now:
-
Check browser support and add a polyfill: You can check for native support with
CSS.supports('anchor-name', '--x'). If a significant portion of your users are still on pre-Safari 26+ versions, it's recommended to add the polyfill withpnpm add @oddbird/css-anchor-positioning. Configuring a conditional import using theCSS.supports()check above — so it doesn't load for modern browsers — minimizes the performance impact. -
Replace one existing tooltip with CSS: Pick the simplest tooltip in your project, remove the Popper.js code, and replace it with the three properties
anchor-name+position-anchor+position-area. You'll get a feel for it quickly. You can also visually inspect the anchor connection state in the Elements panel of Chrome DevTools, making debugging easy. -
Combine with the Popover API to remove dropdown JS: Use
<button popovertarget="...">and thepopoverattribute to delegate open/close logic to HTML, and handle placement in CSS withposition-area— you'll noticeably feel the reduction in dropdown-related JS code.
References
- Introducing the CSS anchor positioning API | Chrome for Developers
- Using CSS anchor positioning | MDN Web Docs
- CSS Anchor Positioning Guide | CSS-Tricks
- Anchor Positioning Updates for Fall 2025 | OddBird
- OddBird CSS Anchor Positioning Polyfill | GitHub
- First Public Working Draft: CSS Anchor Positioning Module Level 2 | W3C
- Dropdown Menus with HTML Popovers and CSS Anchor Positioning | Cory Rylan
- CSS Anchor Positioning and the Popover API for a JS-Free Site Menu | CSS { In Real Life }