← ui

portal

ui

where the popups inside a surface open, declared once: a fullscreen player's layer

npx shadcn add @ag/portal
Already installed? Preview an update
npx shadcn add @ag/portal --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • New: PortalTarget, where the popups inside a surface open, declared once. tooltip, popover, hover-card, menu, select, dialog and sheet read it as their default container (one given its own still goes there), so a surface that shows on its own (a player in fullscreen, where a popup on the body is invisible) says it once instead of handing a container to each popup. usePortalTarget() reads it for a popup of your own. A setting's tip in a fullscreen player, which no one could hand a container, now shows.
Updated 2026-10-12 · release notes

also called portal container, popup container

shelf foundations

used by tooltip, dialog, menu, select, popover, hover-card, sheet, playback, film-mosaic

the demo above, verbatim

portal.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTitle,
  PopoverTrigger,
} from "@/registry/base-nova/ui/popover"
import { PortalTarget } from "@/registry/base-nova/ui/portal"
import { Tip } from "@/registry/base-nova/ui/tooltip"

/** A surface that goes fullscreen on its own: only it is drawn then, so a popup on
 *  the page's body would be invisible. It declares a layer inside itself once, and
 *  the tooltip and the popover in it open there with no container passed. */
function Stage() {
  const stage = React.useRef<HTMLDivElement>(null)
  const [layer, setLayer] = React.useState<HTMLElement | null>(null)
  const full = () =>
    document.fullscreenElement
      ? document.exitFullscreen()
      : stage.current?.requestFullscreen()
  return (
    <div
      ref={stage}
      className="relative grid h-48 place-items-center rounded-xl bg-foreground/5"
    >
      <span
        ref={setLayer}
        className="contain-layout absolute top-0 left-0 z-50 size-0"
      />
      <PortalTarget element={layer}>
        <div className="flex gap-2">
          <Tip content="opens inside the surface, fullscreen too">
            <Button variant="secondary">a tip</Button>
          </Tip>
          <Popover>
            <PopoverTrigger render={<Button variant="secondary" />}>
              a popover
            </PopoverTrigger>
            <PopoverContent>
              <PopoverTitle>inside the surface</PopoverTitle>
            </PopoverContent>
          </Popover>
          <Button onClick={full}>fullscreen</Button>
        </div>
      </PortalTarget>
    </div>
  )
}

export default function PortalDemo() {
  return (
    <Sample
      name="fullscreen"
      glance="wide"
      title="popups in a fullscreen surface"
      description="go fullscreen: the tip and the popover still show, declared once"
    >
      <Stage />
    </Sample>
  )
}

the source, every file an install writes

portal.tsx
"use client"

import * as React from "react"

// WHERE A POPUP GOES. A popup (a tooltip, a popover, a menu, a select's list, a
// dialog) is portalled out of the flow, to the page's body by default. A surface that
// shows on its own declares where the popups inside it go instead, once: a player in
// fullscreen (only the fullscreen element is drawn, so a popup on the body is
// invisible), a frame that clips, a layer above a picture. Every popup item in the
// registry reads it as its default `container`; one given a `container` of its own
// still goes there.
//
//   <PortalTarget element={layerElement}>
//     … every Tip, Popover, Menu, Select and Dialog inside portals into it
//   </PortalTarget>
//
// Surfaces nest: the nearest declaration wins, and a surface hands the popups on to
// the one around it by declaring that one (`usePortalTarget()`) instead of its own,
// as the player does while it is not fullscreen. No declaration, or an element not
// mounted yet, is the body: `usePortalTarget` says undefined then, never null,
// because Base UI's Portal reads a null container as one still to come and renders
// nothing (every popover outside a player stayed shut when it was null).

const PortalTargetContext = React.createContext<HTMLElement | null>(null)

/** Popups inside go into `element` (null or undefined: the page's body). */
export function PortalTarget({
  element,
  children,
}: {
  element: HTMLElement | null | undefined
  children: React.ReactNode
}) {
  return (
    <PortalTargetContext.Provider value={element ?? null}>
      {children}
    </PortalTargetContext.Provider>
  )
}

/** Where a popup here goes: the nearest `PortalTarget`'s element, else undefined
 *  (the body). A popup item's default `container`. */
export function usePortalTarget(): HTMLElement | undefined {
  return React.useContext(PortalTargetContext) ?? undefined
}