TIL

Radix UI - Popper

文章發表於
...

Introduction

Every tooltip, popover, dropdown menu, and context menu in Radix UI needs to answer the same question: "Where should this floating thing appear?" And it's harder than it looks. The floating element needs to stay near its anchor, avoid clipping off-screen, flip when there's no room, position an arrow correctly, and animate from the right origin point.

In the Floating UI series, we explored how positioning works from the ground up: the coordinate system that maps elements to screen positions, the middleware pipeline that transforms those coordinates through composable functions, and the useFloating hook that ties it all together in React. We also looked at how Radix builds its component primitives with the Slot pattern for prop merging and Collection API for compound component coordination.

Now we arrive at the component that brings positioning into Radix's world: . This internal utility wraps 's useFloating hook into a compound component API that powers Tooltip, Popover, DropdownMenu, ContextMenu, Select, and more.

The raw Floating UI hook is imperative: you wire refs manually, configure middleware arrays, and track arrow elements yourself. Popper transforms all of that into declarative JSX:

// Raw Floating UI — imperative, manual wiring
const { refs, floatingStyles } = useFloating({
placement: 'bottom',
middleware: [offset(10), flip(), shift(), arrow({ element: arrowRef })],
});
// Radix Popper — declarative, automatic coordination
<Popper>
<PopperAnchor />
<PopperContent side="bottom" sideOffset={10} avoidCollisions>
<PopperArrow />
</PopperContent>
</Popper>

This article breaks down how Popper achieves this transformation, then builds a working mini-implementation to see these ideas in action.


Architecture Overview

Component Hierarchy

Popper uses four components arranged in a compound pattern. The root provides context, the anchor registers a reference element, the content handles positioning, and the arrow positions itself based on the content's placement.

Context Design

Radix uses two levels of context to coordinate between components:

PopperContext sits at the root level. It holds a reference to the anchor element and a setter function. When PopperAnchor mounts, it registers itself here. When PopperContent mounts, it reads the anchor from here and passes it to Floating UI.

type PopperContextValue = {
anchor: Measurable | null;
onAnchorChange(anchor: Measurable | null): void;
};

PopperContentContext sits inside the content. It shares positioning results (which side the content landed on, arrow coordinates, whether the arrow should be hidden) with PopperArrow.

type PopperContentContextValue = {
placedSide: Side;
onArrowChange(arrow: HTMLSpanElement | null): void;
arrowX?: number;
arrowY?: number;
shouldHideArrow: boolean;
};

Data Flow

The components communicate through a four-step handshake:

  1. PopperAnchor registers the anchor element (or a virtual ref) with the root context
  2. PopperContent reads the anchor, passes it to useFloating, and calculates position via the middleware pipeline
  3. PopperArrow registers its span element with the content context, triggering a recalculation with the arrow middleware included
  4. PopperContent provides the final arrow coordinates back through context, and PopperArrow positions itself

Component Deep Dive

Popper (Root)

The root component is deliberately minimal. It manages a single piece of state: the anchor element.

const Popper: React.FC<PopperProps> = (props) => {
const { children } = props;
const [anchor, setAnchor] = React.useState<Measurable | null>(null);
return (
<PopperProvider anchor={anchor} onAnchorChange={setAnchor}>
{children}
</PopperProvider>
);
};

Popper renders no DOM element at all. It's purely a context provider, giving consumers complete flexibility over their JSX structure. This is the same pattern used across Radix — roots provide coordination, not markup — as we saw with the Collection API.

PopperAnchor

This component handles two modes: DOM anchors and virtual anchors.

const PopperAnchor = React.forwardRef((props, forwardedRef) => {
const { virtualRef, ...anchorProps } = props;
const context = usePopperContext(ANCHOR_NAME);
const ref = React.useRef<HTMLDivElement>(null);
const composedRefs = useComposedRefs(forwardedRef, ref);
const anchorRef = React.useRef<Measurable | null>(null);
React.useEffect(() => {
const previousAnchor = anchorRef.current;
anchorRef.current = virtualRef?.current || ref.current;
if (previousAnchor !== anchorRef.current) {
context.onAnchorChange(anchorRef.current);
}
});
return virtualRef ? null : <Primitive.div {...anchorProps} ref={composedRefs} />;
});

There are two subtle patterns here worth noting.

