TIL

Floating UI - Tree & Node

文章發表於
...

FloatingTree is Floating UI's solution for managing hierarchical relationships between floating elements (tooltips, popovers, menus) that are rendered outside their React component tree via portals.

FloatingTree reconstructs this relationship so components can:

  • Know their children (for Escape key handling)
  • Know their ancestors (for focus restoration)
  • Coordinate with siblings (for hover switching)

The Core Problem

Accessibility is one of the major challenges in the frontend world, and the UI library acts as its gatekeeper. Nowadays the web is increasingly composable, built from fundamental UI components. If the maintainer does not care enough, it can end up at the application level.

If the UI library lacks mechanisms to handle the child–parent relationship,

Questions that become impossible to answer:

  • "When Escape is pressed in child menu, should deeper level of child menu close?"
  • "When clicking outside, was the click inside ANY child menu?"

Floating UI Architecture

Storage

FloatingTreeStore is a class that store each tree's data and the communication methods,

FloatingTreeStore
import { createEventEmitter } from '../utils/createEventEmitter';
export class FloatingTreeStore {
// Flat array of all nodes
public readonly nodesRef: React.RefObject<Array<FloatingNodeType>> = {
current: []
};
public readonly events: FloatingEvents = createEventEmitter();
public addNode(node: FloatingNodeType) {
this.nodesRef.current.push(node);
}
public removeNode(node: FloatingNodeType) {
const index = this.nodesRef.current.findIndex((n) => n === node);
if (index !== -1) {
this.nodesRef.current.splice(index, 1);
}
}
}

In FloatingTreeStore, there are two patterns for inter-node communication. The first is a direct query, and the second is broadcasting to siblings.

The node structure (this.nodesRef.current) is flat. Each node has two three properties: parent_id, node_id and the context.

const nodes = [
{ id: '1', parentId: null },
{ id: '2', parentId: '1' },
{ id: '3', parentId: '1' },
{ id: '4', parentId: '2' },
];

The reason the data structure uses a flat array instead of a tree-like structure is that a flat array is simpler to implement and easier to reason about with portals.

// Adding a node with flat array
this.nodesRef.current.push(node);
// Adding a node with nested tree - requires traversal!
function addToTree(tree, parentId, newNode) {
if (tree.id === parentId) {
tree.children.push(newNode);
return true;
}
for (let child of tree.children) {
if (addToTree(child, parentId, newNode)) return true;
}
return false;
}

Lifecycle

Initialization

Once we have the concept in mind, the idea of using the floating UI in your menu or popover component becomes clearer:

Phase 1. Tree Initialization
function App() {
return (
<FloatingTree>
<Menu />
</FloatingTree>
);
}
Phase 2. Component Mounting - Register node & provide parent context
function Menu() {
const nodeId = useFloatingNodeId();
// nodeId = "floating-ui-1" (from React.useId)
// tree.addNode({ id: "floating-ui-1", parentId: null })
return (
<FloatingNode id={nodeId}>
<MenuTrigger />
<MenuPopup />
</FloatingNode>
);
}

Separate Structure from State

After component mounted the node only structural info { id: "floating-ui-1", parentId: null } and the context attachment will comes after it

Phase 3. Context Attachment
function Menu() {
const nodeId = useFloatingNodeId();
const context = useFloating({
nodeId,
open,
onOpenChange,
// ... positioning options
});
useIsoLayoutEffect(() => {
const node = tree?.nodesRef.current.find(n => n.id === nodeId);
if (node) {
node.context = context;
}
});
...
}
// Tree state after context attached
tree.nodesRef.current = [
{
id: 'floating-ui-1',
parentId: null,
context: {
open: false,
onOpenChange: fn,
elements: { floating: null, domReference: button },
dataRef: { current: {} }
}
}
];

Why separate?

  • Registration happens early (useFloatingNodeId hook)
  • Context happens later (after useFloating computes positioning)
  • Structure is stable, state changes frequently
  • Queries can handle missing context safely

Nested Menu Mounting

function MenuPopup() {
return (
<Portal>
<div>
<MenuItem>New</MenuItem>
<Menu> {/* Step 6: Nested menu mounts */}
<MenuTrigger>Open Recent</MenuTrigger>
<MenuPopup />
</Menu>
</div>
</Portal>
);
}
// After nested menu rendered
tree.nodesRef.current = [
{
id: 'floating-ui-1',
parentId: null,
context: { open: true, ... }
},
{
id: 'floating-ui-2',
parentId: 'floating-ui-1',
context: { open: false, ... }
}
];
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