TIL

Floating UI - Middleware Deep Dive

文章發表於
...

In , we explored the pipeline architecture, which includes the loop, reset mechanism, and data accumulation. Today let's dive into the offset, flip and shift middleware.

MiddlewarePurposeOverflow CheckReset Behavior
offsetCreate gapNo overflow checkNo reset
shiftSlide to fitUses detectOverflowNo reset
flipChange sideUses detectOverflowTRIGGERS RESET

offset

Let's look at our first middleware, the offset middleware creates visual breathing room between reference and floating elements. It's the simplest because it doesn't check overflow and never triggers reset.

floating-ui/.../middleware/offset.ts
export const offset = (options: OffsetOptions = 0): Middleware => ({
name: 'offset',
options,
async fn(state) {
const {x, y, placement, middlewareData} = state;
const diffCoords = await convertValueToCoords(state, options);
// This enables the early bail-out on subsequent passes:
// When `arrow` middleware triggers a reset due to alignment issues, `offset` checks if the placement is unchanged.
// If so, it skips recalculation to avoid infinite loops.
if (
placement === middlewareData.offset?.placement &&
middlewareData.arrow?.alignmentOffset
) {
return {};
}
return {
x: x + diffCoords.x,
y: y + diffCoords.y,
data: {
...diffCoords,
placement,
},
};
},
});

The heavy lifting happens in convertValueToCoords:

export async function convertValueToCoords(
state: MiddlewareState,
options: OffsetOptions,
): Promise<Coords> {
const {placement, platform, elements} = state;
const rtl = await platform.isRTL?.(elements.floating);
const side = getSide(placement); // 'top' | 'right' | 'bottom' | 'left'
const alignment = getAlignment(placement); // 'start' | 'end' | null
const isVertical = getSideAxis(placement) === 'y';
// Determine push direction based on placement
const mainAxisMulti = ['top', 'left'].includes(side) ? -1 : 1;
const crossAxisMulti = rtl && isVertical ? -1 : 1;
const rawValue = evaluate(options, state);
let {mainAxis, crossAxis, alignmentAxis} =
typeof rawValue === 'number'
? {mainAxis: rawValue, crossAxis: 0, alignmentAxis: null}
: {
mainAxis: rawValue.mainAxis || 0,
crossAxis: rawValue.crossAxis || 0,
alignmentAxis: rawValue.alignmentAxis,
};
// alignmentAxis overrides crossAxis for aligned placements
if (alignment && typeof alignmentAxis === 'number') {
crossAxis = alignment === 'end' ? alignmentAxis * -1 : alignmentAxis;
}
// Convert to x,y based on whether placement is vertical
return isVertical
? {x: crossAxis * crossAxisMulti, y: mainAxis * mainAxisMulti}
: {x: mainAxis * mainAxisMulti, y: crossAxis * crossAxisMulti};
}

The Three Axes

offset accepts three different axis values:

AxisDescriptionUse Case
mainAxisDistance away from referenceGap between button and tooltip
crossAxisShift along the reference edgeHorizontal nudge for bottom placement
alignmentAxisLike crossAxis, but inverts for endConsistent behavior for -start/-end

Understanding mainAxisMulti

The mainAxisMulti determines which direction "away from reference" means:

const mainAxisMulti = ['top', 'left'].includes(side) ? -1 : 1;

Practical Examples

// Simple number: applies to mainAxis only
offset(10)
// → { mainAxis: 10, crossAxis: 0 }
// Object form: full control
offset({ mainAxis: 10, crossAxis: 5 })
// With alignmentAxis for aligned placements
offset({ mainAxis: 10, alignmentAxis: 5 })
// For 'bottom-start': shifts right
// For 'bottom-end': shifts left (inverted!)

Before diving into shift and flip middleware, we need to understand their shared foundation: detectOverflow.

The Shared Foundation: detectOverflow

Both shift and flip need to answer the same question: "How much does the floating element overflow each edge of the viewport?"

The Core Formula

The convention: Positive values mean overflow, negative values mean available space.

type SideObject = {
top: number;
right: number;
bottom: number;
left: number;
};
function detectOverflow(floatingRect: Rect, boundaryRect: Rect): SideObject {
return {
top: boundaryRect.y - floatingRect.y,
right: (floatingRect.x + floatingRect.width) - (boundaryRect.x + boundaryRect.width),
bottom: (floatingRect.y + floatingRect.height) - (boundaryRect.y + boundaryRect.height),
left: boundaryRect.x - floatingRect.x,
};
}

