Floating UI - Middleware Overview
- 文章發表於
- ...
In the , we explored how computeCoordsFromPlacement calculates the initial x and y coordinates. But that's just the starting point. The real power of Floating UI lies in its middleware pipeline - a sequential, resettable, data-sharing loop that transforms positioning through composable functions.
Overview
Part 1: The Pipeline Architecture
The middleware pipeline is the heart of Floating UI's extensibility. It transforms positioning through composable functions that run in sequence.
export interface MiddlewareData {[key: string]: any;arrow?: Partial<Coords> & {centerOffset: number;alignmentOffset?: number;};autoPlacement?: {index?: number;overflows: Array<{placement: Placement;overflows: Array<number>;}>;};flip?: {index?: number;overflows: Array<{placement: Placement;overflows: Array<number>;}>;};hide?: {referenceHidden?: boolean;escaped?: boolean;referenceHiddenOffsets?: SideObject;escapedOffsets?: SideObject;};offset?: Coords & {placement: Placement};shift?: Coords & {enabled: {[key in Axis]: boolean};};}
Step 1. Initial positioning
let { x, y } = computeCoordsFromPlacement(rects, placement)
The process begins with an initial position. The computeCoordsFromPlacement looks at the reference element and the requested placement to calculate a basic starting point.
Step 2. The Middleware Loop
The initial coordinates are passed through a sequence of middleware functions. Each middleware has a single responsibility (e.g. offset adds a gap, flip handles viewport collisions).
for (let i = 0; i < validMiddleware.length; i++) {const {name, fn} = validMiddleware[i];// ... call fn, process resultif (reset && resetCount <= 50) {// ... handle reseti = -1; // After i++, becomes 0 - restart from first middleware}}
Normal flow: i=0 → process → i++ → i=1 → process → i++ → doneReset at i=1: i=0 → process → i++ → i=1 → RESET → i=-1 → i++ → i=0 → restart
Step 3. Building the Middleware State
Each middleware receives a comprehensive state object and returns modifications:
const {x: nextX,y: nextY,data,reset,} = await fn({x, // Current x positiony, // Current y positioninitialPlacement: placement, // Original placement (never changes)placement: statefulPlacement, // Current placement (may have changed)strategy, // 'absolute' | 'fixed'middlewareData, // Data from previous middlewarerects, // Element rectanglesplatform, // Platform methodselements: {reference, floating}, // Actual elements});
Why both initialPlacement and placement? The initialPlacement preserves the user's original intent (e.g., 'bottom'), while placement reflects the current working state (may become 'top' after flip). This lets flip know what to fall back to while offset applies the gap in the correct direction.
Step 4. Processing Middleware Return
After each middleware runs, we apply its modifications:
// Apply position changes (if any)x = nextX ?? x;y = nextY ?? y;// Accumulate data under middleware's namespacemiddlewareData = {...middlewareData, // Keep existing data[name]: { // Namespace by middleware name...middlewareData[name], // Keep existing data for this middleware...data, // Merge new data},};
For example, if we have offset and flip in our middleware, after executing it, the middleware data will look like this:
middlewareData = {offset: { mainAxis: 10 },flip: { index: 1, overflows: [...] }}
Step 5. The Reset Mechanism
When a middleware needs to change placement (like flip), it triggers a reset:
if (reset && resetCount <= 50) {resetCount++;if (typeof reset === 'object') {if (reset.placement) {statefulPlacement = reset.placement;}if (reset.rects) {rects =reset.rects === true? await platform.getElementRects({reference, floating, strategy}): reset.rects;}({x, y} = computeCoordsFromPlacement(rects, statefulPlacement, rtl));}i = -1;}
Reset types:
Part 2: The Complete Data Flow
Here's how all the pieces fit together in a single visual flow:
┌─────────────────────────────────────────────────────────────────────────────┐
│ INITIAL STATE │
│ { x, y } = computeCoordsFromPlacement(rects, placement) │
│ middlewareData = {} │
│ statefulPlacement = placement (e.g., 'bottom') │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ MIDDLEWARE LOOP │
│ for (let i = 0; i < middleware.length; i++) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ EXECUTE MIDDLEWARE[i] │
│ │
│ Input (MiddlewareState): │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ x, y, // Current coordinates │ │
│ │ initialPlacement, // Original (never changes) │ │
│ │ placement, // Current (may have flipped) │ │
│ │ middlewareData, // Accumulated data from previous runs │ │
│ │ rects, elements, platform, strategy │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Output (MiddlewareReturn): │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ x?, // New x (optional) │ │
│ │ y?, // New y (optional) │ │
│ │ data?, // Data to store under middlewareData[name] │ │
│ │ reset? // true | { placement?, rects? } │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ APPLY CHANGES │
│ │
│ x = nextX ?? x │
│ y = nextY ?? y │
│ middlewareData[name] = { ...middlewareData[name], ...data } │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ reset returned? │
└───────────────────────────────┘
│ │
YES NO
│ │
▼ ▼
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ RESET HANDLER │ │ NEXT MIDDLEWARE │
│ │ │ │
│ if (reset.placement) │ │ i++ → continue loop │
│ statefulPlacement = ... │ │ │
│ if (reset.rects) │ │ When i >= middleware.length: │
│ rects = await getElementRects│ │ → EXIT LOOP │
│ │ │ │
│ { x, y } = computeCoords(...) │ └─────────────────────────────────┘
│ i = -1 (restart loop) │ │
└─────────────────────────────────┘ │
│ │
└───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ FINAL OUTPUT │
│ return { │
│ x, // Final x coordinate │
│ y, // Final y coordinate │
│ placement: statefulPlacement,// Final placement (may differ from input) │
│ middlewareData // All accumulated middleware data │
│ } │
└─────────────────────────────────────────────────────────────────────────────┘
Complete Example
Let's trace through a real scenario where a tooltip needs to flip:
computePosition(buttonEl, tooltipEl, {placement: 'bottom',middleware: [offset(30), flip()],});
The Scenario
Imagine a button near the bottom of your screen at y = 500. It is 40px tall. We want to place an 80px tall tooltip below it, but the viewport is only 600px tall.
Initial Placement: With placement: 'bottom', the tooltip starts directly under the button at y = 540 (500 + 40).
{x: 250, // Centered under referencey: 540, // reference.y + reference.height = 500 + 40initialPlacement: 'bottom', // User's original intent (never changes)statefulPlacement: 'bottom', // Current working placementmiddlewareData: {}, // Empty - no middleware has run yetresetCount: 0}
The Problem: The tooltip's bottom edge would be at 620 (540 + 80). Since the viewport ends at 600, we already have an overflow!
Pass 1, i=0: offset(30)
The middleware pipeline runs. The offset middleware's job is to push the floating element away from the reference.
Since we are at the bottom, it adds 30 px to the y-coordinate.
{x: 250,y: 570, // Updated: 540 + 30initialPlacement: 'bottom',statefulPlacement: 'bottom',middlewareData: {offset: { mainAxis: 30, crossAxis: 0, placement: 'bottom' }},resetCount: 0}
Pass 1, i=1: flip()
The flip() middleware sees this overflow and effectively says, "There's no room down here!" It resets the placement to 'top' and recalculates everything to fit perfectly safely above the button.
{x: 250,y: 420, // Recomputed: reference.y - floating.height = 500 - 80initialPlacement: 'bottom', // Still 'bottom' - preserves user intentstatefulPlacement: 'top', // Changed! flip triggered thismiddlewareData: {offset: { mainAxis: 30, crossAxis: 0, placement: 'bottom' },flip: {index: 0,overflows: [{ placement: 'bottom', overflows: [50] }] // Recorded the overflow}},resetCount: 1 // Incremented to prevent infinite loops}// Loop restarts: i = -1 → i++ → i = 0
Pass 2, i=0: offset(30) again
Now that placement is 'top', offset pushes the floating element upward (away from the reference).
// STATE AFTER offset(30) - Pass 2{x: 250,y: 390, // Updated: 420 - 30 (negative direction for 'top')initialPlacement: 'bottom',statefulPlacement: 'top',middlewareData: {offset: { mainAxis: 30, crossAxis: 0, placement: 'top' }, // Updated with new placementflip: { index: 0, overflows: [...] }},resetCount: 1}
Pass 2, i=1: flip() again
{x: 250,y: 390, // UnchangedinitialPlacement: 'bottom',statefulPlacement: 'top',middlewareData: {offset: { mainAxis: 30, crossAxis: 0, placement: 'top' },flip: { index: 0, overflows: [...] } // Unchanged},resetCount: 1}
Final Result:
// FINAL OUTPUT returned by computePosition(){x: 250, // Final x coordinatey: 390, // Final y coordinateplacement: 'top', // Changed from 'bottom'!middlewareData: {offset: { mainAxis: 30, crossAxis: 0, placement: 'top' },flip: {index: 0,overflows: [{ placement: 'bottom', overflows: [50] }]}}}
Complete Flow Summary:
Notice how offset runs twice - once for each placement direction. This is why the reset mechanism exists: when placement changes, all middleware must reprocess with the new context. The initialPlacement ('bottom') is preserved so middleware can always reference the user's original intent, while placement reflects the actual working state.
Summary
The Floating UI middleware pipeline is a powerful pattern that transforms simple coordinate calculations into a flexible, extensible positioning system. Here are the key takeaways:
The Pipeline Pattern
- Middleware functions run sequentially in a
forloop, each receiving the current state and returning modifications - Each middleware has a single responsibility:
offsetadds gaps,fliphandles collisions,shiftkeeps elements in view - Data accumulates in
middlewareDataunder each middleware's namespace, allowing later middleware to access earlier results
The Reset Mechanism
- When a middleware returns
{ reset: true }or{ reset: { placement: '...' } }, the loop restarts fromi = 0 - This allows middleware like
flipto change placement and have all previous middleware (likeoffset) reprocess with the new context - A
resetCountlimit (50) prevents infinite loops from misconfigured middleware
State Management
initialPlacement: The user's original intent - never changes during processingplacement(statefulPlacement): The current working placement - may change via resetmiddlewareData: Accumulated data from all middleware runs, namespaced by middleware name
Why This Design Works
- Composable: Add or remove middleware without changing others
- Order-independent results: The reset mechanism ensures correct final positioning regardless of when collisions are detected
- Debuggable: The
middlewareDataobject provides full visibility into what each middleware contributed
This architecture enables Floating UI to handle complex positioning scenarios - from simple tooltips to elaborate dropdown menus with arrows, boundaries, and dynamic placement - all through the same unified pipeline.
