Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Portals in React

Turtlekit Editorial Team

Design moved Send invoice into the top bar. On a long invoice the button had sat at the foot of the form, two screens below the line someone had just checked, while the top bar stays in view however far the page scrolls.

The top bar belongs to the app’s frame, though, and the frame knows nothing about invoices. Lifting the editor’s state up to the frame would teach it about invoices for the sake of one button, and every keystroke in a line would re-render the frame along with the editor.

A portal is made for this. createPortal renders part of a component into a different DOM node while it keeps its place in the React tree, so the button is drawn in the top bar and still belongs to the editor, with its state, its context and its events.

A button with two parents

The first version gave the top bar an empty slot and drew Send into it. Everything else in the editor stayed as it was:

// Avoid: a submit button outside its form in the DOM, and a key
// handler that also hears the keys pressed in the top bar
return (
  <form
    onSubmit={(event) => {
      event.preventDefault();
      onSend(
        new FormData(event.currentTarget)
          .getAll("line")
          .map(String),
      );
    }}
    onKeyDown={(event) => {
      if (event.key !== "Enter") return;
      event.preventDefault();
      setCount(count + 1);
    }}
  >
    <PageActions label="Invoice actions">
      <button type="submit">Send invoice</button>
    </PageActions>
    {/* One input per line, as in the finished editor */}
  </form>
);

It looked right, and it failed twice. Clicked with a mouse, Send did nothing at all. A submit button submits the form it sits inside, and in the DOM this one now sat inside the top bar.

Pressed with Enter, it was worse. The form treats Enter as “add a line”, the way a spreadsheet does, and the key from the top bar reached that handler, because React sends events up the React tree, through the portal, to the form. A keyboard user who pressed Send got a blank line instead. Each bug came from forgetting one of the button’s two parents.

Drawn in one place, owned by another

react.dev puts it plainly: a portal only changes the physical placement of the DOM node. In every other way, what you render into it acts as a child of the component that renders it.

React treeDOM treeAppShellInvoiceEditorformbuttonheadermainslotformbuttoninput
The Send button has two parents. In the React tree it sits inside the form, so it reads the editor’s context and its events reach the form’s handlers. In the DOM it sits in the top bar, so it takes the top bar’s place on screen and in the Tab order, and the browser doesn’t count it as part of the form.

A portal moves where an element is drawn, never where it belongs. Most of the work is knowing which of its two parents each behaviour listens to:

What follows which tree
Follows the React treeFollows the DOM tree
State and contextLayout and CSS, including inherited direction and theme
React’s events: onClick, onKeyDown, onSubmitThe Tab order and the reading order
When it mounts and unmountsNative behaviour: which form a button submits, contains, inert

Putting Send in the top bar

Two files change: the frame gains a slot and a way to draw into it, and the editor learns to answer to both trees.

A slot the frame owns

// The slot's node lives in state: once the top bar is in the DOM,
// the page renders again and finds somewhere to draw.
const [slot, setSlot] = useState<HTMLElement | null>(null);
return (
  <ActionsSlot value={slot}>
    <header className="top-bar">
      <a href="/invoices">Invoices</a>
      <div ref={setSlot} className="top-bar__actions" />
    </header>
    <main>{children}</main>
  </ActionsSlot>
);

The slot’s node is kept in state rather than in a ref, because the page has to render again once the node exists, and a ref changes without telling anyone. The state setter is the same function on every render, so React attaches it as a ref callback once and leaves it alone.

Drawing into it

export function PageActions({
  label,
  children,
}: {
  label: string;
  children: ReactNode;
}) {
  const slot = use(ActionsSlot);
  // On the server and in the first render there is no slot yet.
  if (!slot) return null;
  return createPortal(
    <div role="group" aria-label={label}>
      {children}
    </div>,
    slot,
  );
}

PageActions reads the node from context and returns nothing until there is one. That covers the first render in the browser and the whole render on the server, where there is no DOM to draw into. The group gives the moved buttons a name of their own in the top bar.

A button that names its form

The DOM decides which form a button submits, so the fix is in the DOM. The form attribute joins a button to a form by id, wherever the two sit, and the id comes from useId so two editors on one page can’t claim each other’s button.

A key handler where the key is pressed

function send(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  const data = new FormData(event.currentTarget);
  onSend(data.getAll("line").map(String));
}

