Positioning

Middlewares

Configure positioning middlewares to handle spacing, collisions, sizing, and boundary constraints.

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:

  1. Initial placement. VFloat calculates the unconstrained coordinates for the preferred placement (such as bottom-start).
  2. Clearance. offset pushes the panel away from the trigger.
  3. Side selection. flip or autoPlacement checks available space and selects a viable side.
  4. Boundary retention. shift slides the panel along the viewport edge to keep it on screen.
  5. Dimension constraints. size or matchWidth adjusts panel dimensions to fit the viewport or match the trigger.
  6. Visibility clipping. hide checks whether the anchor is scrolled out of view.
  7. Indicator alignment. arrow centers 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: 8 creates visual breathing room.
  • flip: true switches 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.

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:

StrategyWhen to useBehavior
flipDefault 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.
autoPlacementTooltips, 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:

  1. inline. Resolves multi-line client rects for wrapped text anchors.
  2. offset. Adds spacing between anchor and floating element before collision tests.
  3. flip or autoPlacement. Selects the side with sufficient room.
  4. shift. Nudges the surface along the cross-axis to stay within viewport bounds.
  5. matchWidth or size. Constrains width to match anchor or restricts height to available space.
  6. hide. Checks whether anchor or panel is clipped out of view.
  7. arrow. Aligns a pointed arrow indicator against the finalized panel coordinates.
  8. 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 offset runs before flip: Collision tests must evaluate whether the surface fits including its margin. If flip ran before offset, a panel might fit unshifted, only to overflow the viewport once offset pushes it outward.
  • Why flip runs before shift: Flipping determines which side of the anchor to use. Shifting slides along that side's boundary edge. If shift ran first, the panel would slide along the wrong side before flipping.
  • Why size runs before shift: Restricting height or width modifies element geometry. Shifting must clamp the element's actual constrained boundaries, not its unconstrained starting size.
  • Why arrow runs 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

Released under the MIT License. Copyright © 2026