#7416·ariakit

Replace Floating UI in Popover with CSS anchor positioning

Author: diegohazCreated Sep 5, 2026Updated Sep 5, 2026
Labelsfeaturev0.5

Popover positions itself with Floating UI today. This replaces that with CSS anchor positioning: the application writes every positioning declaration, and Ariakit's only remaining job is to link the popup to its anchor in a way that survives server rendering.

This is a breaking change, shipped as a single flip. Today's behaviour moves to a pinned entry point that keeps its current API.

Every decision below is settled. The contract is complete enough to implement from; the open work is the measurement gates in "Risks", not the design.

Base for all file references: cede8bc07. Browser results are from Chromium 151.0.7922.34, Firefox 153.0 and WebKit 26.5.

Goals

  • Stop paying for Floating UI by default. The measured prize is 18,162 bytes raw and 6,981 gzipped from an ordinary popover import graph. Only a real module split reaches it: a runtime flag cannot remove a static import, and a disabled positioner with the import intact measured eight bytes larger than re-exporting everything.
  • Let the application position the popup in CSS. Not possible in principle, which it already is, but possible against an Ariakit component, which today it is not. Ariakit emits no anchor link at all, and its transformed wrapper defeats anchoring from outside.
  • Position a server-rendered open popover before hydration, within the configuration set in "What fails silently".
  • Delete the wrapper element and the z-index synchronisation that exists only to manage it.

Non-goals

  • Feature parity. Four capabilities have no CSS successor and stay on the pinned entry permanently.
  • Shipping CSS. Ariakit publishes examples, not a stylesheet. That makes the examples part of the API rather than documentation.
  • Runtime diagnostics. No warnings anywhere. The published examples are the only mitigation for every silent failure listed below.
  • Working everywhere. Progressive enhancement is accepted. Below the support floor the examples centre the popup like a dialog, and that CSS is written by the application.
  • Keeping the twelve positioning props. They do not exist on the new entry, and passing one is a type error.

The contract

What Ariakit emits

On each anchor candidate:

anchor-name: --popover-<ownId>, --popover-<storeId>

On the popup:

position-anchor: --popover-<storeId>     /* switches to --popover-<ownId> at hydration */
--popover-position-area: <value>

Nothing else. Ariakit never emits position, inset, top, left, margin, position-area itself, anchor(), anchor-size(), justify-self, position-try-fallbacks, position-visibility, anchor-scope, z-index, any wrapper element, or any marker attribute.

The pinned entry emits none of it. Its content sits inside a transformed wrapper which is a containing block, so an anchor link would be measurably inert there.

Naming follows what the repository already does. The component packages write five custom properties and all five are --popover-*; --ak-* appears in no component package and belongs to @ariakit/tailwind. The library writes fourteen distinct data-* attributes and prefixes none of them.

An anchor name and a custom property occupy different namespaces in CSS despite looking alike, so --popover-_R_2_ as an anchor name cannot collide with --popover-position-area as a property.

Public surface of the new entry

Member Shape Notes
positionArea store option, string A position-area value. Defaults per family. Parsed for keyboard direction where the subset allows.
anchorName store option, string Overrides the generated name. Compatible with the shape proposed in #3311 if that lands.
PopoverArrow component A plain element. No JavaScript, no SVG mask, no border inference.
store.popoverElement always null Retained so existing readers keep working. Removable once the pinned entry is the only wrapper consumer.
Twelve positioning props absent Type error. No runtime warning.
unstable_placing, data-placing deleted Including the store key.
currentPlacement absent No portable readback of the used side exists.

placement is renamed to positionArea. It is a store option, never a component prop, which is what it already is today: all 24 placement= call sites in the repository are on a provider or a store pass-through.

Defaults per family

Family Today New default
Popover, Hovercard bottom block-end span-all
Tooltip top block-start span-all
Menu, Select, Combobox bottom-start block-end span-inline-end
Submenu derived from the parent derived, unchanged

span-all rather than center is what makes these faithful. The centre keyword looks like the natural reading of bottom and is the one value that breaks flipping.

Server rendering

Each anchor candidate carries two names, using the list form that anchor-name allows and position-anchor does not.

