CSS Anchor Positioning: Migrating shadcn/ui Off Radix
How to migrate shadcn/ui Popover and Tooltip from Radix UI to native CSS Anchor Positioning and the Popover API, with real before/after code.
On this page
CSS Anchor Positioning and the native Popover API reached full Baseline support across major browsers in 2026. That means shadcn/ui's Popover and Tooltip components, both built on Radix UI primitives, can now be rebuilt with zero JavaScript for positioning.
Plenty has been written about what CSS Anchor Positioning is. This post skips that. It walks through what actually happens when you take a real shadcn/ui component off Radix and rebuild it on native CSS: the code, the trade-offs, and the parts you should not touch yet.
If your stack is already leaning into modern, low-JS patterns like the setup in this flash-free dark mode guide, this migration fits the same philosophy: ship less JavaScript and let the platform do more of the work.
Why Radix/Floating UI Existed in the First Place
This isn't a story about Radix getting something wrong. Before native anchor positioning existed, JavaScript was the only way to solve tethered positioning correctly.
You needed to measure the trigger's position with getBoundingClientRect(), calculate where the floating element should go, listen for scroll and resize events, and detect viewport overflow to flip the element to the other side. Floating UI, the library Radix uses internally, does all of this well. It's the reason shadcn/ui's Popover, Tooltip, DropdownMenu, and Select all work reliably today.
This mirrors a broader shift happening across CSS: capabilities that used to require JavaScript, like conditional styling with the :has() selector, are moving into the platform itself. What changed for positioning specifically is that the browser now does the tethering calculation natively.
What You Gain and What You Lose
| Dimension | Radix UI + Floating UI | Native CSS Anchor Positioning |
|---|---|---|
| Bundle size | @radix-ui/react-popover plus @floating-ui/react adds real weight per component | Zero JS for positioning |
| Runtime cost | JS recalculates position on every scroll and resize | Browser handles it natively, no JS execution |
| Viewport flip behavior | Handled by Floating UI's flip() middleware | Handled by position-try-fallbacks |
| Focus trapping | Built in out of the box | You implement it yourself, or keep Radix for this |
| Keyboard navigation | Built into Radix's primitives | You implement it yourself for complex cases |
| aria-expanded wiring | Automatic | Wired manually |
| Browser support | Works everywhere JS runs | Requires Baseline 2026 browsers, needs a fallback for older Safari |
The honest read: for a tooltip that shows a label, or a popover holding a paragraph of text and a button, native CSS wins outright. You delete a dependency and the runtime cost that comes with it.
For a DropdownMenu with arrow-key navigation and type-ahead search, or a Select with real focus management, Radix still does work you don't want to rebuild by hand. Match the tool to the component, not to a blanket "native is better" rule.
Migrating the shadcn/ui Popover Component
Here's the standard shadcn/ui Popover, generated straight from the CLI. It wraps @radix-ui/react-popover and forwards props through to Radix's Content primitive.
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Open</Button>
</PopoverTrigger>
<PopoverContent>
<p>Place content for the popover here.</p>
</PopoverContent>
</Popover>Now the native version. It uses anchor-name (set via the anchorName CSS property) to mark the trigger, anchor() to position the content relative to it, and the browser's built-in popover attribute for top-layer rendering, light-dismiss, and Escape-to-close.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface PopoverContextValue {
triggerId: string
contentId: string
}
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
function Popover({ children }: { children: React.ReactNode }) {
const id = React.useId()
const value = React.useMemo(
() => ({ triggerId: `popover-trigger-${id}`, contentId: `popover-content-${id}` }),
[id]
)
return <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>
}
function PopoverTrigger({
children,
asChild,
}: {
children: React.ReactElement
asChild?: boolean
}) {
const ctx = React.useContext(PopoverContext)
if (!ctx) throw new Error("PopoverTrigger must be used within Popover")
return React.cloneElement(children, {
popoverTarget: ctx.contentId,
style: { anchorName: `--${ctx.triggerId}`, ...children.props.style },
} as any)
}
const PopoverContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { align?: "start" | "center" | "end" }
>(({ className, align = "center", style, ...props }, ref) => {
const ctx = React.useContext(PopoverContext)
if (!ctx) throw new Error("PopoverContent must be used within Popover")
const alignStyle =
align === "center"
? { justifySelf: "anchor-center" as const }
: align === "start"
? { justifySelf: "start" as const }
: { justifySelf: "end" as const }
return (
<div
ref={ref}
id={ctx.contentId}
popover="auto"
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none m-0",
"opacity-0 [&:popover-open]:opacity-100 transition-[opacity,display] duration-150",
"[transition-behavior:allow-discrete]",
className
)}
style={{
positionAnchor: `--${ctx.triggerId}`,
top: `anchor(--${ctx.triggerId} bottom)`,
positionTryFallbacks: "flip-block, flip-inline",
...alignStyle,
...style,
}}
{...props}
/>
)
})
PopoverContent.displayName = "PopoverContent"
export { Popover, PopoverTrigger, PopoverContent }Look at what disappeared. No Portal component, the popover attribute handles top-layer rendering natively. No manual scroll or resize listeners. No Floating UI middleware configuration to tune.
The popover="auto" attribute gives light-dismiss and Escape-to-close automatically, the same behavior Radix's Popover ships today, just built into the browser instead of shipped in a bundle.
Migrating the shadcn/ui Tooltip Component
Tooltip follows the same pattern as Popover, with one difference: it uses popover="hint" instead of popover="auto". Here's the Radix version first.
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground",
"animate-in fade-in-0 zoom-in-95",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const TooltipContext = React.createContext<{ anchorId: string } | null>(null)
function Tooltip({ children }: { children: React.ReactNode }) {
const id = React.useId()
return (
<TooltipContext.Provider value={{ anchorId: `tooltip-${id}` }}>
{children}
</TooltipContext.Provider>
)
}
function TooltipTrigger({ children }: { children: React.ReactElement }) {
const ctx = React.useContext(TooltipContext)
if (!ctx) throw new Error("TooltipTrigger must be used within Tooltip")
return React.cloneElement(children, {
popoverTarget: ctx.anchorId,
style: { anchorName: `--${ctx.anchorId}`, ...children.props.style },
} as any)
}
const TooltipContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, style, ...props }, ref) => {
const ctx = React.useContext(TooltipContext)
if (!ctx) throw new Error("TooltipContent must be used within Tooltip")
return (
<div
ref={ref}
id={ctx.anchorId}
popover="hint"
role="tooltip"
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground m-0",
"opacity-0 [&:popover-open]:opacity-100 transition-opacity duration-150",
className
)}
style={{
positionAnchor: `--${ctx.anchorId}`,
bottom: `anchor(--${ctx.anchorId} top)`,
justifySelf: "anchor-center",
marginBottom: "4px",
positionTryFallbacks: "flip-block",
...style,
}}
{...props}
/>
)
})
TooltipContent.displayName = "TooltipContent"
export { Tooltip, TooltipTrigger, TooltipContent }Handling the Safari @position-try Gap
Safari 18.2 and 18.3 support the core anchor() function and position-anchor, but not @position-try or the position-try-fallbacks shorthand. Safari 18.4 and later have full support.
The fix is to write your base positioning properties as plain values, not exclusively inside a fallback block, so browsers without fallback support still get correct initial placement. They just miss the automatic viewport-edge flip.
/* This pattern degrades gracefully */
.popover-content {
position-anchor: --my-trigger;
top: anchor(--my-trigger bottom); /* works everywhere with anchor() support */
position-try-fallbacks: flip-block; /* ignored gracefully on Safari 18.2-18.3 */
}Browsers that don't understand position-try-fallbacks simply ignore that line and keep the top: anchor(...) value. You get correct placement on every supporting browser, and automatic viewport-edge flipping only where it's available. No feature detection required.
What You Still Need Radix (or Manual JS) For
None of the above means native CSS is ready to replace Radix everywhere. Three gaps matter enough that you should keep the JavaScript version until you've closed them yourself.
- Focus trapping - if a Popover holds a form with multiple focusable elements, Radix traps focus inside it while open and returns focus to the trigger on close. The native Popover API does not do this. Implement it manually with a focus-trap utility, covered in more depth in this guide to ARIA in React, or keep complex interactive popovers on Radix.
- Complex keyboard navigation - DropdownMenu and Select involve arrow-key navigation, type-ahead search, and submenu handling. Rebuilding that with native CSS plus manual JS is a meaningfully bigger project than this post covers.
- ARIA wiring for complex widgets - the
popoverattribute androle="tooltip"cover the essentials for a simple Popover or Tooltip. More complex widgets still need manual ARIA work to stay accessible.
Incremental Adoption Strategy
Don't migrate everything in one pass. Work through it in order of risk, lowest first.
- 1
Start with Tooltip
Tooltip is the simplest component here: no focus management, no keyboard navigation to preserve. It's the lowest-risk place to validate the pattern across your target browsers before touching anything more complex.
- 2
Migrate Popover for simple content next
Move Popover instances that hold text, a single button, or a one-field form. Hold off on anything with a multi-field form or nested interactive content until you have a focus-trap utility in place.
- 3
Leave DropdownMenu and Select on Radix
For most teams, the keyboard navigation and accessibility rebuild cost for these two components outweighs the payoff right now. Revisit once native focus trapping is more mature.
- 4
Measure bundle size before and after each migration
Run your bundle analyzer before and after each component migration. Use the real number, not an assumption, to decide how far to take this and whether it's worth the engineering time for your app.
Frequently Asked Questions
What is CSS Anchor Positioning?
CSS Anchor Positioning lets you tether one element, like a popover, to another element, its trigger, using pure CSS. No JavaScript measuring or repositioning code required.
- Anchor name - the
anchor-nameproperty marks any element as a positioning reference - Anchor function -
anchor()reads the anchor's edges (top, bottom, start, end) inside top, left, right, or bottom values - Fallback positioning -
position-try-fallbacksflips the tethered element to the other side when it would overflow the viewport
How does CSS Anchor Positioning compare to Radix UI and Floating UI?
| Feature | Radix UI / Floating UI | CSS Anchor Positioning |
|---|---|---|
| Positioning engine | JavaScript, recalculated on scroll and resize | Native browser layout |
| Bundle size | Adds a dependency per component | Zero JS |
| Focus trapping | Built in | Manual |
| Best fit | Complex interactive menus | Simple tooltips and popovers |
For anything more complex than a tooltip or a text popover, that gap in focus trapping and keyboard handling is usually the deciding factor, not positioning accuracy.
Does this work with React Server Components?
The native Popover and Tooltip components in this post still need "use client". They use useId, context, and event handling through the popover attribute, all of which require a client component, exactly like their Radix equivalents did.
CSS Anchor Positioning itself is just CSS and has no rendering-environment restrictions. What stays client-side is the React wrapper logic, not the positioning.
How do I handle the Safari @position-try gap in production?
Write your base position as a plain anchor() value outside any fallback block, then add position-try-fallbacks as a progressive enhancement on top of it.
.popover-content {
position-anchor: --my-trigger;
top: anchor(--my-trigger bottom);
position-try-fallbacks: flip-block;
}What happens with nested popovers or z-index conflicts?
Elements with the popover attribute render in the top layer, a rendering layer above the normal document, so they never need manual z-index juggling against your app's stacking contexts. That part just works.
- Multiple `auto` popovers - opening a new
autopopover closes any other openautopopover unless one is an ancestor of the other, matching native<select>and browser menu behavior. - Nested popovers - a popover triggered from inside another
autopopover stays open alongside its parent, since it's part of the same ancestor chain. - `hint` popovers - these don't participate in that light-dismiss chain, so a tooltip can stay open while a separate popover is open, which is what you want.
Which browsers support CSS Anchor Positioning and the Popover API in 2026?
- Chrome and Edge - full support for anchor positioning and the Popover API, including
@position-try - Firefox - anchor positioning support landed and is stable in current releases
- Safari 18.2-18.3 - supports
anchor()andposition-anchor, but not@position-tryorposition-try-fallbacks - Safari 18.4+ - full support, matching Chrome and Firefox
The graceful-degradation pattern in this post's Safari section covers the 18.2-18.3 gap without any feature detection, so you don't need a JS-based @supports check for most use cases.
Related Articles
Next.js Dark Mode Without the Flash (Tailwind v4)
Add dark mode to your Next.js App Router app without the white flash. Complete guide covering next-themes, Tailwind CSS v4 setup, and Cloudflare Pages.
8 CSS :has() Patterns You'll Actually Use (2026)
CSS :has() is production-ready in every browser. Here are 8 real-world patterns: form states, sibling dimming, modal scroll-lock, and more.
ARIA in React: Stop Using aria-label Wrong
Pages using ARIA average 41% more accessibility errors. Learn the correct ARIA patterns for React: icon buttons, modals, toasts, spinners, and tab panels.