Overlays
Composition

Overlays

Portal, Popover, Dialog, Overlay, FocusTrap, ScrollLock, and CloseButton: the primitives that floating and modal components are built on.

Portal

Portal renders children into a DOM container outside the normal React tree but inside the themed subtree, keeping floating elements within ThemeProvider's CSS variable scope. It defaults to the portal root set up by ThemeProvider, falling back to document.body.

Popover and Dialog use Portal internally. You only need Portal directly when building a custom floating component from scratch.

Portal usageTSX
import { Portal } from "@solarius/onset";

function MyToast() {
  return (
    <Portal>
      <div className="my-toast">
        Rendered outside the tree, inside the theme
      </div>
    </Portal>
  );
}

Pass a container prop to target a specific element. Without it, the portal resolves to the nearest ThemeProvider's root (set via usePortalContainer) or document.body.

Popover

Popover positions a floating panel against an anchor element and tracks the viewport: it flips vertically when there is not enough room and clamps horizontally so the panel stays on-screen. DropdownMenu, Select, PopoverPanel, HoverCard, and Tooltip are all built on this positioning implementation.

Popover contractTSX
import { Popover, Surface } from "@solarius/onset";
import { useRef, useState } from "react";

function MyDropdown() {
  const anchorRef = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);

  return (
    <>
      <button ref={anchorRef} onClick={() => setOpen(!open)}>
        Open
      </button>
      <Popover
        open={open}
        anchorRef={anchorRef}
        placement="bottom-start"
        offset={4}
        matchWidth
      >
        <Surface elevation="overlay">
          <div>Item 1</div>
          <div>Item 2</div>
        </Surface>
      </Popover>
    </>
  );
}

Placement

The placement prop accepts six values:

  • bottom-start: below, aligned to the anchor's left edge (default)
  • bottom-end: below, aligned to the anchor's right edge
  • top-start: above, aligned to the anchor's left edge
  • top-end: above, aligned to the anchor's right edge
  • right-start: to the right, aligned to the anchor's top edge
  • left-start: to the left, aligned to the anchor's top edge

Popover automatically flips between bottom and top variants based on available viewport space. Horizontal variants (right-start and left-start) flip sides when there is not enough room.

Placement optionsTSX
<Popover
  open={open}
  anchorRef={ref}
  placement="top-end" // above, right-aligned
  offset={6} // gap between anchor and panel, in px
  matchWidth // panel min-width matches anchor width
>
  {/* panel content */}
</Popover>

Set matchWidth to make the panel at least as wide as the anchor. Use it for select-style dropdowns and autocomplete menus.

Dialog

Dialog combines portal, backdrop, Escape handling, scroll lock, and focus trap into a single modal layer. Modal, Drawer, AlertDialog, and CommandPalette are all built on it.

Dialog usageTSX
import { Dialog } from "@solarius/onset";

function MyModal({ open, onClose }) {
  return (
    <Dialog open={open} onClose={onClose} placement="center" width={480}>
      <div>Modal content goes here</div>
    </Dialog>
  );
}

Placements

placement controls where the panel appears:

  • "center": modal dialog, centered both axes with a strong backdrop
  • "top": centered horizontally, near the top of the viewport
  • "right": drawer anchored to the right edge
  • "left": drawer anchored to the left edge

Edge-anchored panels (right, left) get a single border seam against the page rather than a floating outline, and use a slide-in animation instead of the center pop animation.

Complete Dialog exampleTSX
import {
  Dialog,
  PanelHeader,
  PanelBody,
  PanelFooter,
  Button,
} from "@solarius/onset";

function SettingsModal({ open, onClose }) {
  return (
    <Dialog
      open={open}
      onClose={onClose}
      placement="center"
      width={520}
      closeOnEscape
      closeOnBackdrop={false}
      role="dialog"
    >
      <PanelHeader onClose={onClose} size="lg" closeLabel="Close settings">
        Settings
      </PanelHeader>
      <PanelBody scroll>
        <p>Your settings content here.</p>
      </PanelBody>
      <PanelFooter>
        <Button variant="ghost" onClick={onClose}>
          Cancel
        </Button>
        <Button variant="primary">Save</Button>
      </PanelFooter>
    </Dialog>
  );
}