Server HTML has the popup reference the shared store name. That needs no knowledge of siblings and no election, which matters because the election cannot be resolved during render: React gives a component no way to know what its siblings will render, and the real election happens on a click.

At hydration the store knows which candidate won, and the popup switches to that candidate's own name. Measured in all three engines: the shared reference binds to the last candidate in document order, an individual reference binds exactly to that candidate, and switching between them in a single task lands correctly with no unanchored frame in between.

With one candidate, the ordinary case, nothing can go wrong. With several, the pre-hydration frame can point at the wrong trigger and corrects itself when hydration commits.

React 19.2 identifiers (_R_0_ on the server, _r_0_ on the client) are already legal CSS names. React 18 identifiers (:r0:) are not, and need an inlined identifier encoder of about twenty lines. Never CSS.escape, which does not exist in Node or in server components.

Release

One breaking flip in 0.5.

The pinned entry keeps today's API, its twelve props under their existing names, and its wrapper. It is composed on the new hook rather than forked, at the hook level, which is the repository's house style and how all six existing popup families already compose. The pinned hook reintroduces the focus gate for itself by computing autoFocusOnShow and passing it down as an ordinary option.

The pinned entry gets no removal date. Four capabilities have no CSS successor: positioning at a pointer, caret or text selection, because an anchor must be an element or a fully styleable pseudo-element and a Range is neither; the arrow's border-width and border-colour inference; viewport fitting; and overflow padding.

The value vocabulary

Values are position-area syntax. Ariakit accepts every valid value and passes it to CSS untouched, and parses only the logical and generic branches to recover a keyboard direction.