The effect has no dependency array. It runs on every render. This is intentional: virtualRef.current can change without triggering a re-render (that's how refs work), so the effect needs to check on every render cycle whether the anchor has actually changed.

It tracks the previous anchor to avoid calling onAnchorChange unnecessarily. Without this check, the content would reposition on every render even when the anchor hasn't moved.

When using a virtual anchor, the component renders nothing. The virtual ref just needs to implement the Measurable interface — an object with a getBoundingClientRect method. This is how Radix's ContextMenu anchors to the right-click position, and how Select could anchor to a text selection.

PopperContent

This is the most substantial component. It translates Radix's declarative props into Floating UI's imperative configuration.

interface PopperContentProps extends PrimitiveDivProps {
side?: 'top' | 'right' | 'bottom' | 'left';
sideOffset?: number;
align?: 'start' | 'center' | 'end';
alignOffset?: number;
arrowPadding?: number;
avoidCollisions?: boolean;
collisionBoundary?: Element | Element[] | null;
collisionPadding?: number | Partial<Record<Side, number>>;
sticky?: 'partial' | 'always';
hideWhenDetached?: boolean;
updatePositionStrategy?: 'optimized' | 'always';
onPlaced?: () => void;
}

Inside, it converts side/align to a Floating UI placement string, normalizes collision padding, and sets up the middleware pipeline (detailed in the next section). The useFloating hook receives the anchor from context and returns calculated styles:

const desiredPlacement = (side + (align !== 'center' ? '-' + align : '')) as Placement;
// 'bottom' + 'start' → 'bottom-start'
// 'top' + 'center' → 'top'
const { refs, floatingStyles, placement, isPositioned, middlewareData } = useFloating({
strategy: 'fixed',
placement: desiredPlacement,
whileElementsMounted: (...args) => {
const cleanup = autoUpdate(...args, {
animationFrame: updatePositionStrategy === 'always',
});
return cleanup;
},
elements: {
reference: context.anchor, // ← Read from PopperContext
},
middleware: [ /* ... */ ],
});

The render structure uses two nested divs:

<div
ref={refs.setFloating}
data-radix-popper-content-wrapper=""
style={{
...floatingStyles,
transform: isPositioned
? floatingStyles.transform
: 'translate(0, -200%)',
minWidth: 'max-content',
zIndex: contentZIndex,
['--radix-popper-transform-origin']: [
middlewareData.transformOrigin?.x,
middlewareData.transformOrigin?.y,
].join(' '),
...(middlewareData.hide?.referenceHidden && {
visibility: 'hidden',
pointerEvents: 'none',
}),
}}
>
<PopperContentProvider
placedSide={placedSide}
onArrowChange={setArrow}
arrowX={arrowX}
arrowY={arrowY}
shouldHideArrow={cannotCenterArrow}
>
<Primitive.div
data-side={placedSide}
data-align={placedAlign}
style={{
...contentProps.style,
animation: !isPositioned ? 'none' : undefined,
}}
/>
</PopperContentProvider>
</div>

Two other patterns worth calling out:

Off-screen while measuring. Before isPositioned becomes true, the wrapper uses translate(0, -200%) to keep content off-screen. This prevents a flash of content at the wrong position while Floating UI measures dimensions.

Animation disabled until positioned. The inner div sets animation: 'none' until positioning is complete. This ensures entrance animations don't play at the wrong position or with the wrong data-side attribute.

PopperArrow

The arrow component reads placement data from the content context and positions itself on the opposite side of the content:

const OPPOSITE_SIDE: Record<Side, Side> = {
top: 'bottom', right: 'left', bottom: 'top', left: 'right',
};
const PopperArrow = React.forwardRef((props, forwardedRef) => {
const contentContext = useContentContext(ARROW_NAME);
const baseSide = OPPOSITE_SIDE[contentContext.placedSide];
return (
<span ref={contentContext.onArrowChange} style={{
position: 'absolute',
left: contentContext.arrowX,
top: contentContext.arrowY,
[baseSide]: 0,
transform: {
top: 'translateY(100%)',
right: 'translateY(50%) rotate(90deg) translateX(-50%)',
bottom: 'rotate(180deg)',
left: 'translateY(50%) rotate(-90deg) translateX(50%)',
}[contentContext.placedSide],
visibility: contentContext.shouldHideArrow ? 'hidden' : undefined,
}}>
<ArrowPrimitive.Root {...arrowProps} ref={forwardedRef} style={{ display: 'block' }} />
</span>
);
});

The arrow is always an SVG pointing "up" by default. The transform rotates it to match the placement side:

The arrow is wrapped in a <span> rather than being a bare SVG because ResizeObserver (used by useSize to measure the arrow) doesn't report SVG dimensions correctly. The span ensures accurate size measurement.


Middleware Pipeline

The middleware pipeline is where the actual positioning happens. If you want a deeper understanding of how the pipeline architecture works — the sequential loop, the reset mechanism, and how middleware share data — see Floating UI - Middleware Overview.

Here's Popper's middleware configuration in order:

middleware: [
offset({ mainAxis: sideOffset + arrowHeight, alignmentAxis: alignOffset }),
avoidCollisions && shift({ mainAxis: true, crossAxis: false, limiter, ...detectOverflowOptions }),
avoidCollisions && flip({ ...detectOverflowOptions }),
size({ ...detectOverflowOptions, apply: ({ elements, rects, availableWidth, availableHeight }) => { ... } }),
arrow && floatingUIarrow({ element: arrow, padding: arrowPadding }),
transformOrigin({ arrowWidth, arrowHeight }),
hideWhenDetached && hide({ strategy: 'referenceHidden', ...detectOverflowOptions }),
]

Order matters. Each middleware can modify x, y, or placement, affecting everything that runs after it. As we saw in the middleware article, flip can trigger a reset that reruns the entire pipeline with a new placement.

offset

offset({ mainAxis: sideOffset + arrowHeight, alignmentAxis: alignOffset })

Creates space between anchor and content. The mainAxis value includes arrowHeight so the arrow sits in its own space rather than overlapping the anchor.

shift

shift({
mainAxis: true,
crossAxis: false,
limiter: sticky === 'partial' ? limitShift() : undefined,
...detectOverflowOptions,
})

Slides content along the viewport edge to stay visible. Note that mainAxis in shift refers to the axis parallel to the reference edge (e.g., X for 'bottom' placement) — this is swapped from offset where mainAxis means the axis away from the reference (Y for 'bottom'). So mainAxis: true here enables horizontal sliding for vertical placements, while crossAxis: false prevents adjusting the gap distance. See Middleware Deep Dive — How shift Uses mainAxis and crossAxis for a detailed explanation.

The sticky prop controls how aggressively it keeps content in view:

  • 'partial' (default) uses limitShift() — content can partially leave the viewport, but the arrow stays connected to the anchor
  • 'always' has no limiter — content always stays fully inside the viewport, even if the arrow detaches

flip

flip({ ...detectOverflowOptions })

When there's not enough room on the requested side, flip tries the opposite side. If that also overflows, it tries perpendicular sides and picks whichever has the most space. This is the middleware that triggers a pipeline reset, as we explored in the middleware deep dive.

size

size({
...detectOverflowOptions,
apply: ({ elements, rects, availableWidth, availableHeight }) => {
const { width: anchorWidth, height: anchorHeight } = rects.reference;
const contentStyle = elements.floating.style;
contentStyle.setProperty('--radix-popper-available-width', `${availableWidth}px`);
contentStyle.setProperty('--radix-popper-available-height', `${availableHeight}px`);
contentStyle.setProperty('--radix-popper-anchor-width', `${anchorWidth}px`);
contentStyle.setProperty('--radix-popper-anchor-height', `${anchorHeight}px`);
},
})

Rather than modifying position, this middleware exposes dimensions as CSS custom properties. Consumers use them to write responsive floating content:

.select-content {
width: var(--radix-popper-anchor-width);
}
.dropdown-content {
max-height: var(--radix-popper-available-height);
overflow-y: auto;
}

arrow

floatingUIarrow({ element: arrow, padding: arrowPadding })

Calculates where to position the arrow element. The arrowPadding prevents the arrow from reaching the content's corners. The middleware also reports centerOffset — when non-zero, Radix hides the arrow because it can't center on the anchor.

transformOrigin (Custom)

A custom middleware unique to Radix. It calculates the CSS transform-origin so scale animations grow from the arrow tip toward the anchor. The result is exposed as --radix-popper-transform-origin:

.popover-content {
transform-origin: var(--radix-popper-transform-origin);
animation: scaleIn 150ms ease-out;
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}

hide

hide({ strategy: 'referenceHidden', ...detectOverflowOptions })

Detects when the anchor scrolls out of view. The content wrapper receives visibility: hidden and pointerEvents: 'none', preventing orphaned floating elements.


Implementation

Let's build a working mini-Popper to see these concepts in action. This implementation uses the same architecture as the real Radix Popper: two context levels, compound components, and Floating UI's middleware pipeline.

import React, { useState } from 'react';
import { Popper, PopperAnchor, PopperContent, PopperArrow } from './popper';
import './index.css';

function App() {
  const [side, setSide] = useState('bottom');
  const [isOpen, setIsOpen] = useState(true);

  return (
    <div className="app">
      <div className="controls">
        <label>Side:</label>
        <select value={side} onChange={(e) => setSide(e.target.value)}>
          <option value="top">top</option>
          <option value="right">right</option>
          <option value="bottom">bottom</option>
          <option value="left">left</option>
        </select>
        <button onClick={() => setIsOpen(!isOpen)}>
          {isOpen ? 'Hide' : 'Show'}
        </button>
      </div>

      <div className="demo-area">
        <Popper>
          <PopperAnchor className="anchor">
            Anchor
          </PopperAnchor>
          {isOpen && (
            <PopperContent side={side} sideOffset={4} className="content">
              <PopperArrow className="arrow" />
              <p>Floating content</p>
              <p className="side-label">side: {side}</p>
            </PopperContent>
          )}
        </Popper>
      </div>
    </div>
  );
}

export default App;

Key implementation details:

  1. Two context levelsPopperContext coordinates anchor/content. PopperContentContext shares arrow positioning data. This mirrors the real Radix architecture.

  2. Arrow state is liftedPopperContent owns the arrow element state (setArrowEl), even though PopperArrow provides the actual DOM element. This lets the middleware pipeline include the arrow before PopperArrow mounts.

  3. Compound component pattern — Like the Collection API, each component self-registers via context rather than requiring the parent to iterate children. PopperArrow can be nested anywhere inside PopperContent.

  4. The OPPOSITE_SIDE map — The arrow sits on the opposite side from the content's placement. If content is below the anchor (side="bottom"), the arrow points up from the top edge of the content.


Key Design Decisions

Fixed Positioning Strategy

useFloating({ strategy: 'fixed' })

Popper uses fixed positioning instead of absolute. This avoids issues with ancestor elements that have transform, filter, or will-change properties, which create new containing blocks and break absolute positioning. Fixed positioning also avoids scroll container issues.

Off-Screen While Measuring

transform: isPositioned ? floatingStyles.transform : 'translate(0, -200%)'

Before Floating UI finishes calculating, the content is translated off-screen. This prevents a flash of content at (0, 0) before the correct position is computed. The content is still rendered (so it can be measured), just not visible.

Animation Disabled Until Positioned

animation: !isPositioned ? 'none' : undefined

Combined with off-screen rendering, this prevents entrance animations from playing at the wrong position. Once positioning completes, the animation property is removed and the CSS animation kicks in with the correct data-side attribute.

Scoped Context

const [createPopperContext, createPopperScope] = createContextScope(POPPER_NAME);

Each Popper instance gets its own isolated context scope via __scopePopper. This is critical for composite components: Select uses Popover internally, which uses Popper. Without scoped contexts, the nested Popper instances would clash.


Connection to Other Primitives

Popper doesn't exist in isolation — it's one layer in a composition of primitives that make Radix components work:

  • Slot handles prop merging via asChild, letting consumers control the rendered element
  • Collection tracks items for keyboard navigation in menus and selects
  • Presence manages mount/unmount animations — delaying removal until exit animations complete
  • FocusScope traps keyboard focus inside modals and dialogs
  • RovingFocusGroup handles arrow-key navigation within item groups

A DropdownMenu, for example, combines all of these: Popper positions the menu, Presence animates it in and out, Collection tracks menu items, RovingFocusGroup handles arrow keys between them, and FocusScope traps focus inside. Each primitive handles one concern well, and composition creates sophisticated behavior.


Summary

@radix-ui/react-popper transforms Floating UI's imperative hook into a declarative compound component API. The key ideas:

  1. Context coordination — The root stores the anchor, content reads it via context. No manual ref wiring.
  2. Lifted arrow state — Content owns the arrow ref so it can include the arrow in middleware configuration before the arrow component mounts.
  3. Middleware pipeline — Seven middleware in order: offset → shift → flip → size → arrow → transformOrigin → hide. Order matters because each builds on previous results.
  4. Virtual anchors — The Measurable interface decouples positioning from DOM structure, enabling cursor-anchored context menus and selection popovers.
  5. CSS custom properties — Dimensions and transform origins are exposed as CSS variables, keeping styling in CSS where it belongs.

This architecture enables Tooltip, Popover, Menu, Select, and more to share consistent positioning behavior with minimal duplication. Each component just declares its intent (side="bottom", avoidCollisions), and Popper handles the rest.

References

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Share on X
Buy me a coffee