---
title: Portals
source: 'https://academia.sh/en/courses/component-based-development/transfer'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

# Portals

A subtree keeping its place in the component tree while rendering to a different node in the document tree; clipping and stacking constraints, the two trees separating, the event path and context resolution following the component tree, the conditions on the target node, and accessibility obligations.

The previous seven lessons determined **when** a subtree renders: when state changes,
when a dependency changes, when an error is caught, when data arrives. One question
remains, and it concerns not time but place: **where** does a subtree render?

The default answer is obvious. A component's position in the component tree determines
its position in the document tree; the nodes a component produces go inside its parent
component's nodes. There is a point on the measurement station page where this default
does not work: the dropdown list in the filter panel. The list is logically part of the
panel — it reads the panel's state, it produces the panel's events — but visually it
needs to escape the panel's box.

The mechanism this lesson establishes is called a **portal**: keeping a subtree's place
in the component tree while changing its place in the document tree.

## Two Constraints: Clipping and Stacking

The problem is not a preference; it is two measurable constraints.

```js
// clipping-and-stacking.mjs — the two visual constraints a portal solves
const intersect = (a, b) => ({ top: Math.max(a.top, b.top), bottom: Math.min(a.bottom, b.bottom) });
const height = (rect) => Math.max(0, rect.bottom - rect.top);

const dropdownList = { top: 280, bottom: 520 };     // 240 pixels tall
const filterPanel = { top: 120, bottom: 320 };      // clips overflowing content
const viewport = { top: 0, bottom: 800 };

console.log("clipping:");
for (const [label, clipper] of [["inside the panel", filterPanel], ["portaled (body root)", viewport]]) {
  const clipped = intersect(dropdownList, clipper);
  console.log(
    `  ${label.padEnd(22)} visible ${String(height(clipped)).padStart(3)}/${height(dropdownList)} px`,
    `(${clipped.top}-${clipped.bottom})`,
  );
}

// Stacking chain: each stacking context's z-index value, starting from the root.
const chains = {
  "dropdown-list (inside panel)": [1, 9999],   // the panel establishes its own stacking context
  "dropdown-list (portaled)": [9999],
  "table-header (sticky)": [5],
};

function compare(a, b) {
  const n = Math.max(a.length, b.length);
  for (let i = 0; i < n; i++) {
    const x = i < a.length ? a[i] : -Infinity;
    const y = i < b.length ? b[i] : -Infinity;
    if (x !== y) return x < y ? "BELOW" : "ABOVE";
  }
  return "EQUAL";
}

console.log("stacking order (relative to table header):");
const header = chains["table-header (sticky)"];
for (const label of ["dropdown-list (inside panel)", "dropdown-list (portaled)"]) {
  const z = chains[label];
  console.log(`  ${label.padEnd(30)} chain=[${z.join(", ")}] -> ${compare(z, header)}`);
}
```

```
clipping:
  inside the panel       visible  40/240 px (280-320)
  portaled (body root)   visible 240/240 px (280-520)
stacking order (relative to table header):
  dropdown-list (inside panel)   chain=[1, 9999] -> BELOW
  dropdown-list (portaled)       chain=[9999] -> ABOVE
```

The first constraint is clipping. If the filter panel cuts off content that overflows its
own box, only forty of the list's two hundred forty pixels are visible. This is not a
styling mistake; the clipping the panel's own scroll behavior requires also cuts the
list.

The second constraint is stacking. The **stacking context** defined in the Visual
Presentation with CSS course makes an element's order number meaningful only within its
own context. If the panel establishes its own context, the nine thousand nine hundred
ninety-nine inside it is tied to the panel's one in the root context. The comparison is
made from the chain's first element: one is less than five, so the list stays below the
sticky table header. Raising the number does not help; the raised number is in the wrong
context.

Both constraints arise from a single cause: the subtree's **position** within the
document. The way to change the position is either to break the component tree — tearing
the dropdown list out of the panel's logic and moving it to the page root — or to use a
portal.

## The Two Trees Separating

A portal leaves the subtree's place in the component tree exactly as it is and changes
only its target in the document tree. The result is two trees that diverge from that
point on.

```js
// portal.mjs — the component tree and document tree separating
// dropdown-list is filter-panel's child in the component tree, and body-root's child in the document tree.
const COMPONENT_PARENT = {
  "option-temperature": "dropdown-list",
  "dropdown-list": "filter-panel",
  "filter-button": "filter-panel",
  "filter-panel": "page",
  "title": "page",
  "measurement-table": "page",
  "page": null,
};

const DOCUMENT_PARENT = {
  "option-temperature": "dropdown-list",
  "dropdown-list": "body-root",
  "body-root": "document",
  "filter-button": "filter-panel",
  "filter-panel": "app-root",
  "title": "app-root",
  "measurement-table": "app-root",
  "app-root": "document",
  "document": null,
};

const PROVIDERS = { page: "celsius" };   // the unit context is provided at the page
const DEFAULT_UNIT = "kelvin";

const path = (map, node) => {
  const trail = [];
  for (let n = node; n; n = map[n]) trail.push(n);
  return trail;
};

const TARGET = "option-temperature";
const componentPath = path(COMPONENT_PARENT, TARGET);
const documentPath = path(DOCUMENT_PARENT, TARGET);

console.log(`target: ${TARGET}`);
console.log(`  component tree path: ${componentPath.join(" > ")}`);
console.log(`  document tree path : ${documentPath.join(" > ")}`);

console.log("click listeners:");
for (const listener of ["filter-panel", "measurement-table", "app-root", "body-root"]) {
  const inComponentPath = componentPath.includes(listener);
  const inDocumentPath = documentPath.includes(listener);
  console.log(
    `  ${listener.padEnd(18)} by component tree: ${inComponentPath ? "fires" : "skips"}`,
    `| by document tree: ${inDocumentPath ? "fires" : "skips"}`,
  );
}

console.log("context resolution (unit):");
for (const [label, trail] of [["component tree", componentPath], ["document tree", documentPath]]) {
  const found = trail.find((n) => n in PROVIDERS);
  console.log(`  ${label.padEnd(15)} -> ${found ? PROVIDERS[found] : DEFAULT_UNIT}`,
              `(${found ? `provider: ${found}` : "no provider"})`);
}
```