Accessibility behavior

When open is true, Dialog:

  • Renders into a portal (inside the themed subtree)
  • Locks body scroll (overflow: hidden)
  • Traps Tab focus inside the panel (loops first/last focusable elements)
  • Closes on Escape when closeOnEscape is true (default)
  • Sets role="dialog" and aria-modal on the panel
  • Returns focus to the previously focused element when closed

Use role="alertdialog" for destructive confirms so assistive tech announces them as interrupting. Set closeOnBackdrop=false for confirms that must be dismissed deliberately.

Overlay

Overlay combines Popover positioning with anchor and panel refs, open state, dismissal, trigger ARIA attributes, and a Surface panel.

OverlayTSX
<Overlay placement="bottom-end">
  <OverlayTrigger asChild>
    <Button>Filters</Button>
  </OverlayTrigger>
  <OverlayPanel role="menu">
    <MenuItem>Only failing</MenuItem>
    <MenuItem>Last 24 hours</MenuItem>
  </OverlayPanel>
</Overlay>

asChild

OverlayTrigger wraps its child in a <span> by default. Pass asChild to wire the child element itself instead: no extra DOM node, and the child keeps its own ref and onClick rather than having them replaced. The child must forward its ref to a DOM node, or there is nothing to anchor the panel to.

useOverlay

Use useOverlay directly for custom triggers or panels. Spread its triggerProps and popoverProps onto the corresponding elements. See useOverlay.

FocusTrap & ScrollLock

FocusTrap and ScrollLock provide focus and scroll handling for custom overlays. Dialog includes both behaviors.

FocusTrapTSX
<FocusTrap enabled={open} className={styles.panel} role="dialog" aria-modal>
  <input ref={inputRef} />
  <Button onClick={close}>Done</Button>
</FocusTrap>

as changes the element, so the trap replaces your wrapper rather than adding one. initialFocusRef, autoFocus and restoreFocus pass through to useFocusTrap.

ScrollLockTSX
{
  open ? <ScrollLock /> : null;
}

ScrollLock renders nothing and freezes background scrolling for as long as it is mounted, compensating the scrollbar's width so the page does not shift. Locks nest: a Modal opened over a Drawer releases the lock only when both are gone.

CloseButton

CloseButton is the dismiss affordance used by Modal, Drawer, Toast, Notification, Banner, FilterChip, and Token. It wraps an IconX in a button with consistent hover, focus, and sizing.

aria-label is required and has no default. A missing label on a close button is invisible to linting, so the prop must be set explicitly.

CloseButton usageTSX
import { CloseButton } from "@solarius/onset";

<CloseButton onClick={onClose} aria-label="Close settings" size="md" />;

Two sizes: "sm" (11px icon) and "md" (13px icon, default). Use "sm" in tight contexts like toasts and chips.

Composition

The overlay stack is layered so each primitive builds on the one below:

  • Portal gets content outside the React tree but inside the theme
  • Popover adds anchored positioning and viewport awareness on top of Portal
  • Dialog adds backdrop, focus trap, scroll lock, and Escape on top of Portal
  • Overlay adds refs, open state, dismissal and the aria pair on top of Popover
  • FocusTrap and ScrollLock are the two behaviors Dialog bundles, available separately
  • CloseButton provides the dismiss button used by dialogs and popovers

Modal, Drawer, DropdownMenu, Select, Tooltip, and HoverCard configure these primitives internally. Use the primitives directly for custom positioning, focus handling, or portals.

Building a custom flyoutTSX
import { useRef, useState } from "react";
import { Popover, Surface, PanelHeader, PanelBody } from "@solarius/onset";

function CustomFlyout() {
  const anchorRef = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);

  return (
    <>
      <button ref={anchorRef} onClick={() => setOpen(!open)}>
        Open flyout
      </button>
      <Popover
        open={open}
        anchorRef={anchorRef}
        placement="bottom-start"
        offset={8}
      >
        <Surface elevation="overlay" w={300}>
          <PanelHeader plain density="compact">
            Custom flyout
          </PanelHeader>
          <PanelBody density="compact">
            <p>Content that matches Onset's menus exactly.</p>
          </PanelBody>
        </Surface>
      </Popover>
    </>
  );
}