For right and bottom, the floating edge exceeds the boundary when it's greater:

floatingRight (680) > boundaryRight (600) // overflow!
formula: floatingRight - boundaryRight = 680 - (600) = 80

For top and left, the floating edge exceeds the boundary when it's less (goes negative):

We flip the subtraction order so positive always means overflow.

floatingTop (80) < boundaryTop (140) // overflow!
formula: boundaryTop - floatingTop = 140 - (80) = 60

shift

The shift middleware keeps the floating element within viewport bounds by sliding it along edges without changing placement. Unlike flip which switches to a different side when there's overflow, shift maintains the same placement direction and simply adjusts the position to fit within the boundary.

Key Characteristics

  • Uses detectOverflow to measure overflow on each edge
  • Modifies x/y coordinates directly by sliding the floating element
  • Never triggers reset, placement stays the same throughout
  • Operates on mainAxis by default, crossAxis disabled (must opt-in)
  • Works with limiters like limitShift to constrain the shifting behavior
floating-ui/.../middleware/shift.ts
export const shift = (
options: ShiftOptions | Derivable<ShiftOptions> = {},
): Middleware => ({
name: 'shift',
options,
async fn(state) {
const {x, y, placement, platform} = state;
const {
mainAxis: checkMainAxis = true,
crossAxis: checkCrossAxis = false, // ← Disabled by default!
limiter = {fn: ({x, y}: Coords) => ({x, y})},
...detectOverflowOptions
} = evaluate(options, state);
const coords = {x, y};
const overflow = await platform.detectOverflow(state, detectOverflowOptions);
// Determine which axis is which based on placement
const crossAxis = getSideAxis(getSide(placement)); // 'x' or 'y'
const mainAxis = getOppositeAxis(crossAxis); // opposite
let mainAxisCoord = coords[mainAxis];
let crossAxisCoord = coords[crossAxis];
// Shift on mainAxis (parallel to reference edge)
if (checkMainAxis) {
const minSide = mainAxis === 'y' ? 'top' : 'left';
const maxSide = mainAxis === 'y' ? 'bottom' : 'right';
const min = mainAxisCoord + overflow[minSide];
const max = mainAxisCoord - overflow[maxSide];
mainAxisCoord = clamp(min, mainAxisCoord, max);
}
// Shift on crossAxis (toward/away from reference)
if (checkCrossAxis) {
const minSide = crossAxis === 'y' ? 'top' : 'left';
const maxSide = crossAxis === 'y' ? 'bottom' : 'right';
const min = crossAxisCoord + overflow[minSide];
const max = crossAxisCoord - overflow[maxSide];
crossAxisCoord = clamp(min, crossAxisCoord, max);
}
// Apply limiter (e.g., limitShift)
const limitedCoords = limiter.fn({
...state,
[mainAxis]: mainAxisCoord,
[crossAxis]: crossAxisCoord,
});
return {
...limitedCoords,
data: {
x: limitedCoords.x - x,
y: limitedCoords.y - y,
enabled: {
[mainAxis]: checkMainAxis,
[crossAxis]: checkCrossAxis,
},
},
};
},
});

How shift Uses mainAxis and crossAxis

The shift middleware uses the terms "mainAxis" and "crossAxis", but the axis assignment is swapped compared to offset. Look at the code:

const crossAxis = getSideAxis(getSide(placement)); // For 'bottom' → 'y'
const mainAxis = getOppositeAxis(crossAxis); // opposite of 'y' → 'x'

For placement: 'bottom', getSideAxis returns 'y' (the axis the floating element sits along — perpendicular to the reference edge). Then shift calls this the crossAxis and takes the opposite ('x') as the mainAxis. This is the reverse of offset, where mainAxis is Y for bottom placement.

The reason: each middleware defines "main" based on its primary job:

MiddlewarePrimary jobmainAxis for 'bottom'crossAxis for 'bottom'
offsetPush away from referenceY (away direction)X (along edge)
shiftSlide along reference edgeX (slide direction)Y (toward/away)

So when the shift code says checkMainAxis = true, it means "slide along the reference edge" (X for bottom placement). And checkCrossAxis = false means "don't adjust the gap distance" (Y for bottom placement).

By default, shift only operates on the mainAxis (checkMainAxis = true), sliding the floating element along the reference edge to keep it in view. The crossAxis is disabled by default (checkCrossAxis = false) because adjusting it would change the gap between the floating element and reference, which is typically controlled by the offset middleware.