Two things about the vocabulary are worth knowing. The CSS Working Group has said this syntax is confusing to read and reason about (w3c/csswg-drafts#9862, raised January 2024, closed as accepted by resolution), citing interchangeable axis ordering, spanning syntax that overlaps axis syntax, keywords that behave differently per axis, and unclear defaults for single values. And the property was called inset-area until Chrome 129, with the old name working only through Chrome 131.

Rules the parser depends on, each measured in all three engines

  • In the generic two-value form the first value binds to the block axis. start end lands above the anchor; end start lands below it.
  • Order otherwise does not matter. block-end span-inline-end and its reverse both compute to end span-end.
  • The computed value collapses logical to generic, turning block-end into end. This is why no readback can tell the block axis from the inline one, and why currentPlacement cannot be restored.
  • Branches cannot be mixed. bottom span-inline-end and self-inline-end block-start are both invalid everywhere.
  • An invalid value drops the whole declaration, so the popup renders at its static position, which is indistinguishable from a broken link.
  • Several valid values name no direction. center, center center, span-all and a bare span-top all place the popup over its own anchor.

The supported subset

Ariakit parses block-start, block-end, inline-start, inline-end, their span- forms, and the generic start and end forms. Anything else reaches CSS untouched and yields no keyboard direction, in which case the store's derived direction is used.

The subset was implemented as a parser and run against real layout in all three engines over sixteen values, with zero disagreements. Every value it claims a direction for lands exactly there. It declines on center, span-all and span-top because they name no side, and on bottom right and self-block-end because bottom is only the block end in a horizontal writing mode, which is not knowable during render.

Right-to-left mirroring is free: logical values mirror in CSS with no second table and no code, so inline-start lands on the right under dir="rtl". Deriving keyboard direction from the store also fixes a live bug, since rtl is read by Composite, Menubar and Toolbar but by none of the four arrow key maps.

What the application writes

css
/* BASE = the unsupported fallback. Never write this inside @supports not (). */
.popover {
  position: fixed;
  inset: 0;
  inline-size: fit-content;
  block-size: fit-content;
  max-inline-size: calc(100% - 2rem);
  max-block-size: calc(100% - 2rem);
  margin: auto;                        /* this is what centres it */
  overflow: auto;
  z-index: 50;                         /* this frame is not in the top layer */
}

/* Named, so the arrow can ask which one applied. A built-in tactic is invisible
   to @container anchored(), which is the whole reason these have names. */
@position-try --popover-flip-block  { position-area: block-start span-inline-end; }
@position-try --popover-flip-inline { position-area: block-end span-inline-start; }

/* ENHANCEMENT. Gate on three properties, not one, because single-property
   gates lie in both directions. */
@supports (anchor-name: --x) and (position-anchor: --x) and (position-area: block-end) {
  .popover {
    position-area: var(--popover-position-area);
    position-try-fallbacks: --popover-flip-block, --popover-flip-inline;
    container-type: anchored;

    inset: auto;                       /* must be inside this block */
    margin: 0;                         /* outside it, this kills the fallback */
    margin-block-start: 0.5rem;        /* was gutter={8} */
    max-inline-size: none;
    max-block-size: none;
  }

  /* Chromium only. Elsewhere the arrow keeps its default orientation. */
  @container anchored(fallback: --popover-flip-block) {
    .arrow { rotate: 180deg; inset-block-start: auto; inset-block-end: -4px; }
  }
}

Three things this stylesheet deliberately does not do, each for a measured reason.

It never uses a cross-axis center, which makes the popup cover its own anchor once a fallback fires. It never mixes physical and logical keywords, because such a value is invalid in all three engines and an invalid value drops the whole declaration. And it does not set position-visibility, whose initial value already auto-hides in two engines and behaves in opposite directions for a clipped anchor versus a scrolled-out one.

The arrow

PopoverArrow becomes a plain element with no JavaScript. It re-orients only where @container anchored(fallback: --name) works.

That form is the only one that works. Querying a side (anchored(block-start), anchored(top)) never matches, and neither does a built-in tactic (anchored(fallback: flip-block)). Only named @position-try rules are queryable, which is why the published CSS names its fallbacks.

container-type: anchored computes to anchored only in Chromium. Firefox 153 and WebKit 26.5 both compute normal, so there the arrow keeps the requested orientation and points the wrong way after a flip, rather than merely being absent.

The border-width and border-colour inference at popover-arrow.tsx:175,194 has no CSS equivalent under any option and is not replaced.

Worked examples

Before

typescript
import * as Ariakit from "@ariakit/react";

<Ariakit.SelectPopover gutter={4} sameWidth className="popover">

After

typescript
import * as Ariakit from "@ariakit/react";

<Ariakit.SelectPopover className="popover">
css
.popover {
  margin-block-start: 0.25rem;       /* was gutter={4} */
  inline-size: anchor-size(width);   /* was sameWidth */
  max-block-size: min(100%, 20rem);  /* was --popover-available-height */
}

A server-rendered open popover

typescript
<Ariakit.PopoverProvider defaultOpen positionArea="block-end span-inline-end">
  <Ariakit.PopoverAnchor className="anchor">Anchor</Ariakit.PopoverAnchor>
  <Ariakit.Popover className="popover">
    <Ariakit.PopoverArrow className="arrow" />
    Content
  </Ariakit.Popover>
</Ariakit.PopoverProvider>

Server HTML under React 19.2, where no escaping is needed:

xml
<div class="anchor" style="anchor-name:--popover-_R_3_, --popover-_R_2_">Anchor</div>

<div id="_R_2_" class="popover" role="dialog" tabindex="-1" data-open="true"
     style="position-anchor:--popover-_R_2_;
            --popover-position-area:block-end span-inline-end">
  <div class="arrow"></div>
  Content
</div>

At first paint with no JavaScript the enhancement branch matches and the popup lands below the anchor. In an engine without anchor positioning the base rule centres it. Both were measured in all three engines, with the JavaScript-disabled screenshots byte-identical to the JavaScript-enabled ones. At hydration nothing else changes, because the client renders the same values, so no mismatch warning is possible.

What stays on the pinned entry

typescript
import * as Ariakit from "@ariakit/react/v0";   // was "@ariakit/react"

<Ariakit.Popover
  getAnchorRect={() => {
    const selection = ref.current?.ownerDocument.getSelection();
    if (!selection?.rangeCount) return null;
    return selection.getRangeAt(0).getBoundingClientRect();
  }}
>

Only the import changes. The same holds for the caret example in examples/combobox-textarea and the pointer-anchored context menu in app/src/sandbox/menu-context-interactions.

What fails silently

The contract ships no runtime diagnostics. Inert props are a type error only, so anyone on plain JavaScript sees nothing, and there is no support or hazard detection.

One class is closed by construction: because Ariakit emits the link directly rather than through a variable the application must consume, "nothing linked at all" cannot happen, so the first placement rule an application writes takes effect.

These remain silent, visible on screen and absent from the console:

  • Ancestor properties between anchor and popup. transform, filter, backdrop-filter, contain with layout, paint, strict or content, will-change of any of those, perspective, and content-visibility: auto or hidden. Any of them becomes the containing block and the anchor outside it stops resolving. Ariakit's own example stylesheet animates popups, so a popup nested inside an animated popup is the realistic instance, measured landing at the wrapper origin instead of at its anchor in all three engines.
  • A shadow boundary between anchor and popup, in either direction, with no CSS workaround.
  • An invalid positionArea value, which drops the declaration entirely.
  • The application never writing the rule that consumes the variable, leaving the popup anchored and unplaced.
  • A broken link in a capable browser. There is no CSS mechanism that detects this. @supports tests the browser, and position-visibility: anchors-valid hides the popup rather than repositioning it.

For a popover to be positioned before hydration, all of these must hold: not portalled and not modal; defaultOpen; React 18 or newer; the anchor CSS shipped render-blocking; none of the ancestor properties above between anchor and popup; no shadow boundary; the anchor generates a box; and the anchor is an explicit PopoverAnchor or the only disclosure.

Rejected alternatives

Alternative Reason
<Popover render={<PopoverPositioner />}>, the original proposal Three verified mechanics. createElement clones the render element after the hook returns, so the hook cannot know a positioning pass is coming; usePortal renders a placeholder instead of children while the portal node is null, giving the positioner a shorter lifetime than its popover; and mergeProps merges both into one bag, so the positioner's own class lands on the popup.
A positioner prop of any shape Withdrawn with the premise that justified it. A runtime flag also cannot reach the bundle goal.
Ariakit emitting only custom properties, with the application writing the link Keeps the "no CSS" premise exactly, and fails silently and totally: omit the two rules and no anchor name exists anywhere, so every later positioning rule quietly does nothing.
The HTML anchor attribute Does not exist in any engine, including Chromium 151 with the Popover API.
Native popover and the top layer by default Immune to all 234 measured containing-block hazard cases, and it abandons server rendering outright: the top layer cannot be entered from server HTML.
A ladder release, minting both entry points before flipping Considered and declined in favour of a single flip.
Keeping today's twelve placement names Declined in favour of CSS syntax. The cost is a real parser instead of a lookup.
A development-time hazard checker Declined. The contract ships with no diagnostics.
A marker attribute such as data-popover-anchor Introduced to make a broken link take the fallback branch, which it cannot do: the popup cannot know during render whether any element carries the anchor name. What remained was keeping application rules off pinned popovers, which the pinned wrapper already does. Ariakit ships no CSS, so every application supplies its own selector regardless, and role="dialog" is already present.
ak-popover-position-* in the app styles as a starting point It cannot position any Ariakit component: no reference to any anchor, and it relies on an implicit anchor Ariakit never establishes. Its centre cross-axis value also disables its own fallback list. It should be fixed or deleted independently.

Risks and gates

A single breaking flip means these are answered in production unless they are answered first.

Gate Question What it decides
G0 Does an identity transform such as scale: 1 establish a containing block? Whether animated popups are a documentation hazard or a permanent capability gap. Ariakit's own example CSS animates popups, so this is not hypothetical. Cheapest item here.
G1 Does a real server render and hydrate cycle produce a jump-free upgrade, including the name switch? Whether pre-hydration positioning can be called supported. Only synthetic attribute toggling has been done.
G2 Do the placing-pass and composite-presentation suites pass with the gate deleted and the pinned hook restoring it? The hook-level layering decision. Both suites already exist and have never been run against this shape.
G3 What happens to an open popup when its anchor unmounts or is virtualised? Whether the centred fallback is safe. The popup jumps to screen centre; Floating UI keeps the last position.
G4 Why was