Askr documentation
UI & Components

Portals and Layers

Portals and Layers: anatomy, keyboard behavior, state, and theming in Askr.

Example

import { Button, Dialog, DialogContent, DialogTitle, DialogTrigger } from '@askrjs/themes/components';

<Dialog>
  <DialogTrigger asChild><Button>Open settings</Button></DialogTrigger>
  <DialogContent>
    <DialogTitle>Project settings</DialogTitle>
    <ProjectSettings />
  </DialogContent>
</Dialog>

Purpose

Overlay content — dialogs, popovers, dropdowns, tooltips, menus — needs to render outside its trigger's normal position in the DOM to avoid being clipped by overflow: hidden ancestors or scoped z-index contexts. Every overlay family in @askrjs/ui ships a dedicated Portal component for exactly this: DialogPortal, PopoverPortal, TooltipPortal, DropdownPortal, SelectPortal, MenubarPortal, and HoverCardPortal all exist and are re-exported by the matching themed component.

Install and import

Import DialogPortal with its owning Dialog family from '@askrjs/ui/dialog' or the themed component entries. SheetPortal reuses the Dialog portal behavior. Compose each portal under the root that owns its state and lifecycle.

Live examples

A typical Dialog composition wraps its overlay and content in <DialogPortal><DialogOverlay /><DialogContent>...</DialogContent></DialogPortal> — the portal moves that subtree to the end of the document body at render time, while everything else about the composition (FocusScope, DismissableLayer, the a11y contract) works exactly as it would inline.

Anatomy

DialogPortalProps is intentionally minimal — just children — because a portal's only job is relocating the DOM subtree it wraps, not adding behavior of its own. The behavior (open state, focus trapping, dismissal) stays owned by the surrounding Dialog, Popover, or Dropdown component; the portal is purely a rendering-location concern layered underneath it.

State model

Portals hold no state of their own — DialogPortal returns JSX.Element | null depending entirely on whether the parent's open state says there's anything to render. There's nothing to track independently; a portal simply reflects whatever its owning component's state already is.

Keyboard and accessibility

Moving markup to a different DOM location doesn't change its position in the accessibility tree the way it changes its visual position — portaled content still needs the same aria-modal, aria-labelledby, and focus-trap wiring the Dialog a11y contract specifies, and Askr's headless components handle that automatically regardless of where the portal renders to.

Styling and tokens

Stacking order across portaled layers is governed by a real, ordered --ak-z-* token ladder: --ak-z-sticky: 1100, --ak-z-fixed: 1200, --ak-z-modal-backdrop: 1300, --ak-z-modal: 1400, then --ak-z-dropdown and --ak-z-popover tied at 1500, --ak-z-toast: 1550, and --ak-z-tooltip: 1600 on top. Dropdown and popover intentionally share a layer rather than one sitting above the other — don't assume a fixed dropdown-under-popover ordering between those two specifically. Because every overlay family draws from this same ladder, a tooltip will always render above a modal, without any manual z-index juggling in application code.

API

Every overlay family's Portal component takes the same shape: a children-only props type with no configuration surface, matching DialogPortalProps. The corresponding BlockZIndex type used elsewhere in the theme — "header" | "sticky" | "fixed" | "dropdown" | "modal" | "popover" | "toast" | "tooltip" — mirrors the same layer names as the --ak-z-* tokens, so Block-based layout and portal stacking stay in sync by construction.

Edge cases

Because portaled content renders outside its logical parent in the DOM, don't rely on CSS descendant selectors scoped to the trigger's container to style it — target the portal content's own data-slot hooks instead. Also be aware that nested portals (a dropdown opened from inside a dialog, for instance) stack according to the shared z-index ladder, not render order, so a component that opens later isn't guaranteed to render visually on top unless its layer token says it should.

See Focus and Dismissal for how FocusScope and DismissableLayer coordinate with portaled content specifically, and Structures for how Block's own zIndex prop draws from the same token ladder used here.