Ariakit (TypeScript / React)
A headless React component library whose entire overlay family — disclosure, dialog, popover, hovercard, tooltip, menu, select, combobox — is one single-inheritance chain of stores and prop-transformer hooks, with positioning delegated wholesale to Floating UI and hover intent implemented in-house as a 96-line polygon test.
| Field | Value |
|---|---|
| Language | TypeScript (React; a framework-free store core) |
| License | MIT |
| Repository | ariakit/ariakit |
| Documentation | ariakit.com — plus in-repo readme.md files per example |
| Category | Web / headless behavior |
| Surface model | in-canvas: a React portal into a div appended to document.body. No OS popup, no top layer, no showPopover, no HTMLDialogElement |
| Packages read | @ariakit/react 0.4.37, @ariakit/react-components 0.4.1, @ariakit/components 0.1.10, @ariakit/store 0.1.8 |
| Revision read | a0426ed547d95b84c9d53033053e51baeaca4aaa |
NOTE
This is an implementation reading of the source tree at that SHA, not a docs summary. Where a statement comes from a doc comment rather than from executable code it is marked as such. The positioning solver itself is not in this tree: @floating-ui/dom is a pinned dependency (1.8.0) whose source was not read here, so every statement below about flip/shift/size/arrow internals is a statement about Ariakit's use of those middlewares. The solver is covered in its own deep-dive, ./floating-ui.md.
Terms of art used throughout — anchor rect, placement, gravity, constraint adjustment, flip/shift/slide/resize, clipping boundary, top layer, light dismiss, grab, safe polygon, warm-up, cool-down, focus scope, modality, virtual anchor, transform origin — are defined in the shared vocabulary page.
Overview
What it solves
Ariakit's overlay stack is not a set of siblings that happen to share utilities; it is one inheritance chain. createDisclosureStore → createDialogStore → createPopoverStore → createHovercardStore → createTooltipStore, with createMenuStore mixing hovercard with composite, and select / combobox / composite-overflow mixing popover with composite. createDialogStore is a literal alias — it forwards to createDisclosureStore and adds nothing (dialog-store.ts:12).
The consequences are structural, not cosmetic. A Menu submenu gets hover intent because useMenu calls useHovercard (menu.tsx:190). A Hovercard gets outside-dismissal, focus restoration and modality because useHovercard calls usePopover which calls useDialog. A Tooltip is roughly forty lines of overrides on Hovercard (tooltip.tsx:69). Divergence between surface kinds is expressed almost entirely as changed defaults plus one or two narrowed predicates, which makes the differences between a tooltip, a hovercard, a popover and a menu unusually legible.
Design philosophy
Two layers. @ariakit/store is a framework-free, key-granular observable object: getState() / setState(key, value), three subscription timings, parent/child composition with a bounded repair loop. @ariakit/react-components holds every DOM behavior in createHook prop transformers that compose bottom-up. The store layer nonetheless holds HTMLElement references in state — which is what makes anchor / disclosure / content relationships declarative across components, and simultaneously what stops the anchor from ever being a plain comparable value.
The most transferable idea in the subject is that "not yet positioned" is state, not an implementation detail. PopoverStoreState.unstable_placing is documented at popover-store.ts:147-159:
Whether the popover is showing and hasn't settled at the position its current positioning pass will leave it at. Every pass a commit starts asserts it, not just the one that follows the popover being shown […] Components that move focus or scroll into the popup wait for this to become
false, otherwise they act on an element that's still at its pre-placement origin, or at a position it's about to leave, and drag the page along with it.
The second philosophy statement is about hover intent. The whole geometry is one integer-safe cross product per polygon edge — no trigonometry, no velocity, no trajectory extrapolation (polygon.ts:23):
const where = (yi - yj) * (x - xi) - (xi - xj) * (y - yi);And the third is about what the transit corridor is for. It does not merely defer hiding (hovercard.tsx:82-83):
These events can trigger focus on other elements and close the hovercard while the mouse is still moving toward it.
How it works
Three packages carry the overlay stack, and the split is enforced:
@ariakit/store framework-free observable object; no DOM types
@ariakit/components typed store factories; state holds HTMLElement refs
@ariakit/react-components createHook prop transformers; every DOM behaviorThe hook chain mirrors the store chain and composes bottom-up, each level wrapping the previous level's props:
useTooltip -> useHovercard -> usePopover -> useDialog -> useDisclosureContent
-> useFocusable
-> usePortalOne popover renders two elements. The outer wrapper is the positioner (position: absolute|fixed, top/left: 0, width: max-content) and carries the transform; the inner content element is what the author styles and animates. That split is semi-public: wrapperProps is documented API, so authors can animate the content without fighting the positioner.
A positioning pass, as written in popover.tsx:333 and the surrounding helpers:
assert unstable_placing = true (only while mounted)
build virtual anchor { contextElement, getBoundingClientRect }
autoUpdate(anchor, popoverElement, update, { elementResize })
update() = custom updatePosition ?? default
default:
pos = await computePosition(anchor, wrapper, { placement, strategy, middleware })
if (canceled || !popoverElement.isConnected) return // write nothing
setState("currentPlacement", pos.placement)
wrapper.style.transform = translate3d(roundByDPR(x)px, roundByDPR(y)px, 0)
position the arrow; write --popover-transform-origin
finally setState("unstable_placing", false)roundByDPR (popover.tsx:112) snaps x and y to the device-pixel grid — Math.round(value * devicePixelRatio) / devicePixelRatio — which is the closest thing in this subject to integer-cell placement.
The analysis spine
1. Anchor model
The store holds three distinct element slots and keeps them distinct on purpose: anchorElement (what the overlay is positioned against), disclosureElement (what opened it, and where focus returns), popoverElement (the positioned wrapper) (popover-store.ts:58).
Algorithm. An anchor is the pair (HTMLElement | null, getAnchorRect?) lifted into a Floating UI virtual anchor — { contextElement, getBoundingClientRect } (popover.tsx:88). The two slots are reconciled by a mirroring rule rather than by identity, driven by a sync over both keys (popover-store.ts:74-83) against a private syncedAnchorElement (popover-store.ts:69):
on change of { anchorElement, disclosureElement }:
if anchorElement && anchorElement !== syncedAnchorElement:
syncedAnchorElement = null # stop mirroring, explicit anchor wins
else:
syncedAnchorElement = disclosureElement
anchorElement := disclosureElementSix edge cases of that rule are pinned in popover-store.test.ts, including "preserves an explicit anchor matching a previous fallback" and re-initialisation reseeding the fallback — a ready-made conformance suite for anyone porting the rule.
Many-triggers-one-popup is handled separately, in the shared trigger's ref callback: it refuses to reassign anchorElement while the current one is still isConnected, so adding a second anchor to the DOM does not steal the anchor (__hovercard-trigger.tsx:107-118, with the guard on :112). Detached trigger-versus-anchor is first class — MenuAnchor / SelectAnchor / ComboboxAnchor exist as separate components, and Menu positions against anchorElement while restoring focus to disclosureElement || anchorElement.
Point, cursor and text-range anchors all go through one caller-supplied getAnchorRect returning {x, y, width, height}: the context-menu example returns the contextmenu event's clientX/clientY (examples/menu-context-menu), the selection example returns range.getBoundingClientRect() (examples/popover-selection). Multi-rect text ranges are not supported — a wrapped range collapses to a single bounding rect. Moving anchors are handled by Floating UI's autoUpdate plus an explicit store.render() escape hatch that flips a rendered: symbol state to force a fresh pass (popover-store.ts:91).
There is no anchor-to-screen conversion: everything is viewport coordinates in one document, and cross-document anchoring is refused outright — a target in another document is unconditionally outside (use-hide-on-interact-outside.ts:126).
Where it lives. Identity in the store layer (@ariakit/components); rect production in the React layer; consumption in @floating-ui/dom.
Degradation. No OS window changes nothing — there is none. With no script the anchor cannot be measured at all. With no sub-cell precision the rect becomes integral, which roundByDPR half-anticipates already. No key release is irrelevant here. The fragile part is the anchor/disclosure mirror, which is driven by mount-order effects: with no hover, the hovercard trigger's setAnchorElement path never runs and the anchor must be set programmatically.
IMPORTANT
The anchor here cannot be compared, cached or serialised — it is an element identity plus a getBoundingClientRect closure, deliberately late-bound so re-measurement is always fresh. The reconciliation rule, however, is value-shaped and would port unchanged over an opaque comparable id. That contrast is the single most useful thing this dimension offers a toolkit whose widgets already have ids (see ./sparkles-baseline.md).
2. Placement model
Ariakit's own placement vocabulary is deliberately small: twelve physical placements (top|bottom|left|right, each with optional -start/-end), declared as a TypeScript union at popover-store.ts:19-24 and re-validated at runtime by a regex, isValidPlacement (popover.tsx:107).
There is no logical (block/inline) placement, no writing-mode awareness and no RTL handling in Ariakit itself; whatever -start/-end mean under dir=rtl is Floating UI's answer. (composite-store does carry an rtl state, but it governs arrow-key direction inversion, not placement.)
Per-component defaults are the real behavioural signal: popover bottom, hovercard bottom (hovercard-store.ts:25), tooltip top (tooltip-store.ts:34), menu bottom-start, submenu derived from the parent's orientation by a store-level sync — vertical parent → right-start, horizontal → bottom-start (menu-store.ts:110-117).
Algorithm. The middleware array is assembled per render:
offset(({placement}) => {
arrowOffset = arrowElement.clientHeight / 2
mainAxis = typeof gutter === "number" ? gutter + arrowOffset : gutter ?? arrowOffset
hasAlignment = !!placement.split("-")[1]
return { crossAxis: hasAlignment ? undefined : shift, mainAxis, alignmentAxis: shift }
})
flip({ padding: overflowPadding, fallbackPlacements: flip.split(" ") })
shift({ mainAxis: slide, crossAxis: overlap, padding, limiter: limitShift() })
arrow({ element, padding: arrowPadding = 4 })
size({ padding, apply: write CSS vars + optional sameWidth / fitViewport })Note the crossAxis/alignmentAxis duality at popover.tsx:122-144: the same shift number is applied as a cross-axis offset for a centred placement and as an alignment-axis offset for an aligned one. slide (main-axis shift) defaults true, overlap (cross-axis shift, letting the popup cover its anchor) defaults false, and if both are false the shift middleware is omitted entirely (popover.tsx:165).
Fallback ordering is exposed as a space-delimited string — flip="top bottom" — rather than an array, with a development-time validity check on each entry. overflowPadding accepts a number or a per-side object; the object goes straight to Floating UI, but the exported CSS variable --popover-overflow-padding collapses it to max(left, right) with omitted sides treated as 0 (popover.tsx:117-120).
Viewport insets, safe areas, work areas and multi-monitor geometry are absent: the clipping boundary is whatever Floating UI's default clipping-ancestor detection finds. Virtual-keyboard avoidance exists only for Dialog, and only as data: an effect writes --dialog-viewport-height from window.visualViewport.height and rewrites it on visualViewport resize, leaving the actual avoidance to author CSS (dialog.tsx:343).
Degradation. No script means no placement at all — the wrapper stays at translate3d(0,0,0) at its offset parent's top-left. On a cell grid the offset arithmetic is integer-friendly except arrowElement.clientHeight / 2, which needs an odd/even rule. The soft-keyboard case is the notable inversion: Ariakit discovers the inset from visualViewport rather than accepting it as a placement input.
3. Collision & geometry engine
Ariakit owns none of the collision math and all of the lifecycle around it. Overflow detection, clipping-ancestor discovery, scroll containers and transform/zoom handling belong to @floating-ui/dom. What Ariakit contributes is five things.
autoUpdatewith a guardedelementResize—typeof ResizeObserver === "function"gates the option, so JSDOM / happy-dom degrade to scroll + resize listeners only (popover.tsx:503).- DPR snapping —
roundByDPRbefore writingtranslate3d(popover.tsx:112). - A cancellation protocol.
computePositionis async andautoUpdate's cleanup does not abort an in-flight call, so each effect run owns acanceledflag;shouldCancelUpdate()additionally re-checkspopoverElement.isConnected(popover.tsx:378), andshouldCancelis injected into thesizemiddleware'sapply()so a stale run cannot mutatewidth/maxWidth(popover.tsx:181-198). - An explicit skip when the popover is unmounted-while-hidden and no custom
updatePositionis supplied, so a closed-but-connected popover keeps no observers alive. - Geometry exported as data — the
sizemiddleware writes--popover-anchor-width(Math.round) and--popover-available-width/--popover-available-height(Math.floor) as CSS custom properties. Measured geometry is deliberately handed to the styling layer as integers.
Algorithm. The pass is the pseudo-code in How it works. Its subtlety is the writer accounting: a separate layout effect keeps a per-store WeakMap<Store, number> count of how many mounted popovers assert the placing bit, and defers the final reset by a queueMicrotask, so StrictMode replay, keyed remounts and store swaps can neither strand the bit nor clear it early (popover.tsx:46, popover.tsx:568).
Where it lives. Solver and observers in @floating-ui/dom; lifecycle, cancellation, DPR snapping and CSS variable export in popover.tsx. Nothing in the framework-free store layer.
Degradation. The portable parts are the cancellation protocol (an in-flight async pass must be invalidated by identity, not by cleanup), the placed/unplaced bit, and publishing resolved geometry as data. The non-portable parts are clipping-ancestor discovery, transform/zoom compensation and ResizeObserver. In a single-surface integer-cell toolkit with synchronous layout, collision reduces to a pure function of (anchorRect, popupSize, surfaceRect, padding) and the whole observer/rAF apparatus disappears. With no script there is no measurement, so no collision detection at emit time.
4. Arrow / caret geometry
Arrow geometry is data in exactly two places and CSS everywhere else. The store holds arrowElement; the positioning pass reads middlewareData.arrow.{x,y} and writes three things (popover.tsx:444-473):
arrow.style.left/arrow.style.top— the offset along the popup's edge;arrow.style[side] = "100%"— detachment to the outside of the popup, withrightandbottomexplicitly cleared first, because a stale RTLrightwould override the newleftand visibly detach the arrow;--popover-transform-originon the popover element — the arrow's centre projected onto the popup box.
Algorithm. Transform origin is a per-side lookup over the resolved side:
side = currentPlacement.split("-")[0]
centerX = arrow.clientWidth / 2
centerY = arrow.clientHeight / 2
originX = arrowX == null ? -centerX : arrowX + centerX # likewise originY
top: `${originX}px calc(100% + ${centerY}px)`
bottom: `${originX}px ${-centerY}px`
left: `calc(100% + ${centerX}px) ${originY}px`
right: `${-centerX}px ${originY}px`The arrow's size feeds the gutter: getOffsetMiddleware adds arrowElement.clientHeight / 2 to it — and does so even when the author rendered no arrow, because usePopover lazily creates a detached div purely so the arrow middleware and the gutter arithmetic always have something to measure (popover.tsx:388). Corner constraint is arrowPadding (default 4), passed to Floating UI's arrow middleware. Arrow hiding is not implemented — there is no hide({strategy: 'referenceHidden'}) middleware anywhere in the tree, and nothing reacts to the anchor being scrolled out of view.
Border-aware arrows are unusually elaborate and live entirely in a component, PopoverArrow: it reads getComputedStyle of the content element and infers stroke width and colour either from a Tailwind-style ring (the first box-shadow segment with zero offsets and positive spread, located by masking parenthesised colour functions so the commas inside rgb(59, 130, 246) do not split segments, popover-arrow.tsx:56) or from border-<side>-width / border-<side>-color; the stroke is then scaled as borderWidth * 2 * (30 / size) against a fixed 30-unit SVG viewBox, and a four-path SVG with a mask emulates a border that joins the popup's own.
Where it lives. Offset and clamping in Floating UI's arrow middleware; the projection to transform origin and the style writes in popover.tsx; all visuals in popover-arrow.tsx.
Degradation. In whole cells an arrow is one character: the geometry collapses from (x, y, centre, origin) to a single integer offset along the popup's edge plus a side, and the natural rendering is a box-drawing joint glyph replacing one border cell. Ariakit's own split is directly reusable — side plus integer offset is the data; everything in popover-arrow.tsx is presentation. The ring/border colour inference has no analogue without sub-cell edges. Static HTML keeps the arrow (it is pure CSS) but cannot compute its offset, so only a centred arrow is honest there.
5. Trigger semantics
Triggers are separate composable hooks, and the composition order is itself the race-avoidance mechanism. useHovercardTrigger (the shared internal, filename-prefixed __ to mark it private) implements hover-open; usePopoverDisclosure / useDialogDisclosure implement click-toggle; useMenuButton composes both plus composite typeahead.
Ariakit does not listen to mouseenter for opening. It listens to onMouseMove and gates on a globalisMouseMoving() predicate (__hovercard-trigger.tsx:57-63), so a mouseover synthesised by scrolling, by a tap, or by the page moving under a stationary cursor cannot open a hovercard. That predicate is one module-global boolean, set by a capture-phase document mousemove listener that requires non-zero movementX/movementY, and reset by mousedown, mouseup, keydown and scroll (hooks.ts:399-442). It is re-validated: showHovercard() calls isMouseMoving() a second time before store.show() (__hovercard-trigger.tsx:74), so a pointer that stopped moving during the delay still opens while a tap that produced one synthetic move does not. Pointer-type distinction is therefore implicit — movement-based — rather than read from PointerEvent.pointerType.
A zero delay is a distinct code path, not setTimeout(0) (__hovercard-trigger.tsx:88-93):
const timeoutMs = showTimeout ?? timeout;
if (timeoutMs === 0) { showHovercard(); } else { setTimeout(showHovercard, timeoutMs); }Menubar submenus set timeout: 0 (menu-store.ts:81-85) and rely on that synchronous branch so the pointer cannot outrun the popup.
Algorithm. Multiple triggers are combined by prop-chain composition plus per-trigger latches, never by a priority table. Each hook wraps the previous handler (onMouseMoveProp?.(event); if (event.defaultPrevented) return; …) and owns one ref — showTimeoutRef on the hovercard trigger, canShowOnHoverRef on the tooltip anchor. Cross-trigger races are broken by three latches:
- a non-zero
showTimeoutRef.currentmakesonMouseMoveidempotent; - a native capture-phase
mouseleavelistener clears it, deliberately bypassing React'sonMouseLeavebecause the open hovercard stops propagation of mouse events during transit (__hovercard-trigger.tsx:38-51); onClickclears the pending show timeout, so click-then-hover cannot resurrect it.
Long-press and touch-triggered tooltips are absent. The context menu is not a trigger primitive at all — the documented pattern is the application's own onContextMenu plus getAnchorRect plus menu.show(). Keyboard: MenuButton maps Arrow keys to open-with-initial-focus based on the resolved base placement, so a menu that flipped to top opens on ArrowUp with "last". TooltipAnchor opens on onFocusVisible, not onFocus (tooltip-anchor.tsx:127), so mouse-focus shows no tooltip.
Degradation. With no hover, every hover path is dead and Hovercard becomes unopenable unless HovercardDisclosure is rendered. No key release is irrelevant — every trigger here is keydown, click or focus. With no script, only :hover / :focus-within can trigger anything, which means a static tier-0 emitter cannot reproduce the isMouseMoving guarantee at all. Multiple simultaneous pointers are not modelled; one pointer is assumed throughout.
6. Timing
Three numbers live in the store and nothing else does: timeout (base), showTimeout, hideTimeout, resolved at use time as showTimeout ?? timeout and hideTimeout ?? timeout — read lazily from getState(), not captured at subscription. The defaults encode the component taxonomy precisely:
| Surface | timeout | hideTimeout | Other |
|---|---|---|---|
| Hovercard | 500 | inherits | — |
| Tooltip | inherits | 0 | skipTimeout 300 |
| Menu (submenu) | 150 | 0 | 0 when the parent is a menubar |
A tooltip disappears instantly (tooltip-store.ts:36); a hovercard waits 500 ms to appear (hovercard-store.ts:29); moving along a menubar swaps menus with no delay (menu-store.ts:81-85).
Algorithm. The machine as implemented, in states:
Closed --qualified move on trigger--> Opening(showTimeout)
Opening --native mouseleave | click--> Closed
Opening --timer fires && isMouseMoving()--> Open
Open --move outside {card, anchor, disclosure, nested} Closing(hideTimeout)
&& not in transit polygon && hideOnHoverOutside
Closing --any qualifying move back inside--> Open
Closing --timer fires--> ClosedOverlaid on that, for tooltips only, a page-global singleton implements warm-up and cool-down: skipTimeout (default 300, tooltip-store.ts:42) is the window during which any other tooltip opens instantly. The singleton is a module-level createStore<{activeStore: TooltipStore | null}> (tooltip-anchor.tsx:23-27) — its own comment says it exists "so we can show other tooltips without a delay when there's already an active tooltip". On mount, if a different store is active, hide it and become active; on close, schedule removal after skipTimeout and cancel if it re-opens (tooltip-anchor.tsx:83-116).
Two re-entrancy guards make that safe. A hidingStores WeakSet marks a store between hide() and a queueMicrotask cleanup, so a controlled open prop that forces it back open cannot start a hide/show loop (tooltip-anchor.tsx:39-46); and onBlur clears activeStore, so clicking a menu button does not leave the next tooltip in instant mode. There is no max display duration.
Degradation. With no timers (static HTML) the entire dimension vanishes: :hover gives an instantaneous, uncancellable open and close, which is precisely the accidental-tooltip problem the delays exist to solve. With no hover, show/hide delays are meaningless; only the skip-window grouping would survive if triggers became taps. Everything here is integer milliseconds over a monotonic clock, so it ports verbatim to a target that can advance a virtual clock — which is how these behaviours become assertable without a tty.
7. Interactive hover
This is Ariakit's most distinctive code, and it is an independent implementation rather than a fork of Floating UI's safe polygon. Four differences are worth naming.
(a) The polygon is built from the hovercard's own rect plus one enter point. The anchor rect is not part of it, and there is no blocking rectangle and no buffer parameter. getElementPolygon (polygon.ts:70-96) classifies the enter point against the rect as (x ∈ {left, right, null}, y ∈ {top, bottom, null}) via getEnterPointPlacement (polygon.ts:62) and emits a fan of four to six vertices:
if x is set: [enterPoint,
(near-side top corner unless y === "top"),
(far-side top corner),
(far-side bottom corner),
(near-side bottom corner unless y === "bottom")]
elif y === "top": [enterPoint, TL, BL, BR, TR]
else: [enterPoint, BL, TL, TR, BR](b) The enter point is refreshed as the pointer advances. refreshEnterPoint: true on the mousemove path (hovercard.tsx:251-257) rewrites enterPointRef to the current point on every successful in-corridor move, so the corridor narrows monotonically toward the card and a pointer that stalls then reverses falls out immediately.
(c) Inside the polygon, mouse events are suppressed globally. disablePointerEventsOnApproach (defaulting to !!hideOnHoverOutside) installs capture-phase handlers for mouseenter, mouseover, mouseout and mouseleave that call preventDefault() and stopPropagation() while the pointer is in transit (hovercard.tsx:286-307) — because those events would otherwise focus intervening elements and close the card. This is a substitute for a pointer grab that the library does not have.
(d) The point-in-polygon test carries an explicit third-vertex lookback. isPointInPolygon (polygon.ts:9-60) is a crossing-count ray cast; when the ray's ordinate equals the shared vertex ordinate it consults polygon[j === 0 ? l - 1 : j - 1] (vy) and toggles only if y > vy, so a horizontal ray grazing a local extremum does not flip the result. Its test states the reason directly (polygon.test.ts:70-71):
The apex
[3, 0]is a local extremum, so the horizontal ray grazing it must not be counted as a crossing. Without the vy guard this point would toggle.
There are also explicit on-edge and on-horizontal-edge early returns (a point on the boundary counts as inside) and a null-vertex guard returning false for a malformed polygon.
Nested surfaces. Each Hovercard registers itself on the nearest ancestor Hovercard through NestedHovercardContext, in a layout effect specifically so no mousemove is lost between mount and registration (hovercard.tsx:335-351 — the comment names the failing case: "a submenu that's overlapping its menu button and we keep moving the mouse while the submenu is due to open"). Registration also clears the parent's pending hide timer and recurses, so a grandparent sees the grandchild (hovercard.tsx:353-367). Non-portal children need no registration because composedPath() already contains them.
There is no menu-aim heuristic beyond this, and no velocity or trajectory extrapolation anywhere. Submenus get exactly this algorithm because Menu calls useHovercard.
Cost. O(V) per pointer move with V = 5 or 6 — five or six integer multiply-subtract pairs, no allocation. The nested check is an O(N) path.includes scan over the composed path per open card; in a toolkit with an owned overlay tree it would be an O(depth) walk instead.
Degradation. In whole cells the geometry is exact and cheaper: all coordinates are integers and there is no DPR to worry about, and the vertex-grazing guard becomes more important rather than less, because on a lattice a horizontal ray hits a vertex ordinate constantly. What does not survive is (c): without an event-dispatch capture phase there is no way to swallow events destined for other widgets. The structure suggests a substitute that is strictly cleaner in a toolkit that derives its own hit list — while a corridor is live, veto hover targets that are not the card, the anchor, or a registered descendant — but that is an inference about the port, not a mechanism Ariakit implements. With no hover (or no script) the whole dimension is dead. Everything here is a pure function of (enterPoint, cardRect, currentPoint), so it is fully assertable on a recording target.
8. Dismissal
Dismissal is centralised in useDialog and useHideOnInteractOutside, and every overlay inherits it.
Escape. Three listeners cooperate — a React onKeyDown, a React onKeyDownCapture, and a document-level capture + bubble pair — coordinated by a per-component WeakMap<KeyboardEvent, {accepted, defaultPrevented}> so the hideOnEscape predicate runs at most once per physical keypress and the decision is memoised (dialog.tsx:663-669). The memo is invalidated if defaultPrevented flipped after it was taken. Topmost-wins is decided by reading DOM marks left by other dialogs (dialog.tsx:696-698):
Ignore the event if the current dialog is marked by another dialog. This guarantees that only the topmost dialog will close on Escape.
The document capture handler additionally accepts Escape when the target is BODY, inside the dialog, inside the disclosure, or marked outside by this dialog, so Escape works with focus anywhere.
Outside interaction. Three event types, all capture-phase, installed through addGlobalEventListener (which recurses into same-origin child frames): click, focusin, contextmenu (use-hide-on-interact-outside.ts:215, :255, :275). Notably not pointerdown/mousedown — a mousedown ref is captured separately (use-previous-mouse-down-ref.ts:62) and used to classify the click, so dragging a text selection from inside the overlay and releasing outside does not dismiss it, and an overlay opened on mousedown ignores the trailing click. Four further guards:
isMouseEventOnDialogdoes a bounding-box hit test, so clicking a transparent gap that is geometrically over the dialog counts as inside (use-hide-on-interact-outside.ts:58);- a target in a different document is unconditionally outside (
:126); - a target that is not
isConnectedis ignored (unmount-then-focus); - the marked-tree check applies only once the dialog has been focused at least once (a
focusedReffed by afocusinlistener), so hovercards are not closed when unrelated nodes are added and focused.
Scroll does not dismiss — and structurally cannot, because the global mouse-moving flag is reset by scroll, which makes the hovercard's mousemove path inert during a wheel; a browser test pins "does not hide an open hovercard on wheel" (sandbox/hovercard-interactions). Anchor removal or hiding is not watched at all. Parent closing cascades via hideAll() on menus (menu.tsx:197); a child opening does not close its parent. Tooltip narrows both predicates: it refuses to hide on hover-outside while the anchor has data-focus-visible, and refuses to hide on interact-outside when the interaction is within the anchor (tooltip.tsx:75).
Algorithm. acceptEscape(e) (dialog.tsx:684-705):
if key !== "Escape" or !e.bubbles -> false
if memoised -> replay memo unless defaultPrevented flipped
if e.defaultPrevented -> false
if !mounted or !dialog -> false
if isElementMarked(dialog) -> false # a deeper dialog owns it
accepted = hideOnEscape(e); memoise; return acceptedDegradation. No key release is irrelevant — everything is keydown. No script leaves only <details>-style toggling and no outside-dismiss at all. No OS window is the status quo; window/application deactivation is deliberately ignored (a changelog entry for 0.4.36 states that true browser or application window blur remains ignored). The Android back key is not modelled anywhere; it would map onto the same accept/hide predicate as Escape, which suggests treating it as a dismissal reason rather than a keycode. The mousedown-to-click pairing and the bounding-box hit guard both survive verbatim on a cell grid, and are the two things a naive "click outside closes" implementation gets wrong.
9. Focus
The four surfaces are kept distinct by defaults chosen at each layer, not by a mode enum:
| Surface | modal | autoFocusOnShow | Notes |
|---|---|---|---|
| Dialog | true | true | portal, backdrop, preventBodyScroll all follow modal |
| Popover | false | true ANDed with positioned (!unstable_placing) | preserveTabOrder: true |
| Hovercard | false | false in the store; forced true only when modal | adds useAutoFocusOnHide; pins finalFocus to the anchor |
| Tooltip | false | never focuses | preserveTabOrder: false |
| Menu | false | only with a resolved initialFocus or when modal | finalFocus prefers disclosureElement |
The popover's AND with positioned (popover.tsx:644) is the placed-bit paying off: focus is never moved into an element that is still at its pre-placement origin. Menu's narrowing (menu.tsx:148) exists because a hover-opened submenu must not steal focus.
Algorithm. Initial focus resolution in useDialog (dialog.tsx:496) is an ordered ladder: initialFocus prop if focusable → [data-autofocus=true],[autofocus] → the first tabbable (with a portal- and preserveTabOrder-aware variant) → the dialog element itself. The actual .focus() is deferred to a queueMicrotask that re-checks open, scrolls with block/inline: "nearest", then focuses with preventScroll (dialog.tsx:515). A late-arriving microtask must not steal focus back if focus escaped meanwhile — that is what focusedStoreRef plus a shadow-root-descending getDeepestActiveElement decide.
Restoration is a second resolver (dialog.tsx:582):
focusOnHide(dialog, retry = true):
if interactedOutside -> return
if an outside focusable already has focus -> return
el = finalFocus ?? disclosureElement
if some node has aria-activedescendant === el.id -> el = that composite
if !focusable(el) and el.closest("[data-dialog]").id -> el = [aria-controls~=id]
if !focusable(el) and retry -> rAF(() => focusOnHide(dialog, false))
if !autoFocusOnHide(el) -> return
el.focus()The retry-on-next-frame exists because a nested dialog may still be removing inert.
WARNING
There is no focus trap in the modal path. Containment is achieved by inerting everything outside (see dimension 11). FocusTrapRegion exists in the tree and is used by nothing (focus-trap-region.tsx:27) — dead code that a reader can easily mistake for the mechanism.
Degradation. No key release is irrelevant to Ariakit's focus paths — every one of them is keydown, click or focus-driven (a statement about this subject only). No OS window is unaffected: focus here is a document concept, which is what a single-surface toolkit has anyway. With no script, focus scope is impossible; :focus-within is the only tier-0 handle, so a static emitter can express "popup visible while the trigger group has focus" but never "focus moved into the popup". The portable content is the restoration ladder and the rule that a hover-opened surface must not take focus while a click-opened one must — both pure decisions over a small state record. The rAF retry is an artefact of inert removal ordering and should not be ported.
10. Layering & portals
Ariakit has no top layer and does not use the native popover or dialog APIs — grepping for showPopover, showModal or HTMLDialogElement in this tree finds only doc-comment links. Layering is:
portalElement prop (element | factory) ?? document.createElement("div")
-> appended to PortalContext ?? document.fullscreenElement ?? document.body
-> id = `portal/${element.id}` or a random id
-> published via portalRef and PortalContext so descendants nestgetRootElement prefers document.fullscreenElement when one exists (portal.tsx:33), and a fullscreenchange listener re-parents the node (portal.tsx:186). Ordering is DOM order plus author z-index; the library's only contribution is to mirror z-index twice — the positioning wrapper copies getComputedStyle(contentElement).zIndex and re-copies it across two animation frames in case it changes after mount (popover.tsx:543), and DialogBackdrop copies it in a layout effect.
The overlay tree is real but is three separate registries, none of them a single structure:
| Registry | Kind | Used for |
|---|---|---|
NestedDialogsContext | React context (array) | exempting descendants from inerting |
NestedHovercardContext | register callback | hover-intent membership |
MenuStore.parent/.menubar/ | store-to-store refs | hideAll and placement inheritance |
Public API versus implementation detail is explicitly annotated: portal, portalElement, portalRef, preserveTabOrder, preserveTabOrderAnchor, getPersistentElements, wrapperProps, updatePosition and getAnchorRect are public; data-placing, unstable_placing, unstable_treeSnapshotKey, __hovercard-trigger.tsx, the __ariakit-dialog-* element properties and the insideElements WeakMap are not.
Modal cohort ordering is the one genuinely surprising piece: when several default-modal portals open in the same layout pass, getLaterOpenModalPortals (dialog.tsx:139) walks root.querySelectorAll("[data-dialog][data-dialog-portal][data-open]"), keeps only dialogs after this one in DOM order whose portal is not already in the openModalPortals WeakSet, and treats them as peers to be exempted from inerting — so two modals opening together do not inert each other, while an already-established stack is never re-ordered by DOM position.
Degradation. Everything in this dimension is a workaround for problems a single-surface toolkit does not have: with one surface and a display list, "later in the list is in front" replaces portals, stacking contexts, z-index mirroring and fullscreen re-parenting. What must be kept is the ownership tree. Ariakit needed three ad-hoc registries because it has no first-class overlay tree, and the subtle bugs this dimension guards against (persistent elements, cohorts, nested marks) all trace back to that absence.
11. Modality
Modality here is not a focus trap and not aria-modal — it is a DOM-mutation regime applied to everything outside the dialog. On open, three passes run in order:
- Snapshot.
createWalkTreeSnapshotstamps a per-dialog property__ariakit-dialog-snapshot-<id>onbodyand on every element outside the dialog at open time, so later-added third-party nodes are never touched (walk-tree-outside.ts:70). The snapshot is taken independently of nested dialogs, so re-rendering a child does not re-snapshot. - Mark inside.
markTreeInsiderecords the dialog, persistent elements, opening-cohort peers and nested dialogs' content elements in aWeakMap<Element, WeakSet<Element>>keyed by the dialog node (tree-cleanup.ts:40), so outside-listeners can recognise them as inside before the dialog has ever been focused. - Mark and disable outside.
markAndDisableTreeOutsidewalks siblings-of-ancestors and, per element, stamps__ariakit-dialog-outsideand either setsinert(when supported) or falls back to:tabindex="-1"on every tabbable, a no-opfocusmethod,role="none"on ancestors that had a role, andpointer-events: none/user-select: none(disable-tree.ts:90).
Non-modal dialogs run only the marking half, so "outside" is a queryable property of nodes without any interaction being blocked — which is exactly what makes light dismiss cheap for hovercards and tooltips.
Algorithm. The walk (walk-tree-outside.ts:38):
walkTreeOutside(id, elements, cb, ancestorCb):
for each element in elements:
skip if it already has an ancestor in the list
walk up to body:
ancestorCb(parent, element)
unless it had an ancestor in the list:
for each sibling child passing shouldWalkElement: cb(child)
shouldWalkElement(child) = tag not in {SCRIPT, STYLE}
&& inSnapshot(id, child)
&& no listed element is contained by itinSnapshot walks up looking for the snapshot property and returns true if body was never stamped, so a dialog opened before the snapshot degrades to "everything is in scope" rather than to nothing.
Every mutation goes through orchestrate() (orchestrate.ts:26), a per-element per-key stack of setup/cleanup entries that restores in LIFO order and tolerates out-of-order disposal — an entry only marks itself disposed, and the stack is flushed from the top until it meets a live entry. That is what lets overlapping dialogs inert and un-inert the same nodes without corrupting the original attributes.
The backdrop is a separate DisclosureContent-driven element (role="presentation", data-backdrop="<dialogId>", position: fixed, inset 0) deliberately excluded from marking and disabling by isBackdrop, and marked as an ancestor of the dialog so clicking it counts as an outside interaction. Scrim appearance is entirely author CSS. preventBodyScroll is separate again — root-dialog-arbitrated via a body attribute plus a MutationObserver retry, with three compensation strategies (none, scrollbar-gutter: stable, padding plus --scrollbar-width) and a distinct iOS position: fixed strategy (use-prevent-body-scroll.ts:29).
Degradation. With one surface and a derived hit list, modality reduces to a boolean on the overlay node plus a hit-test cut and a keyboard-routing cut, and every file above becomes unnecessary. Two ideas remain worth importing, and both are pure data: getPersistentElements — an explicit escape list of things that count as inside though they live elsewhere in the tree (a toast container; a combobox input rendered outside its listbox) — and the open-time snapshot, i.e. modality applies to the world as it was when the overlay opened, not to whatever appears later. No OS window is unaffected; with no script, modality cannot exist.
12. Adaptive presentation
Ariakit deliberately does not own this decision. There is no compact/regular breakpoint, no sheet variant, no long-press-for-tooltip and no teaching-tip component. The library's answer is to make every dimension of presentation a prop an application can flip from its own media query. The canonical responsive-popover example uses a useMedia hook and passes modal={!isLarge}, a conditional backdrop, and — the load-bearing part — updatePosition={isLarge ? undefined : customFn}, where the custom function abandons Floating UI entirely and writes position: fixed; bottom: 0; width: 100%, i.e. a bottom sheet (examples/popover-responsive).
Algorithm. There is no adaptation algorithm in the library. There is only a seam (popover.tsx:477, documented at :812):
updatePosition?: (props: { updatePosition: () => Promise<void> }) => void | Promise<void>It is invoked in place of the default positioner while the placing bit is held; the application may call the supplied default, keep working, and the popup counts as placed only when the whole callback resolves. If a custom callback throws, waiters are released only if a position had already been written.
Touch is handled only negatively: the isMouseMoving gate suppresses tooltips and hovercards on taps (the second isMouseMoving() check inside showHovercard exists for exactly this), and combobox-store carries an isTouchSafari constant. Keyboard-driven relocation exists in one narrow form: HovercardDisclosure, a visually-hidden button that becomes visible when a MutationObserver sees data-focus-visible appear on the anchor (hovercard-disclosure.tsx:98), giving keyboard users a way into a hover-only surface.
Degradation. This is the dimension where the subject has the least to offer a target with no hover: every hovercard and tooltip trigger is dead there, and the library supplies no mapping to a replacement. Its transferable piece is only the shape of the seam — a single override hook that can hold the "placed" bit open while it works. A toolkit whose placement layer must also fold in a soft-keyboard inset cannot push the decision to the application the way this seam does, because the inset is an input to placement rather than a style choice.
13. Accessibility
Roles are computed from the rendered content element rather than asserted: getPopupRole(contentElement, fallback) reads the actual role attribute, so a MenuButton whose menu renders as a dialog reports aria-haspopup="dialog" (menu-button.tsx:224); getPopupItemRole does the same for items.
Tooltip semantics are being narrowed. role="tooltip" is emitted only when type === "description" (tooltip.tsx:63), and the type option is deprecated with a development-mode warning (tooltip-store.ts:17-25):
The
typeoption on the tooltip store is deprecated. Render a visually hidden label or use thearia-labeloraria-labelledbyattributes on the anchor element instead.
Ariakit therefore refuses the description-versus-label duality at the primitive level and pushes the anchor's accessible name onto the author. Tooltip content may be interactive here — a Tooltip is a Hovercard is a Dialog, so it can contain focusable content, and the repo's own browser test clicks a button inside a tooltip and presses Escape to restore the anchor (sandbox/tooltip-interactions). That is a deliberate divergence from the ARIA tooltip pattern described in ./aria-apg.md.
Hover-only hazards are addressed structurally: HovercardDisclosure gives keyboard access; hover-out is polygon-guarded (hoverable); the tooltip refuses to hide on hover-out while the anchor is focus-visible (persistent); Escape dismisses (dismissible). Modal dialogs prepend a visually-hidden "Dismiss popup" button when no DialogDismiss exists (dialog.tsx:364), reset heading levels via HeadingLevel, wire aria-labelledby / aria-describedby from DialogHeading / DialogDescription contexts, and set role="none" on outside ancestors that had a role. aria-modal is not used; the outside tree is inerted instead. Portals get aria-owns from a fixed-position span placed next to the tab-order anchor.
Degradation. None of the ARIA attributes exist off the DOM. What belongs in a primitive is the classification, not the attributes: an overlay kind, whether it is described-by or labelled-by its anchor, whether it is dismissible and hoverable, and whether it takes focus. Those bits drive both ARIA on an HTML backend and, on a cell grid, the equivalent decisions about announcement order and Escape handling. The strongest evidence this subject offers is negative: the one place it modelled a semantic distinction inside the overlay store — the tooltip's type: label | description — is the one place it is deprecating.
14. Animation
Ariakit emits geometry metadata specifically for animation, and owns an animation lifecycle so exit animations can run before unmount.
Metadata. data-placing (mirroring unstable_placing), data-open / data-enter / data-leave on the content element (disclosure-content.tsx:268-271), currentPlacement in the store (distinct from the requested placement, and documented as the thing a Motion example reads), and the custom properties --popover-transform-origin, --popover-anchor-width, --popover-available-width / --popover-available-height, --popover-overflow-padding (written even while hidden, because it is public API) and --dialog-viewport-height.
Lifecycle. The disclosure store carries {open, animated, animating, mounted} with mounted = open || animating, so unmount is deferred. The duration is derived from computed style rather than from a transitionend event that may never fire (disclosure-content.tsx:41):
getEndTime(names, delays, durations) =
max over i of ( name[i] !== "none"
? parse(delay[i % nDelays]) + parse(duration[i % nDurations])
: 0 )That i % n is CSS's cyclic list-matching rule, and pairing the lists index-wise rather than taking independent maxima is the point: independent maxima would combine one transition's delay with another's duration and overestimate the end time. The result is computed separately for transitions and animations, maxed, then maxed again across the content element, the store-tracked other element (backdrop ↔ dialog) and an explicitly passed related element; finally one 60 Hz frame is subtracted to avoid a flicker (disclosure-content.tsx:236). If the computed timeout is zero it not only stops immediately but sets animated = false, so the next close unmounts without waiting.
Transition state is set on a double animation frame so the data attribute lands after the element is really in the DOM, and stale states are ignored (transition === "leave" && open, or "enter" && !open). Reposition during an animation is not special-cased — autoUpdate keeps running. alwaysVisible exists specifically so a third-party animation library can keep the element mounted and visible while closed.
Reduced motion is not read anywhere in the overlay machinery. The only prefers-reduced-motion occurrence in this tree is in an unrelated example's readme.md (examples/tab-panel-animated/readme.md); motion preference is left entirely to author CSS.
Degradation. On a cell grid there is no transform origin and no easing surface worth naming, but the lifecycle is backend-neutral and is exactly what a toolkit needs: open / closing / closed with a closing duration, plus the invariant that geometry metadata (resolved side, alignment offset, anchor width, available size) is published as data before paint. The derive-duration-from-computed-style trick is DOM-specific; a toolkit that owns its animation clock states the duration directly. Because mounted and animating are store state and the timeout is a number, the whole thing is assertable with a virtual clock.
15. State architecture
An event-driven observable-store architecture — explicitly not a statechart and not a reducer. createStore(initialState, ...parentStores) (store/index.ts:380) returns {getState, setState, __unstableInternals}. setState is per-key: it early-returns on SameValue equality, clones the state object, fans the change out to parent stores, then notifies.
Three subscription timings share one type and differ in semantics: subscribe (after the change), sync (immediately on registration and synchronously on change), batch (immediately, then microtask-coalesced with a Set of updated keys). Listeners may return a cleanup that runs before their next invocation — which is how derived rules such as "when mounted goes false, clear activeId" express themselves (menu-store.ts:103-108).
Algorithm. setState(key, value, fromStores = false) (store/index.ts:709):
if !hasOwnProperty(state, key) return
next = applyState(value, () => state[key])
if SameValue(next, state[key]) return
prev = state; state = { ...state, [key]: next }
if (!fromStores && parents):
for each parent: parent.setState(key, next)
if state[key] changed underneath -> mark superseded and stop
if superseded: run up to MAX_REPAIR_PASSES pushing the committed value to every parent
if (!superseded):
notify sync listeners with a prevState that preserves reentrantly-committed other keys
if batch listeners exist: add key to updatedKeys; schedule one microtask flush that
swaps the Set so reentrant updates land in the next flushMAX_REPAIR_PASSES is 100 (store/index.ts:115) and bounds parent/child fights over one key. Composition primitives are mergeStore, pick, omit, setup (register an init-time callback) and init (reference-counted, so a store shared by two components is destroyed only once). Controlled versus uncontrolled is convention — open versus defaultOpen, with throwOnConflictingProps rejecting store-plus-default combinations.
Derived state is written as explicit sync rules inside setup() (mounted = open || animating; submenu placement from parent orientation; activeId = null on unmount), i.e. the "reducer" is a set of small local invariants rather than a transition table. There is exactly one enum-shaped state in the overlay stack — menu's initialFocus: "container" | "first" | "last"; everything else is booleans, numbers and element references.
Degradation. The shape survives a value-semantics, allocation-conscious toolkit; the implementation does not. Portable: per-key change detection with SameValue, derived invariants as small pure rules, controlled-versus-uncontrolled by explicit precedence, per-listener cleanup discipline. Not portable: {...state} cloning per setState (an allocation per keystroke), WeakMaps and WeakSets keyed by DOM nodes, Symbol instance sets, microtask batching, and the fast-path-frame recovery machinery. That last piece appears to exist because listeners can register and re-key themselves during dispatch — the repair loop and the recovery frames are both about dispatch-time mutation — which is a hazard specific to this dispatch model rather than a universal cost of observable state.
IMPORTANT
The store's state holds HTMLElement references. That single choice is what keeps @ariakit/components DOM-bound despite depending only on @ariakit/store and @ariakit/utils, and it is precisely what prevents the anchor from being a plain comparable value (dimension 1).
16. Shared infrastructure
Factoring is by single-inheritance store chains plus hook chains, with very little duplication.
stores: Disclosure -> Dialog (alias) -> Popover -> Hovercard -> Tooltip
Menu = Composite + Hovercard
Select = Composite + Popover Combobox = Composite + Popover
CompositeOverflow = Popover verbatim
hooks: useTooltip -> useHovercard -> usePopover -> useDialog
-> { useFocusableContainer, useDisclosureContent, useFocusable, usePortal }Genuinely common: the disclosure/mounted/animating lifecycle, the anchor/disclosure/content/popover element quartet, hide-on-escape, hide-on-interact-outside, focus restoration, portal plus tab-order preservation, the positioning pass. Pieces that only look common and are correctly kept apart:
- Hover intent is Hovercard-and-below only.
PopoverandDialognever load the polygon. - Composite / roving focus is a sibling axis, mixed in per component rather than part of the overlay chain, so a
Popoverhas no items and aMenugets items without the overlay knowing. - The tooltip singleton lives in the React component file, not in any store, because it is a page-global policy rather than overlay state.
__hovercard-trigger.tsxis a private shared trigger used by bothHovercardAnchor(setAnchorElement: true) andMenuButton(setAnchorElement: false) — the anchor-versus-trigger distinction is its only parameter.- Arrow rendering is a component (
PopoverArrow) while arrow geometry is in the positioning pass.
The mechanical device. Divergence is written as props = useX({defaults, ...props, narrowedPredicate}): placing ...props before the predicate makes the predicate authoritative, placing defaults before it makes them overridable. BooleanOrCallback options let a subclass wrap the superclass's predicate — check isFalsyBooleanCallback(prop, event), then add its own rule.
Where sharing broke down, it shows. ComboboxPopover has to re-implement getPersistentElements to re-admit its own input and select controls into its modal context (combobox-popover.tsx:233), because the dialog's notion of "inside" is DOM containment rather than explicit membership.
Degradation. The factoring is the most directly actionable thing here. What one anchored-overlay primitive should own, on this evidence: anchor (a comparable id or rect), requested and resolved placement, the gutter/shift/flip/slide/overlap/padding policy, open/closing/closed, dismissal predicates, focus policy, a modality flag, a parent link, and the resolved side plus arrow offset as data. What it should not own: hover intent (a separate value the caller feeds it), roving focus and item collections, the tooltip singleton (a global policy value), arrow rendering, animation duration, or the accessibility role. Ariakit's evidence is that it already keeps every one of those apart — and its one leak is exactly the case where "inside" was defined by tree containment.
Strengths
polygon.tsis 96 lines, dependency-free, integer-arithmetic-only, unit-tested including the vertex-grazing edge case, and separated from all orchestration — the most directly portable artefact in the subject.unstable_placingturns "not yet positioned" into observable state with a mirroreddata-placingattribute, so focus, scroll-into-view and tests all wait on the same fact instead of guessing with animation frames.- The store chain makes each overlay's differences explicit and small;
Tooltip's entire divergence fromHovercardis two narrowed predicates and four default values, which reads as a specification of what actually differs between surface kinds. - Element-reference state is split three ways —
anchorElement/disclosureElement/contentElement— with a tested reconciliation rule, correctly separating "what I am positioned against" from "what opened me and where focus returns". - Outside-interaction detection is unusually careful: mousedown/click pairing survives drag-out selections, a bounding-box hit test treats transparent gaps over the popup as inside, cross-document targets are handled, and a focused-once gate keeps hovercards alive through unrelated DOM insertions.
- Geometry is deliberately published as data —
currentPlacementin the store; anchor width, available size, transform origin and overflow padding as CSS variables — rather than consumed privately. - The tooltip singleton solves warm-up and cool-down with one global nullable plus one number, and hardens it against controlled-open re-entrancy with a
WeakSet. - Comments carry the reasoning and often link the issue that produced the behaviour, which makes the non-obvious guards (
capturedDisclosures,hidingStores, thevylookback, the cohort scan) legible rather than mysterious. - Overlay tests are behaviour-level and edge-case-first — sandboxes named after issue numbers, browser tests for wheel-during-hovercard and cross-anchor tooltip timing — rather than snapshot-level.
Weaknesses
- Modality by DOM mutation is a large body of accidental complexity: a snapshot pass, an inside-marking pass, an inert/disable pass, a stacked restoration mechanism, backdrop and focus-trap exemptions, a nested-dialog registry, an opening-cohort DOM scan, and
getPersistentElementsas an escape hatch. - No logical placement, no RTL handling and no writing-mode awareness in Ariakit itself; the placement union is twelve physical strings and
-start/-endsemantics are delegated. - The anchor cannot be compared, cached or serialised — element identity plus a
getBoundingClientRectclosure, so "did the anchor change?" is answerable only by element identity. - No arrow hiding when the anchor scrolls out of view (no
referenceHiddenmiddleware), and no reaction to anchor removal or the anchor becoming hidden — the popup stays where it was. - Three unrelated ad-hoc registries stand in for one overlay tree, and bugs leak between them:
ComboboxPopovermust re-inject its own controls viagetPersistentElementsbecause dialog membership is DOM containment. - Hover intent requires globally suppressing four mouse event types with
preventDefault/stopPropagation, which is invasive to the host application and forces the trigger to use a nativemouseleavelistener to escape its own suppression. isMouseMovingis a module-global mutable boolean installed once and never removed, with aprocess.env.NODE_ENV === "test"branch that makes every synthetic move count as movement — so the production behaviour on that path is not the tested behaviour.- Adaptive presentation is entirely the application's problem; there is no touch or compact story beyond suppressing hover.
- Reduced motion is not read anywhere in the overlay machinery.
- Store internals have grown intricate — fast-path frames, listener re-keying recovery, a 100-pass parent repair loop with a console warning — correctness machinery attached to dispatch-time listener mutation.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
Make Dialog the base of every overlay (Tooltip is a Hovercard is a Popover is a Dialog is a Disclosure). | Dismissal, focus restoration, portalling, mount/unmount animation and modality are the same problem for all of them; writing them once and narrowing with predicates and defaults eliminates the drift separate implementations accumulate. | A tooltip carries the whole dialog apparatus: dialog-shaped defaults, disclosure capture, escape arbitration, portal machinery and outside-interaction listeners. It also puts the ARIA tooltip pattern out of reach by construction, since tooltip content can be focusable and interactive. |
Delegate all positioning to @floating-ui/dom and expose an updatePosition override rather than a strategy enum. | Collision detection against clipping ancestors, transforms and zoom is a large browser-specific problem the library does not want to own; an override seam lets applications express bottom sheets or custom two-phase measurement with no new API. | Placement semantics (RTL, logical placements, fallback ordering details) are not Ariakit's to define or fix, and the async computePosition forces both the cancellation protocol and unstable_placing into existence. The override must itself participate in the placed-bit contract. |
| Implement hover intent as a polygon over the popup rect from a refreshed exit point, and suppress mouse events inside it. | A pure hide-delay is not enough: while the pointer crosses the gap it passes over elements whose mouseenter/focus handlers would close the card, and suppressing those events is the only way to guarantee the corridor without a pointer grab. | Global capture-phase preventDefault/stopPropagation on four mouse event types is invasive to the host application, and it forces the trigger to listen for a native mouseleave to escape its own suppression. It cannot be replicated without control of the event dispatcher. |
| Achieve modality by walking the DOM and inerting/marking everything outside, rather than by a focus trap. | Real inertness (pointer, focus and accessibility tree) is stronger than a tab-cycling trap, and it lets non-modal surfaces reuse the same marking pass to answer "is this event outside?". | It mutates foreign DOM, which required an open-time snapshot, a stacked restoration mechanism, backdrop and focus-trap exceptions, a nested-dialog registry, an opening-cohort heuristic and getPersistentElements. FocusTrapRegion survives as dead code. |
Gate hover-opening on a global movement predicate rather than on mouseenter or pointerType. | mouseover/mouseenter fire from scrolling, from taps, from layout moving under a stationary cursor and from programmatic focus; requiring non-zero movementX/movementY since the last mousedown/mouseup/keydown/scroll rejects all of those with one boolean. | A page-global mutable flag with a test-mode escape that makes every synthetic move count. Touch is handled implicitly, so there is nowhere to hang a deliberate touch presentation. |
| Publish resolved geometry as CSS custom properties and data attributes rather than as callback arguments. | The styling layer, not JavaScript, should decide how a popup reacts to its resolved side, its anchor's width or the available space — --popover-anchor-width, --popover-available-*, --popover-transform-origin, data-enter/data-leave do that with zero runtime. | The metadata contract becomes stringly-typed and partly undocumented (--popover-overflow-padding silently collapses a per-side object to max(left, right)), and consumers who need the values in code must read them back out of the DOM or subscribe to currentPlacement separately. |
| Keep the state layer framework-free and key-granular, but let it hold DOM element references. | A tiny observable object with per-key subscriptions gives fine-grained re-renders and lets stores compose (menu inherits combobox, submenu inherits parent) without a reducer or a statechart; element references make anchor/disclosure/content relationships declarative. | @ariakit/components cannot be used off the DOM despite depending only on @ariakit/store and @ariakit/utils. The elements-in-state choice is precisely what prevents the anchor from being a plain comparable value. |
Sources
Primary sources, all read at a0426ed547d95b84c9d53033053e51baeaca4aaa:
- Store engine —
packages/ariakit-store/src/index.ts(createStore,setStatefan-out and repair,MAX_REPAIR_PASSES). - Typed stores —
popover-store.ts,hovercard-store.ts,tooltip-store.ts,menu-store.ts,dialog-store.ts,disclosure-store.ts. - Positioning —
popover.tsx(virtual anchor, middleware assembly, cancellation, DPR snapping, CSS variables, placing-bit writers) andpopover-arrow.tsx. - Hover intent —
hovercard/utils/polygon.tsand its test; orchestration inhovercard.tsx; the shared trigger in__hovercard-trigger.tsx; the movement predicate inariakit-react-utils/src/hooks.ts. - Dismissal, focus and modality —
dialog.tsxplusdialog/utils/:walk-tree-outside.ts,disable-tree.ts,tree-cleanup.ts,orchestrate.ts,use-hide-on-interact-outside.ts,use-previous-mouse-down-ref.ts,use-prevent-body-scroll.ts. - Layering —
portal/portal.tsx; cohort logic indialog.tsx:139. - Animation lifecycle —
disclosure/disclosure-content.tsx. - Timing singleton —
tooltip/tooltip-anchor.tsx. - Examples and sandboxes —
menu-context-menu,popover-selection,popover-responsive,sandbox/hovercard-interactions,sandbox/tooltip-interactions,sandbox/tooltip-cross-anchor. - Project documentation — ariakit.com (used for API surface and deprecation guidance only; every mechanism above is read from source).
Related pages in this catalog: ./index.md, ./concepts.md, ./comparison.md, ./features-people-forget.md, ./proposal.md; the positioning engine this subject delegates to, ./floating-ui.md; the nearest headless peers, ./radix.md, ./base-ui.md, ./zag.md, ./react-aria.md, ./headlessui.md, ./angular-cdk.md; the platform baseline it does not use, ./popover-api.md and ./css-anchor.md; and the pattern it diverges from, ./aria-apg.md. Toolkit context: ../../specs/ui/index.md, ../../specs/ui/input.md, ../../specs/ui/state-machines.md, ../../specs/ui/containers.md.