Core Concepts

Hierarchy

How nested floating surfaces coordinate across teleport boundaries.

When you nest floating surfaces (like a submenu inside a dropdown or a select menu inside a modal), <Teleport to="body"> breaks their DOM ancestry.

In the DOM, the parent and child become flat siblings under <body>. Without coordination, clicking the child closes the parent, and pressing Escape dismisses the entire stack at once.

VFloat solves this by keeping parent-child links in the node itself.

Connecting parent and child

Every FloatingNode tracks its parent and child relationships. You link them in one of two ways:

1. Automatic injection (parent: "auto")

In multi-component setups, a child component automatically finds its nearest ancestor floating node using Vue provide and inject:

<!-- SubMenu.vue -->
<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);

// Automatically links to the parent menu node via Vue DI
const node = useFloatingNode({
  anchorEl,
  floatingEl,
  parent: "auto",
});

usePosition(node, { placement: "right-start" });
</script>

2. Explicit reference (parent: parentNode)

In a single component, pass the parent node directly:

const root = useFloatingNode({ anchorEl: rootBtn, floatingEl: rootMenu });
const sub = useFloatingNode({ anchorEl: subBtn, floatingEl: subMenu, parent: root });

If parent is omitted, the node remains completely standalone. It will never adopt or be adopted by other surfaces accidentally.

Complete nested menu setup

Here is a full working setup coordinating a root menu with a child submenu:

<script setup lang="ts">
import { ref } from "vue";
import {
  useClick,
  useEscapeKey,
  useFloatingNode,
  useHover,
  useOutsideClick,
  usePosition,
} from "v-float";

const rootAnchor = ref<HTMLElement | null>(null);
const rootFloating = ref<HTMLElement | null>(null);
const subAnchor = ref<HTMLElement | null>(null);
const subFloating = ref<HTMLElement | null>(null);

// 1. Establish node hierarchy
const root = useFloatingNode({ anchorEl: rootAnchor, floatingEl: rootFloating });
const sub = useFloatingNode({ anchorEl: subAnchor, floatingEl: subFloating, parent: root });

// 2. Position both surfaces
usePosition(root, { placement: "bottom-start" });
usePosition(sub, { placement: "right-start" });

// 3. Root menu interactions
useClick(root);
useOutsideClick(root);
useEscapeKey(root);

// 4. Submenu interactions with hover intent and safe corridor
useHover(sub, {
  delay: { open: 100, close: 200 },
  safePolygon: true,
});
useOutsideClick(sub);
useEscapeKey(sub);
</script>

<template>
  <button ref="rootAnchor" type="button">Options</button>

  <Teleport to="body">
    <div v-if="root.open.value" ref="rootFloating" class="menu">
      <button type="button">New document</button>
      <button ref="subAnchor" type="button">Share ›</button>
      <button type="button">Delete</button>
    </div>

    <div v-if="sub.open.value" ref="subFloating" class="submenu">
      <button type="button">Copy link</button>
      <button type="button">Email invite</button>
    </div>
  </Teleport>
</template>

What hierarchy coordinates

Outside click protection

When useOutsideClick checks whether a click happened outside a parent menu, it calls node.contains(event.target).

Instead of checking only its own DOM element, node.contains recursively checks open children. Clicking inside a teleported child submenu counts as inside the family, so the parent stays open.

Leaf-first Escape dismissal

When the user presses Escape, useEscapeKey checks whether the current node has any open children:

  • If open children exist, the node ignores the keypress.
  • Only the innermost leaf node closes.

Pressing Escape a second time closes the next parent up the chain, guaranteeing predictable step-by-step dismissal.

Cascading programmatic teardown

When the root menu closes (for example, on outside click or menuitem selection), you can tear down all open descendants using node.traverse:

function closeAll() {
  root.traverse(
    (descendant) => {
      descendant.open.value = false;
    },
    { order: "bottom-up" },
  );
  root.open.value = false;
}

Visiting descendants bottom-up ensures child cleanup hooks and state listeners run before the root unmounts.

Focus trap family coordination

When a modal dialog opens a select picker or date dropdown, useFocusTrap recognizes that focus has moved into a child floating surface via node.contains(). The parent modal does not steal focus back or trap the user inside the dialog boundary.

Automatic cleanup

When a child component unmounts, Vue's scope disposal automatically removes the child from the parent's children set and clears the parent reference.

Where to go next

Released under the MIT License. Copyright © 2026