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>
</>
)
}
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
})
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>. -
sizematches the trigger.size: 'width'emitsanchor-size(width)— exactly what you want for a combobox/select popover that should be as wide as its button. -
RTL is free.
-start/-endplacements pin logical edges (inset-inline-*), sobottom-startaligns 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>
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)
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
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
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.flipcovers the common overflow case; if you genuinely need continuous shifting, floating-ui is still the right tool. -
autoPlacementisn't mapped; pick aplacementand letfliphandle 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/-endalignment),sizematches the anchor, andflipkicks in on overflow.
Install
npm i css-anchor-kit
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])
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)