Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

ยฉ 2026 Dev Encyclopedia

Back to top โ†‘
  1. Home
  2. /Blog
  3. /CSS Anchor Positioning: Migrating shadcn/ui Off Radix
html css11 min read

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.

Zeeshan Tofiq
Zeeshan Tofiq
July 12, 2026
On this page

On this page

  • Why Radix/Floating UI Existed in the First Place
  • What You Gain and What You Lose
  • Migrating the shadcn/ui Popover Component
  • Migrating the shadcn/ui Tooltip Component
  • Handling the Safari @position-try Gap
  • What You Still Need Radix (or Manual JS) For
  • Incremental Adoption Strategy
  • Frequently Asked Questions

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.

๐Ÿ’ก TL;DR

Native CSS Anchor Positioning and the Popover API win for simple Tooltip and Popover components: less JavaScript, less runtime cost, native top-layer rendering. Keep Radix for DropdownMenu, Select, and anything that needs focus trapping or complex keyboard navigation. Migrate incrementally, starting with Tooltip.

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

DimensionRadix UI + Floating UINative CSS Anchor Positioning
Bundle size@radix-ui/react-popover plus @floating-ui/react adds real weight per componentZero JS for positioning
Runtime costJS recalculates position on every scroll and resizeBrowser handles it natively, no JS execution
Viewport flip behaviorHandled by Floating UI's flip() middlewareHandled by position-try-fallbacks
Focus trappingBuilt in out of the boxYou implement it yourself, or keep Radix for this
Keyboard navigationBuilt into Radix's primitivesYou implement it yourself for complex cases
aria-expanded wiringAutomaticWired manually
Browser supportWorks everywhere JS runsRequires 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.

tsx โ€” components/ui/popover.tsx (Radix version)
"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 }
tsx โ€” Usage
<Popover>
  <PopoverTrigger asChild>
    <Button variant="outline">Open</Button>
  </PopoverTrigger>
  <PopoverContent>
    <p>Place content for the popover here.</p>
  </PopoverContent>
</Popover>

โ„น Info

The point of this migration: the usage above does not change. Same component names, same props, same JSX shape. Only the file behind components/ui/popover.tsx changes.

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.

tsx โ€” components/ui/popover.tsx (native version)
"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.

tsx โ€” components/ui/tooltip.tsx (Radix version)
"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 }
tsx โ€” components/ui/tooltip.tsx (native version)
"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 }

โš  Warning

The popover="hint" value is built specifically for tooltip-style behavior: it does not interrupt other open popovers the way auto does, and multiple hints can coexist. Browser support for hint specifically is still growing as of 2026. Pair it with a popover="manual" fallback plus JS hover and focus handlers if your audience includes older browsers.

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.

css โ€” popover.css
/* 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 popover attribute and role="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. 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. 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. 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. 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-name property 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-fallbacks flips 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?
FeatureRadix UI / Floating UICSS Anchor Positioning
Positioning engineJavaScript, recalculated on scroll and resizeNative browser layout
Bundle sizeAdds a dependency per componentZero JS
Focus trappingBuilt inManual
Best fitComplex interactive menusSimple 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.

css
.popover-content {
  position-anchor: --my-trigger;
  top: anchor(--my-trigger bottom);
  position-try-fallbacks: flip-block;
}

โš  Warning

Safari 18.2-18.3 apply the top: anchor(...) value but ignore position-try-fallbacks entirely. You get correct static placement, just without the automatic flip near the viewport edge, until users upgrade to 18.4+.

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.

  1. Multiple `auto` popovers - opening a new auto popover closes any other open auto popover unless one is an ancestor of the other, matching native <select> and browser menu behavior.
  2. Nested popovers - a popover triggered from inside another auto popover stays open alongside its parent, since it's part of the same ancestor chain.
  3. `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.

โ„น Info

If you need a popover to visually sit above another unrelated popover in a specific order, the top layer stacks by DOM insertion order (most recently shown on top), not by a z-index value you set.

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() and position-anchor, but not @position-try or position-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.

Should I migrate DropdownMenu and Select off Radix too?

Not yet, for most teams. Positioning is only part of what DropdownMenu and Select do. The bigger cost is arrow-key navigation, type-ahead search, submenu handling, and focus management, none of which CSS Anchor Positioning touches.

Migrate Tooltip and simple Popover instances first. Revisit DropdownMenu and Select once you have a reusable focus-trap and keyboard-navigation utility, or once native focus trapping matures further.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

nextjs

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.

Jun 6, 2026ยท8 min read
html css

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.

May 30, 2026ยท9 min read
react

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.

Jun 1, 2026ยท10 min read

On this page

  • Why Radix/Floating UI Existed in the First Place
  • What You Gain and What You Lose
  • Migrating the shadcn/ui Popover Component
  • Migrating the shadcn/ui Tooltip Component
  • Handling the Safari @position-try Gap
  • What You Still Need Radix (or Manual JS) For
  • Incremental Adoption Strategy
  • Frequently Asked Questions
Advertisement