The Clamping Logic

The core algorithm uses clamp(min, value, max) to constrain coordinates within valid bounds:

// For mainAxis shifting (e.g., horizontal for 'bottom' placement)
const min = mainAxisCoord + overflow[minSide]; // How far can we go left?
const max = mainAxisCoord - overflow[maxSide]; // How far can we go right?
mainAxisCoord = clamp(min, mainAxisCoord, max);

Understanding the formula:

  • overflow[minSide] is negative when there's space available, positive when overflowing
  • overflow[maxSide] is positive when overflowing, negative when there's space
  • Adding negative overflow expands the range, adding positive overflow shrinks it
  • The clamp ensures the coordinate stays within valid bounds

Example trace for placement: 'bottom':

Initial position:
- x = 320 (current floating element's x coordinate)
- viewport boundary: left = 0, right = 600
- floating element width = 300
Detected overflow:
- overflow.left = 0 - 320 = -320 (negative = has 320px of space on the left)
- overflow.right = (320 + 300) - 600 = 20 (positive = overflowing 20px on the right)
Calculate valid range:
- mainAxis = 'x' (horizontal for bottom placement)
- minSide = 'left', maxSide = 'right'
- min = 320 + (-320) = 0 // Can go as far left as x=0
- max = 320 - 20 = 300 // Must stop at x=300 to avoid right overflow
Apply clamp:
- clamp(0, 320, 300) = 300 // Shifted left to x=300!
- Result: Floating element slides 20px to the left to fit in viewport

Why crossAxis is Disabled by Default

The crossAxis controls the distance between the floating element and the reference. Enabling it would allow the floating element to move closer to or further from the reference to avoid overflow, which could:

  1. Conflict with offset middleware - The gap is usually intentionally set via offset
  2. Cause unexpected visual jumps - The floating element might suddenly appear much closer or further away
  3. Break alignment expectations - For tooltip-like UIs, users expect consistent spacing

If you need crossAxis shifting, you must explicitly enable it:

shift({ crossAxis: true })

The limitShift Limiter

limitShift is a limiter (not a middleware) that constrains how far shift can move the floating element, keeping it tethered to the reference element. Without limits, a wide tooltip might shift so far that it visually detaches from its reference.

Looking at the visualization above, if the floating element were allowed to shift all the way to x=0, it would be completely separated from the reference element, breaking the visual connection between them.

How they work together:

  • shift: "How far do I need to move to fit in the viewport?"
  • limitShift: "You can't move beyond the reference element's edges"
shift({
limiter: limitShift({ offset: 10 })
})

The limiter ensures the floating element stays aligned with at least one edge of the reference, even if that means it overflows the viewport slightly. This maintains the visual relationship between the two elements.

floating-ui/.../middleware/shift.ts
export const limitShift = (
options: LimitShiftOptions | Derivable<LimitShiftOptions> = {},
): {
options: any;
fn: (state: MiddlewareState) => Coords;
} => ({
options,
fn(state) {
const {x, y, placement, rects, middlewareData} = state;
const {
offset = 0,
mainAxis: checkMainAxis = true,
crossAxis: checkCrossAxis = true,
} = evaluate(options, state);
const coords = {x, y};
const crossAxis = getSideAxis(placement);
const mainAxis = getOppositeAxis(crossAxis);
let mainAxisCoord = coords[mainAxis];
let crossAxisCoord = coords[crossAxis];
const rawOffset = evaluate(offset, state);
const computedOffset =
typeof rawOffset === 'number'
? {mainAxis: rawOffset, crossAxis: 0}
: {mainAxis: 0, crossAxis: 0, ...rawOffset};
if (checkMainAxis) {
const len = mainAxis === 'y' ? 'height' : 'width';
const limitMin =
rects.reference[mainAxis] -
rects.floating[len] +
computedOffset.mainAxis;
const limitMax =
rects.reference[mainAxis] +
rects.reference[len] -
computedOffset.mainAxis;
if (mainAxisCoord < limitMin) {
mainAxisCoord = limitMin;
} else if (mainAxisCoord > limitMax) {
mainAxisCoord = limitMax;
}
}
if (checkCrossAxis) {
const len = mainAxis === 'y' ? 'width' : 'height';
const isOriginSide = originSides.has(getSide(placement));
const limitMin =
rects.reference[crossAxis] -
rects.floating[len] +
(isOriginSide ? middlewareData.offset?.[crossAxis] || 0 : 0) +
(isOriginSide ? 0 : computedOffset.crossAxis);
const limitMax =
rects.reference[crossAxis] +
rects.reference[len] +
(isOriginSide ? 0 : middlewareData.offset?.[crossAxis] || 0) -
(isOriginSide ? computedOffset.crossAxis : 0);
if (crossAxisCoord < limitMin) {
crossAxisCoord = limitMin;
} else if (crossAxisCoord > limitMax) {
crossAxisCoord = limitMax;
}
}
return {
[mainAxis]: mainAxisCoord,
[crossAxis]: crossAxisCoord,
} as Coords;
},
});

The constraint: Floating element must always overlap with reference on the mainAxis:

Example trace for placement: 'bottom':

Setup:
- Reference: x = 200, width = 100 (ends at 300)
- Floating: width = 300
- shift wants to move floating to x = 50 (to fit viewport)
Calculate limitMin and limitMax:
- mainAxis = 'x' (horizontal for bottom placement)
- len = 'width' (floating width = 300)
limitMin = reference.x - floating.width
= 200 - 300 = -100
(Floating's left edge can go as far left as x = -100)
limitMax = reference.x + reference.width
= 200 + 100 = 300
(Floating's left edge can go as far right as x = 300)
Apply constraints:
- shift calculated: x = 50
- Check: is 50 < -100? No
- Check: is 50 > 300? No
- Result: x = 50 ✓ (within limits, allow the shift)
If shift calculated x = -200:
- Check: is -200 < -100? Yes!
- Clamp to limitMin: x = -100
- Result: Floating stops at x = -100 to maintain overlap with reference

flip

The flip middleware changes the floating element's placement when it overflows. Unlike shift which slides the element along edges, flip moves the element to the opposite side of the reference. This is a more dramatic change but often necessary when there's simply no room on the original side.

The Core Concept

Imagine a tooltip positioned below a button near the bottom edge of the viewport. There's not enough space below, but plenty above. flip detects this and switches the placement from 'bottom' to 'top'.

Key Characteristics

  • Uses detectOverflow to measure overflow
  • Tracks state across multiple passes via middlewareData
  • Triggers reset with new placement
  • Tries multiple fallback placements before settling
floating-ui/.../middleware/flip.ts
export const flip = (options: FlipOptions = {}): Middleware => ({
name: 'flip',
options,
async fn(state) {
const {
placement,
middlewareData,
rects,
initialPlacement,
platform,
elements,
} = state;
const {
mainAxis: checkMainAxis = true,
crossAxis: checkCrossAxis = true,
fallbackPlacements: specifiedFallbackPlacements,
fallbackStrategy = 'bestFit',
fallbackAxisSideDirection = 'none',
flipAlignment = true,
...detectOverflowOptions
} = evaluate(options, state);
// EARLY BAIL-OUT: Arrow caused alignment offset, flip already did its job
if (middlewareData.arrow?.alignmentOffset) {
return {};
}
const side = getSide(placement);
const initialSideAxis = getSideAxis(initialPlacement);
const isBasePlacement = getSide(initialPlacement) === initialPlacement;
const rtl = await platform.isRTL?.(elements.floating);
// Build the list of placements to try
const fallbackPlacements =
specifiedFallbackPlacements ||
(isBasePlacement || !flipAlignment
? [getOppositePlacement(initialPlacement)] // e.g., 'bottom' → 'top'
: getExpandedPlacements(initialPlacement)); // e.g., 'bottom-start' → ['bottom-end', 'top-start', 'top-end']
// Add perpendicular axis placements if configured
if (fallbackAxisSideDirection !== 'none') {
fallbackPlacements.push(
...getOppositeAxisPlacements(initialPlacement, flipAlignment, fallbackAxisSideDirection, rtl)
);
}
const placements = [initialPlacement, ...fallbackPlacements];
// Detect overflow for current placement
const overflow = await platform.detectOverflow(state, detectOverflowOptions);
const overflows = [];
let overflowsData = middlewareData.flip?.overflows || [];
if (checkMainAxis) {
overflows.push(overflow[side]);
}
if (checkCrossAxis) {
const sides = getAlignmentSides(placement, rects, rtl);
overflows.push(overflow[sides[0]], overflow[sides[1]]);
}
// Accumulate overflow data
overflowsData = [...overflowsData, {placement, overflows}];
// Check if current placement overflows
if (!overflows.every((side) => side <= 0)) {
const nextIndex = (middlewareData.flip?.index || 0) + 1;
const nextPlacement = placements[nextIndex];
if (nextPlacement) {
// Try next placement — TRIGGER RESET
return {
data: {
index: nextIndex,
overflows: overflowsData,
},
reset: {
placement: nextPlacement,
},
};
}
// No more placements to try — use fallback strategy
let resetPlacement = overflowsData
.filter((d) => d.overflows[0] <= 0)
.sort((a, b) => a.overflows[1] - b.overflows[1])[0]?.placement;
if (!resetPlacement) {
switch (fallbackStrategy) {
case 'bestFit': {
// Find placement with least total overflow
const placement = overflowsData
.map((d) => [
d.placement,
d.overflows
.filter((overflow) => overflow > 0)
.reduce((acc, overflow) => acc + overflow, 0),
])
.sort((a, b) => a[1] - b[1])[0]?.[0];
if (placement) {
resetPlacement = placement;
}
break;
}
case 'initialPlacement':
resetPlacement = initialPlacement;
break;
}
}
if (placement !== resetPlacement) {
return {
reset: {
placement: resetPlacement,
},
};
}
}
return {};
},
});

The Placement Iteration Strategy

The flip middleware doesn't just try the opposite side — it builds a priority list of fallback placements and tries them one by one. The placements array depends on whether you're using a base placement ('bottom') or an aligned placement ('bottom-start').

// For initialPlacement: 'bottom' (base placement)
placements = ['bottom', 'top']
// For initialPlacement: 'bottom-start' with flipAlignment: true
placements = ['bottom-start', 'bottom-end', 'top-start', 'top-end']

Why the difference? For aligned placements, flip also considers flipping the alignment (start ↔ end) on the same side before jumping to the opposite side. This often produces a better result than immediately flipping to the opposite side.

  1. Build placements array from initialPlacement
  2. For each placement: detect overflow → if overflows, increment index and RESET
  3. Store overflow data in middlewareData.flip.overflows
  4. When a placement fits (all overflows ≤ 0), stop and return

Pass-by-pass trace with real values:

Initial state: placement = 'bottom-start'
placements = ['bottom-start', 'bottom-end', 'top-start', 'top-end']
═══════════════════════════════════════════════════════════════
PASS 1: placement = 'bottom-start'
═══════════════════════════════════════════════════════════════
middlewareData.flip = undefined (first pass)
overflow = detectOverflow() → { top: -100, right: 10, bottom: 50, left: -20 }
checkMainAxis: overflows.push(overflow['bottom']) → [50]
checkCrossAxis: overflows.push(overflow['left'], overflow['right']) → [50, -20, 10]
Check: overflows.every(v => v <= 0)? → [50, -20, 10].every(v => v <= 0) → false!
nextIndex = 0 + 1 = 1
nextPlacement = placements[1] = 'bottom-end'
return {
data: { index: 1, overflows: [{ placement: 'bottom-start', overflows: [50, -20, 10] }] },
reset: { placement: 'bottom-end' } // ← TRIGGERS PIPELINE RESTART
}
═══════════════════════════════════════════════════════════════
PASS 2: placement = 'bottom-end' (after reset)
═══════════════════════════════════════════════════════════════
middlewareData.flip = { index: 1, overflows: [...] }
overflow = detectOverflow() → { top: -100, right: -20, bottom: 50, left: 10 }
overflows = [50, 10, -20] // still overflows on bottom!
Check: [50, 10, -20].every(v => v <= 0)? → false!
nextIndex = 1 + 1 = 2
nextPlacement = placements[2] = 'top-start'
return { reset: { placement: 'top-start' } }
═══════════════════════════════════════════════════════════════
PASS 3: placement = 'top-start' (after reset)
═══════════════════════════════════════════════════════════════
overflow = detectOverflow() → { top: 20, right: 10, bottom: -150, left: -20 }
overflows = [20, -20, 10] // overflows on top!
Check: [20, -20, 10].every(v => v <= 0)? → false!
nextIndex = 2 + 1 = 3
nextPlacement = placements[3] = 'top-end'
return { reset: { placement: 'top-end' } }
═══════════════════════════════════════════════════════════════
PASS 4: placement = 'top-end' (after reset)
═══════════════════════════════════════════════════════════════
overflow = detectOverflow() → { top: -30, right: -20, bottom: -150, left: 10 }
overflows = [-30, 10, -20]
Wait... overflow['left'] = 10 is positive! But this is crossAxis...
Actually let me recalculate with better values:
overflow = detectOverflow() → { top: -30, right: -20, bottom: -150, left: -10 }
overflows = [-30, -10, -20]
Check: [-30, -10, -20].every(v => v <= 0)? → true! ✓
return {} // No reset needed, we found a placement that fits!
═══════════════════════════════════════════════════════════════
FINAL RESULT: placement = 'top-end'
═══════════════════════════════════════════════════════════════

The overflowsData Accumulator

Here's the key insight: each pipeline pass is independent. When flip triggers a reset, the entire middleware pipeline restarts from scratch. So how does flip remember which placements it already tried?

The answer is middlewareData — a shared object that persists across resets. Each pass, flip reads its previous data, adds the current placement's overflow info, and stores it back.

// On each pass, flip reads previous data and adds current placement
let overflowsData = middlewareData.flip?.overflows || []; // Read from previous passes
overflowsData = [...overflowsData, {placement, overflows}]; // Add current pass data
// Then stores it in the return value
return {
data: { index: nextIndex, overflows: overflowsData }, // Persists to middlewareData.flip
reset: { placement: nextPlacement }
};

The data structure after all passes (when no placement fits perfectly):

// If we had to exhaust all placements, overflowsData would contain:
overflowsData = [
{ placement: 'bottom-start', overflows: [50, -20, 10] }, // mainAxis: 50, crossAxis: -20, 10
{ placement: 'bottom-end', overflows: [50, 10, -20] }, // mainAxis: 50, crossAxis: 10, -20
{ placement: 'top-start', overflows: [20, -20, 10] }, // mainAxis: 20, crossAxis: -20, 10
{ placement: 'top-end', overflows: [15, 5, -10] } // mainAxis: 15, crossAxis: 5, -10
]
// This data is then used by the fallback strategy to pick the "best" placement

Fallback Strategies

When no placement fits perfectly (all placements overflow), flip must choose the "least bad" option. This is where the fallbackStrategy option comes into play.

StrategyBehaviorWhen to use
'bestFit' (default)Choose placement with least total overflowMost cases — minimizes visual clipping
'initialPlacement'Give up and use original placementWhen consistency is more important than fit

The algorithm step by step:

case 'bestFit': {
const placement = overflowsData
// Step 1: Transform each placement into [placement, totalPositiveOverflow]
.map((d) => [
d.placement,
d.overflows
.filter((overflow) => overflow > 0) // Only count positive (actual overflow)
.reduce((acc, overflow) => acc + overflow, 0), // Sum them up
])
// Step 2: Sort by total overflow (ascending)
.sort((a, b) => a[1] - b[1])
// Step 3: Take the first one (least overflow)
[0]?.[0];
if (placement) {
resetPlacement = placement;
}
break;
}

Why filter only positive overflows? Negative values mean there's extra space on that side — that's not a problem. We only care about sides where the element actually sticks out of the boundary.

The arrow?.alignmentOffset Bail-out

This is one of the most subtle parts of the flip middleware — a safeguard against infinite loops caused by the interaction between flip and arrow middleware.

// At the very start of flip's fn()
if (middlewareData.arrow?.alignmentOffset) {
return {}; // Bail out immediately, don't try to flip again
}

The problem scenario:

Imagine a tiny button (40px wide) with a tooltip that has an arrow. The tooltip is positioned at 'bottom-start', but the arrow can't center itself over the button because the button is too small.

The sequence without the bail-out:

  1. flip detects overflow → changes placement
  2. arrow can't center itself → triggers reset with alignmentOffset
  3. flip runs again on the new placement → tries to flip again
  4. arrow still can't center → triggers another reset
  5. Repeat forever (until Floating UI's built-in 50-iteration limit kicks in)

The fix: By checking middlewareData.arrow?.alignmentOffset at the very start, flip knows that:

  • Arrow middleware already ran and had to offset itself
  • The current placement is the "final answer" from arrow's perspective
  • Flipping again would just restart the cycle

This is a great example of how middlewares need to coordinate through middlewareData to avoid stepping on each other's toes.

Why flip Comes After shift in Radix

middleware: [
offset(...),
shift(...), // Try sliding first
flip(...), // Only flip if shift couldn't fix it
]

Philosophy: Flipping is jarring (tooltip jumps to opposite side). Shifting is subtle (tooltip slides). Try the subtle fix first.

User experience with shift → flip:
1. Tooltip appears at 'bottom'
2. Near edge? Slides left/right (barely noticeable)
3. Still overflows? THEN flip to 'top' (only when necessary)

Playgound

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