// On each line's input, where the key is meant. On the form it
// would also hear Enter on Send: keys bubble up the React tree,
// portal and all.
function addLineOnEnter(event: KeyboardEvent<HTMLInputElement>) {
  if (event.key !== "Enter") return;
  event.preventDefault();
  const next = lines.length + 1;
  setLines([...lines, next]);
  setAdded(next);
}

A React handler hears every event from its subtree in the React tree, portals included, so a handler on the form hears keys pressed in the top bar. Listening on the inputs puts the handler where Enter means “add a line” and nowhere else. When a handler has to stay on an ancestor, such as a table row’s onClick, ask the DOM instead: event.currentTarget.contains(event.target) is false for anything drawn through a portal.

The finished editor

"use client";
// The invoice editor. Its Send button is drawn in the app's top bar,
// and it still sends this form; Enter in a line adds the next line.
import { useId, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { PageActions } from "./app-shell";

interface InvoiceEditorProps {
  number: string;
  onSend: (lines: string[]) => void;
}

// Focuses a line's input once, when it mounts.
const focusOnMount = (input: HTMLInputElement | null) =>
  input?.focus();

export function InvoiceEditor({
  number,
  onSend,
}: InvoiceEditorProps) {
  const formId = useId();
  const [lines, setLines] = useState([1]);
  const [added, setAdded] = useState<number | null>(null);

  function send(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    onSend(data.getAll("line").map(String));
  }

  // On each line's input, where the key is meant. On the form it
  // would also hear Enter on Send: keys bubble up the React tree,
  // portal and all.
  function addLineOnEnter(event: KeyboardEvent<HTMLInputElement>) {
    if (event.key !== "Enter") return;
    event.preventDefault();
    const next = lines.length + 1;
    setLines([...lines, next]);
    setAdded(next);
  }

  return (
    <form
      id={formId}
      aria-label={`Invoice ${number}`}
      onSubmit={send}
    >
      <PageActions label="Invoice actions">
        {/* form= joins the button to a form it sits outside of */}
        <button type="submit" form={formId}>
          Send invoice
        </button>
      </PageActions>
      {lines.map((line) => (
        <label key={line}>
          Line {line}
          <input
            name="line"
            onKeyDown={addLineOnEnter}
            ref={line === added ? focusOnMount : undefined}
          />
        </label>
      ))}
    </form>
  );
}

Send sits in the top bar and submits the editor’s form, by mouse or by Enter. Enter in a line adds the next line and puts the cursor in it, through a ref callback that focuses the new input once, when it mounts. The frame still knows nothing about invoices.

Living in two trees

A slot is a contract

The frame promises a place, and each page promises to fill it sensibly. Nothing stops two components drawing into the same slot at once; their buttons sit side by side, in the order they mounted. That is fine for one page at a time and confusing for nested ones, so keep a slot for the page that owns the screen, and give a panel that needs its own actions a slot of its own.

Order on screen, order for the keyboard

The Tab order follows the DOM. Send now comes before the form’s fields, which is where people see it, so the order still makes sense, as WCAG’s focus order criterion asks. A screen reader finds it as “Invoice actions” in the top bar’s banner landmark. The same rule is why a menu portalled to the end of body needs its focus managed by hand: Tab from inside it leaves for the end of the page, far from the button that opened it.

Testing where it is drawn

The test finds Send in the banner, confirms the form doesn’t contain it, and then checks that it sends the form anyway:

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { AppShell } from "./app-shell";
import { InvoiceEditor } from "./invoice-editor";

function renderEditor() {
  const onSend = vi.fn();
  render(
    <AppShell>
      <InvoiceEditor number="INV-2041" onSend={onSend} />
    </AppShell>,
  );
  const topBar = screen.getByRole("banner");
  const send = within(topBar).getByRole("button", {
    name: "Send invoice",
  });
  return { onSend, send };
}

test("draws Send in the top bar, and it sends the form", async () => {
  const { onSend, send } = renderEditor();
  const form = screen.getByRole("form", { name: "Invoice INV-2041" });
  expect(form).not.toContainElement(send);
  await userEvent.type(screen.getByLabelText("Line 1"), "Audit");
  await userEvent.click(send);
  expect(onSend).toHaveBeenCalledWith(["Audit"]);
});

test("Enter adds a line, but Enter on Send only sends", async () => {
  const { onSend, send } = renderEditor();
  await userEvent.type(
    screen.getByLabelText("Line 1"),
    "Audit{Enter}",
  );
  expect(screen.getByLabelText("Line 2")).toHaveFocus();
  send.focus();
  await userEvent.keyboard("{Enter}");
  expect(screen.queryByLabelText("Line 3")).toBeNull();
  expect(onSend).toHaveBeenCalledWith(["Audit", ""]);
});

Both pass. Remove form= and neither does; move the Enter handler back onto the form and the second fails on a third line.

Nothing to draw on the server

The server renderer doesn’t support portals: it has no document to put the node in. Render the portal only once the target exists, as PageActions does, and the server’s HTML has an empty top bar that fills in with hydration. Give the slot a fixed height so the arrival moves nothing. Both files are Client Components, marked with "use client", since they hold state and touch the DOM.

React 18, 19 and the Compiler

createPortal works the same in React 18 and 19. The sample uses two React 19 forms: the context itself as its provider, <ActionsSlot value={slot}>, where 18 writes <ActionsSlot.Provider>, and use(ActionsSlot) in place of useContext. The React Compiler changes nothing here; a portal is an element like any other.

When the top layer does it better

Portals are best known for menus, popovers and dialogs, which used them to escape a parent’s overflow: hidden and its stacking order. Current browsers have a better home for those. An element shown with the popover attribute or a <dialog> opened with showModal() goes to the top layer, above everything and clipped by nothing, while it stays where you wrote it in the DOM. The Tab order, inherited styles and form ownership all follow your JSX, so there is nothing to work around.

And when the frame can render the page’s buttons itself, because the page passes them in as children, do that. A portal earns its place when the content belongs to one component and has to be drawn in a place another one owns.

Where Turtlekit draws its overlays

Turtlekit’s Dialog, Popover and DropdownMenu open in the top layer, so none of them is portalled and each keeps its place in the Tab order. Toast is the part that moves: while a modal dialog is open, the page behind it is inert, so the toasts are drawn inside the dialog instead.

// Sharing an invoice with Turtlekit. Dialog opens in the browser's
// top layer, and a toast raised while it is open shows inside it.
import { useState } from "react";
import {
  Button,
  Dialog,
  Stack,
  TextField,
  useToast,
} from "turtlekit";

const LINK = "https://pay.example.com/inv-2041";

export function ShareInvoice() {
  const [open, setOpen] = useState(false);
  const toast = useToast();

  async function copy() {
    try {
      await navigator.clipboard.writeText(LINK);
      toast.success("Payment link copied");
    } catch {
      toast.danger("Couldn’t copy the link. Select it and copy it.");
    }
  }

  return (
    <>
      <Button variant="soft" onClick={() => setOpen(true)}>
        Share
      </Button>
      <Dialog
        open={open}
        onClose={() => setOpen(false)}
        title="Share invoice INV-2041"
        description="Anyone with the link can view and pay it."
      >
        <Stack gap="tight" align="start">
          <TextField
            label="Payment link"
            defaultValue={LINK}
            readOnly
          />
          <Button onClick={copy}>Copy link</Button>
        </Stack>
      </Dialog>
    </>
  );
}

Under the app’s ToastProvider, Copy link raises “Payment link copied” inside the dialog, above its backdrop, where people can see it and screen readers announce it. The toast still comes from the app’s store, through context: drawn where it can be reached, owned where it always was.

Built on this pattern in Turtlekit: Toast.

When the two trees disagree

A click inside the portal sets off a handler on the page

React events bubble through the portal to its React parents. Move the handler to the elements it is meant for, check currentTarget.contains(target) in the parent’s handler, or stop the event inside the portal when nothing above should hear it.

React says portals aren’t supported by the server renderer

Something calls createPortal during a server render. Return nothing until the target node exists in the browser.

The portal’s content loses its styles or its direction

CSS follows the DOM. A selector written for the editor doesn’t reach the top bar, and a portal into body misses the theme and the dir set on the app’s own root. Draw into a node inside that root, as the slot is.

Know which parent you are talking to

The appeal of a portal is that it seems to change nothing: the same component, the same state, a different spot on the screen. The editor’s two bugs came from believing that. The button lived in two trees, and each behaviour answered to one of them, so the fix was to ask the right tree each time: the DOM for which form to submit and where a key came from, React for everything the button knows. Draw things where they are useful, and keep them owned by the component that understands them.

Ref as a prop and ref cleanup in React 19

When the node you draw into arrives through a ref callback.

Roving focus and keyboard navigation in React

When the moved content is a widget the keyboard has to move through.

Accessible ids with useId in React

When an attribute such as form= has to name an element elsewhere on the page.

Lifting state up in React

When the frame really should own the state, rather than draw a page’s part.