```
target: option-temperature
  component tree path: option-temperature > dropdown-list > filter-panel > page
  document tree path : option-temperature > dropdown-list > body-root > document
click listeners:
  filter-panel       by component tree: fires | by document tree: skips
  measurement-table  by component tree: skips | by document tree: skips
  app-root           by component tree: skips | by document tree: skips
  body-root          by component tree: skips | by document tree: fires
context resolution (unit):
  component tree  -> celsius (provider: page)
  document tree   -> kelvin (no provider)
```

Which tree governs context resolution and event propagation is the most surprising
aspect of a portal. The answer is single: **the component tree.**

When an option is clicked, the event climbs the document tree — where the filter panel
does not exist — but the runtime re-dispatches the event along the path in the component
tree. A click listener set up on the filter panel fires. This resembles what the shadow
tree does with the composed path in The Browser and the Web Platform course: physical
propagation happens in one tree, logical propagation in another.

The same holds for context. Even though the portaled subtree ends up outside the provider
in the document tree, it sees the provider in the component tree and reads Celsius.
Resolving through the document tree would have fallen back to the default. The same rule
works for the previous two lessons' mechanisms: a render error in a portaled subtree goes
to the nearest error boundary in the component tree; a suspension in a portaled subtree
is caught by the nearest suspense boundary in the component tree.

This consistency is what makes a portal usable. A portal is not an escape hatch; it
changes only the render target, and breaks none of the logical relationships.

## Conditions on the Target Node

The target has to satisfy three conditions.

The target has to **exist** in the document when the subtree is written. Placing a
sibling container next to the application's root satisfies this condition in the simplest
way. Producing the target in code and using it at the same moment carries the risk that
the node has not yet been added to the document.

The target's lifetime has to be **longer** than the portaled component's lifetime. If the
target is produced by the portaling component itself, the component takes the target
down with it when removed from the tree, and the teardown order becomes uncertain.

The target has to be **included** in the application's teardown sequence. The portaled
subtree runs its own effects' cleanup normally; but if the target container itself was
produced outside the application, it stays behind in the document as an empty node. Using
a single shared target prevents this accumulation.

If server-side rendering is in use, a fourth condition is added: since no document exists
on the server, portaled content is absent from the initial response and appears only
after the first commit. Content that must be present in the initial view is not
portaled.

## Accessibility

A portal fixes the visual relationship while severing the relationship in the document,
and this break is real for assistive technologies. Four obligations follow.

**Reading order.** In the document tree, the dropdown list is now at the end of the
page; a screen reader finds it at the end, not next to the button that triggers it. The
link is re-established with attributes that associate the button and the list, and
appropriate ARIA roles.

**Focus management.** When the list opens, focus moves to the list; when it closes,
focus **returns to the button that opened it**. Focus not returning leaves the user at
the end of the document. The focus management rules from the Web Fundamentals and HTML
course apply here unchanged.

**Focus trap.** When a persistent layer is involved — a confirmation dialog — focus is
held inside the layer, and the content behind it is hidden from assistive technologies.
Content that is visually covered is still readable unless it is hidden.

**Dismissal path.** A layer that has stepped outside the tab order still has to be
dismissible by keyboard; the escape key and clicking outside are two separate paths, and
both are set up.

## When a Portal Is Not Needed

A portal carries a cost: the two trees separating makes debugging harder and gives rise
to the four obligations above. It is not used where it is not needed.

The clipping problem is often solved with layout. Does the panel really need to clip
overflowing content, or is a scroll container placed in the wrong spot? The stacking
problem can also be solved by fixing the chain: freeing the panel from a property that
needlessly establishes a stacking context brings the list back into the root context.

The criterion can be written in one sentence: **a portal is used when a subtree's
position within the document cannot be fixed.** If the position can be fixed, a portal is
not needed.

## Summary

- A portal keeps a subtree's place in the component tree and changes only its target in
  the document tree.
- Its justification is two measurable constraints: the parent container clipping
  overflowing content, and the stacking context the parent container establishes trapping
  the order number within itself.
- Event propagation, context resolution, error boundaries, and suspense boundaries follow
  the component tree; the break in the document tree does not disturb these
  relationships.
- The target node must exist in the document at the moment of writing, must outlive the
  portaled component, and must be included in the application's teardown sequence.
- A portal disrupts the document's reading order; association attributes, focus
  returning, a focus trap when needed, and a keyboard dismissal path have to be set up
  separately.
- If clipping and stacking can be solved with layout, a portal is not used.

## Next Step

This topic completed a single component's inner life. We now know what a component
stores across renders, how it synchronizes with the outside world, which values it
computes, which it holds quietly, what it reads from the tree, what happens in error and
waiting states, and where it renders its output. All of it stayed within the boundaries
of a single component.

The measurement station page, however, is not a single component; it is a family made up
of the filter panel, the measurement table, the measurement badge, and the comparison
panel. How these parts connect to one another is a separate question, and its answer is
not only "pass props from parent to child." One component can take another as content,
two components can share a common contract, the same behavior can be reused with
different views. The next topic's first lesson, Composition Patterns, takes up these
ways of combining components and the balance each one strikes between encapsulation and
flexibility.
