Skip to content
academia.sh

Lesson 14 / 25

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.

Contents

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.

// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close