DEV Community

Cover image for Position tooltips and popovers with native CSS — zero runtime JS
mk668a
mk668a

Posted on

Position tooltips and popovers with native CSS — zero runtime JS

Repo: https://github.com/mk668a/css-anchor-kit · MIT.

css-anchor-kit is floating-ui's React API with the JavaScript runtime deleted — positioning happens in the browser's layout engine, not in a requestAnimationFrame loop.

Here's a tooltip. The whole thing:

import { useAnchor } from 'css-anchor-kit'

function Tooltip() {
  const { anchorProps, floatingProps } = useAnchor({ placement: 'top', offset: 8 })
  return (
    <>
      <button {...anchorProps}>Hover me</button>
      <div {...floatingProps} role="tooltip">Type less. Think more.</div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

No refs to wire. No effect to keep position in sync on scroll. anchorProps and floatingProps are just inline styles that compile to anchor-name, position-anchor, anchor(), and position-try-fallbacks. When the page scrolls or the anchor moves, the browser repositions the box — the same way it already keeps position: sticky glued in place. Your JavaScript never runs.

That badge under the button is the snippet above, live. The arrow points at the trigger and the box sits 10px below it — placement the browser computed from inline anchor-name / anchor(), not a measurement loop.

The gap it fills

floating-ui is one of the most-downloaded packages on npm — ~28M installs a week — and its core job is keep this box next to that box. To do that in 2020 you had to: measure the anchor's rect, measure the floating box, compute a position, write it to style, and then re-run that whole pipeline on every scroll, resize, and layout shift via an autoUpdate loop. That's the runtime cost. That's why there's a library.

Browsers now do this natively. CSS Anchor Positioning reached Baseline in 2026 (Chrome/Edge 125+, Safari 26+; Firefox behind a flag with a solid polyfill). You name an anchor, point a floating element at it, and the layout engine keeps them together — no measurement, no loop, no reflow in the scroll path.

The catch: the raw CSS API is verbose and unfamiliar. anchor-name: --foo, position-anchor: --foo, top: anchor(bottom), position-try-fallbacks: flip-block… nobody has muscle memory for that yet, and mapping "I want bottom-start with an 8px gap that flips on overflow" onto it by hand is fiddly.

So there's a hole: the platform can do the work, but the ergonomics everyone learned belong to floating-ui. css-anchor-kit is the thin headless layer that bridges it — floating-ui's placement / offset / flip / arrow vocabulary on top, native CSS underneath, nothing in between at runtime.

floating-ui css-anchor-kit
Position computed by JS, on every scroll/resize the browser's layout engine
Runtime cost measure → place → autoUpdate loop none — it's CSS
Bundle (min+gzip) ~6–10 KB core + React < 1 KB, React optional
Arrow stays on anchor when edge-aligned needs JS middleware a sibling anchored to the same element
Works without React yes yes — buildAnchorStyles

The API is the one you already know

If you've written floating-ui, you can read this without docs:

const { anchorProps, floatingProps, arrowProps, supported } = useAnchor({
  placement: 'bottom',  // the 12 floating-ui placements
  offset:    8,          // gap in px
  flip:      true,       // flip to the opposite side on overflow (default)
  hide:      false,      // hide when the anchor scrolls out of view
  size:      false,      // match the anchor's size: 'width' | 'height' | true
})
Enter fullscreen mode Exit fullscreen mode

The repo ships a docs app that turns every one of those options into a live control. Change placement to bottom, drag offset to 10, and the canvas re-positions while the generated code on the right rewrites itself to match — there's no measurement step in between, the engine just resolves the new anchor() insets:

useAnchor only computes position. It deliberately doesn't own visibility or interaction — pair it with the native popover attribute, a hover/focus state, or your own useState. That separation is the whole reason it stays under a kilobyte.

A few things worth calling out:

  • Arrows are a sibling, not middleware. The arrow element is anchored to the same anchor as the floating box, so it stays centered on the trigger even when the popover is edge-aligned or flips. floating-ui needs an arrow() middleware and a ref for this; here it's just {...arrowProps} on a sibling <div>.
  • size matches the trigger. size: 'width' emits anchor-size(width) — exactly what you want for a combobox/select popover that should be as wide as its button.
  • RTL is free. -start/-end placements pin logical edges (inset-inline-*), so bottom-start aligns to the right edge in RTL automatically — matching floating-ui, with zero JS.

Prefer composition? There's optional headless sugar that tree-shakes away if unused:

import { Anchored, Anchor, Floating, Arrow } from 'css-anchor-kit'

<Anchored placement="top" offset={8}>
  <Anchor as="button">Hover me</Anchor>
  <Floating role="tooltip">Type less. Think more.</Floating>
  <Arrow className="arrow" />
</Anchored>
Enter fullscreen mode Exit fullscreen mode

This is exactly how you build a real dropdown. The menu below pairs <Anchored size="width"> with the native popover attribute — so the list renders in the top layer (no z-index wars) and matches its trigger's width via anchor-size(width), which floating-ui needs a JS size middleware to do:

And it isn't even React-locked — the framework-agnostic core has zero dependencies:

import { buildAnchorStyles } from 'css-anchor-kit/core'

const { anchor, floating } = buildAnchorStyles('--my-tooltip', { placement: 'top', offset: 8 })
Object.assign(anchorEl.style, anchor)
Object.assign(floatingEl.style, floating)
Enter fullscreen mode Exit fullscreen mode

You don't have to migrate by hand

If you have an existing floating-ui codebase, there's a jscodeshift codemod that does the mechanical 80%:

npx css-anchor-kit migrate "src/**/*.{ts,tsx}"        # rewrite in place
npx css-anchor-kit migrate "src/**/*.tsx" --dry --print # preview only
Enter fullscreen mode Exit fullscreen mode

It rewrites useFloating(...)useAnchor(...), maps offset/flip/hide middleware to options, drops the now-pointless autoUpdate loop, rewires ref={refs.setReference} / setFloating to {...anchorProps} / {...floatingProps}, and fixes the imports.

Crucially, it does not silently drop what has no native equivalent. shift, autoPlacement, inline, and arrow ref-wiring get a // TODO(css-anchor-kit) comment left in place, so you can finish those by hand:

grep -rn "TODO(css-anchor-kit)" src
Enter fullscreen mode Exit fullscreen mode

The codemod is a dev-time CLI only — it's never imported by the library, so it has zero effect on your runtime bundle.

Honest limitations

CSS Anchor Positioning is discrete, not continuous. The platform's fallback model is "try position A, then B, then C" — not "slide pixel by pixel." That means:

  • shift — continuously sliding a popover to keep it in view — has no native equivalent. flip covers the common overflow case; if you genuinely need continuous shifting, floating-ui is still the right tool.
  • autoPlacement isn't mapped; pick a placement and let flip handle overflow.

Everything else the 90% tooltip/popover/menu case actually uses — placement, offset, flip, hide, size, arrows, RTL/logical alignment — is covered natively, with no JS in the scroll path.

Verified in Chromium 148: all 12 placements position correctly (right side, ~8px gap, logical -start/-end alignment), size matches the anchor, and flip kicks in on overflow.

Install

npm i css-anchor-kit
Enter fullscreen mode Exit fullscreen mode

React 18+ is an optional peer dependency — you only need it for the hook. For older browsers, detect support with the supported flag and lazy-load the @oddbird/css-anchor-positioning polyfill yourself — it's BYO and not bundled, so supporting browsers ship nothing extra.

const { supported } = useAnchor()
useEffect(() => {
  if (!supported) import('@oddbird/css-anchor-positioning/fn').then((m) => m.default())
}, [supported])
Enter fullscreen mode Exit fullscreen mode

Repo: https://github.com/mk668a/css-anchor-kit · MIT.

The pitch is small enough to fit in a sentence: the thing you were shipping 10 KB of JavaScript for is a CSS feature now. Keep the API, drop the runtime. If you try the codemod on a real floating-ui app and it leaves a TODO you think should have a native mapping, open an issue — those edges are exactly where the discrete-vs-continuous line gets interesting.

Top comments (0)