Middlewares
Base placement gets floating surfaces close, but real layouts must adapt to screen edges, scroll containers, and dynamic dimensions. In VFloat, that adaptation is handled by middlewares.
This guide explains how middlewares work, how to configure them declaratively or as a pipeline, and how to solve common collision and sizing problems.
Mental model: successive geometric refinement
A middleware is not an isolated setting. It is a step in a sequential pipeline where each middleware receives the coordinates computed by earlier steps and refines them:
- Initial placement. VFloat calculates the unconstrained coordinates for the preferred placement (such as
bottom-start). - Clearance.
offsetpushes the panel away from the trigger. - Side selection.
fliporautoPlacementchecks available space and selects a viable side. - Boundary retention.
shiftslides the panel along the viewport edge to keep it on screen. - Dimension constraints.
sizeormatchWidthadjusts panel dimensions to fit the viewport or match the trigger. - Visibility clipping.
hidechecks whether the anchor is scrolled out of view. - Indicator alignment.
arrowcenters a pointed indicator against the stabilized panel geometry.
Here is the baseline stack used by most production dropdowns and tooltips:
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingNode, usePosition } from "v-float";
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(true);
const node = useFloatingNode({
anchorEl,
floatingEl,
open,
});
usePosition(node, {
placement: "bottom-start",
middlewares: {
offset: 8,
flip: true,
shift: { padding: 8 },
},
});
</script>
In this stack:
offset: 8creates visual breathing room.flip: trueswitches to the opposite side if the preferred placement overflows.shift: { padding: 8 }slides the panel along the viewport edge with an 8-pixel margin.
Two ways to configure middlewares
VFloat supports both declarative object configuration and imperative array pipelines.
Declarative object syntax (Recommended)
When you pass an object to middlewares, VFloat automatically executes the built-in middlewares in canonical order:
usePosition(node, {
placement: "bottom-start",
middlewares: {
offset: 8,
flip: true,
shift: { padding: 8 },
matchWidth: true,
},
});
Declarative syntax eliminates ordering bugs because VFloat guarantees each middleware runs in its mathematically optimal slot.
Imperative array pipeline
If you need a non-standard execution sequence or want to supply raw middleware instances, pass an array directly:
import { flip, offset, shift } from "@floating-ui/dom";
usePosition(node, {
placement: "bottom-start",
middlewares: [offset(8), shift({ padding: 8 }), flip()],
});
When passing an array, you take full control over the execution order.
Common positioning solutions
Add clearance from the trigger
Use offset to create space between the anchor and the floating panel:
middlewares: {
offset: 8,
}
You can also pass an object to set cross-axis offset (crossAxis) or alignment-axis offset (alignmentAxis):
middlewares: {
offset: {
mainAxis: 8,
crossAxis: 4,
},
}
Handle collisions: flip vs auto-placement
When the preferred placement runs out of room, you have two mutually exclusive strategies:
| Strategy | When to use | Behavior |
|---|---|---|
flip | Default choice. Best when you have an intended placement (e.g. dropdown below an input). | Checks the preferred side first. If it overflows, it tries the opposite side or explicit fallback placements. |
autoPlacement | Tooltips, contextual inspectors, and cursor panels. | Checks all eligible sides and places the panel on whichever side has the greatest available space. |
Using flip
middlewares: {
offset: 8,
flip: {
fallbackPlacements: ["top-start", "right-start"],
padding: 8,
},
}
Using autoPlacement
middlewares: {
offset: 8,
autoPlacement: {
allowedPlacements: ["top", "bottom", "right"],
padding: 8,
},
}
Choose flip when you have a preferred side, or autoPlacement when any roomy side is acceptable. Do not enable both at the same time.
Slide along boundaries with shift
Use shift to keep the floating panel within the visible viewport along its cross-axis:
middlewares: {
offset: 8,
flip: true,
shift: {
padding: 8, // Minimum distance from viewport edges
},
}
If the anchor moves near the screen edge, shift nudges the panel inward so it remains visible without changing placement sides.
Constrain dimensions and match trigger width
Use matchWidth to lock panel width to the trigger (standard for comboboxes and selects), and size to keep panel height within the viewport:
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingNode, usePosition } from "v-float";
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(true);
const node = useFloatingNode({ anchorEl, floatingEl, open });
usePosition(node, {
placement: "bottom-start",
middlewares: {
offset: 8,
flip: true,
shift: { padding: 8 },
matchWidth: true, // Automatically sets width to anchor width
size: {
apply({ availableHeight }) {
if (!floatingEl.value) return;
Object.assign(floatingEl.value.style, {
maxHeight: `${Math.max(100, availableHeight - 16)}px`,
});
},
},
},
});
</script>
<template>
<button ref="anchorEl" class="select-trigger">Select an option</button>
<div v-if="open" ref="floatingEl" class="select-menu">
<!-- Scrollable list items -->
</div>
</template>
<style scoped>
.select-menu {
overflow-y: auto;
}
</style>
Applying availableHeight inside size.apply guarantees long listboxes scroll gracefully rather than expanding off screen.
Detect scrolled-off anchors with hide
Use hide when the anchor is inside a scroll container and may scroll out of view while the floating surface remains open:
<script setup lang="ts">
import { ref } from "vue";
import { useFloatingNode, usePosition } from "v-float";
const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(true);
const node = useFloatingNode({ anchorEl, floatingEl, open });
const { middlewareData } = usePosition(node, {
placement: "top",
middlewares: {
offset: 8,
hide: true,
},
});
</script>
<template>
<div
v-if="open"
ref="floatingEl"
class="tooltip"
:style="{
visibility: middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
}"
>
Tooltip text
</div>
</template>
When referenceHidden is true, the anchor element is clipped by an ancestor scroll container. Hiding the floating surface prevents detached floating panels from hovering over unrelated content.
Canonical pipeline sequence
When you configure middlewares via the declarative object syntax, VFloat executes them in this canonical order:
inline. Resolves multi-line client rects for wrapped text anchors.offset. Adds spacing between anchor and floating element before collision tests.fliporautoPlacement. Selects the side with sufficient room.shift. Nudges the surface along the cross-axis to stay within viewport bounds.matchWidthorsize. Constrains width to match anchor or restricts height to available space.hide. Checks whether anchor or panel is clipped out of view.arrow. Aligns a pointed arrow indicator against the finalized panel coordinates.custom. Runs user-provided custom middlewares.
Why order matters
Middlewares receive the transformed coordinates of preceding steps. If the order changes, calculation results diverge:
- Why
offsetruns beforeflip: Collision tests must evaluate whether the surface fits including its margin. Ifflipran beforeoffset, a panel might fit unshifted, only to overflow the viewport onceoffsetpushes it outward. - Why
flipruns beforeshift: Flipping determines which side of the anchor to use. Shifting slides along that side's boundary edge. Ifshiftran first, the panel would slide along the wrong side before flipping. - Why
sizeruns beforeshift: Restricting height or width modifies element geometry. Shifting must clamp the element's actual constrained boundaries, not its unconstrained starting size. - Why
arrowruns last: Arrow alignment depends on the final, stabilized positions of both anchor and floating panel after all collision and clamping shifts have completed.
Authoring custom middlewares
You can create custom middlewares to apply project-specific positioning logic, such as coordinate snapping or boundary clamps:
import type { Middleware } from "@floating-ui/dom";
export function snapToGrid(step = 8): Middleware {
return {
name: "snapToGrid",
fn({ x, y }) {
return {
x: Math.round(x / step) * step,
y: Math.round(y / step) * step,
};
},
};
}
To run a custom middleware with declarative options, pass it via custom:
usePosition(node, {
placement: "bottom-start",
middlewares: {
offset: 8,
flip: true,
shift: { padding: 8 },
custom: [snapToGrid(8)],
},
});
Custom middlewares in custom execute after all built-in middlewares, receiving the fully resolved coordinates.
Where to go next
- Read Virtual Anchors to position against pointer coordinates and synthetic elements.
- Read Animations and Transitions to add smooth motion without layout jumps.
- Inspect the
usePositionAPI Reference for detailed composable signatures and return types. - Inspect the
useArrowAPI Reference to connect pointed indicators.