Build a popup, compose an existing one, or use positioning and dismissal on their own.

Use createPopup to manage opening, closing, positioning, and focus for your own popup.

You can also compose an existing Popover, Dialog, or Menu into your component, or use positioning and layers separately.

Make Your Own

A Popup of Your Own

Start with an invite popover. popupState supplies its state, createPopup supplies its behavior, and the slot builders connect its parts:

import {
  createPopup,
  definePrimitive,
  popupClose,
  popupContent,
  popupState,
  popupTrigger,
} from "@ui-sdk/core"
import { popoverPreset } from "@ui-sdk/core/popover"

const invite = definePrimitive({
  name: "invite",
  options: { defaultOpen: "boolean" },
  state: (options: { defaultOpen?: boolean }) => popupState(options),
  api: (context) => createPopup(context, { preset: popoverPreset }),
  slots: (slot) => ({
    trigger: popupTrigger(slot),
    content: popupContent(slot),
    close: popupClose(slot),
  }),
})

Render these parts with your framework's adapter, as in Build a Component. The popover preset provides placement beside the trigger, dismissal, and focus handling:

import * as React from "react"
import {
  createPopup,
  definePrimitive,
  popupClose,
  popupContent,
  popupState,
  popupTrigger,
  type ChangeDetails,
} from "@ui-sdk/core"
import { popoverPreset } from "@ui-sdk/core/popover"
import { createParts } from "@ui-sdk/react/primitive"

type InviteOptions = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean, details: ChangeDetails<string>) => void
}

const invite = definePrimitive({
  name: "invite",
  options: {
    open: "boolean",
    defaultOpen: "boolean",
    onOpenChange: "callback",
  },
  state: (options: InviteOptions) => popupState(options),
  sync: (options) => (options.open === undefined ? {} : { open: options.open }),
  api: (context) => createPopup(context, { preset: popoverPreset }),
  slots: (slot) => ({
    trigger: popupTrigger(slot),
    content: popupContent(slot),
    close: popupClose(slot),
  }),
})

const parts = createParts(invite, {
  trigger: "button",
  content: "div",
  close: "button",
})

const Invite = parts.Root

const InviteTrigger = parts.Trigger

const InviteContent = parts.Content

const InviteClose = parts.Close

function Example() {
  return (
    <Invite>
      <InviteTrigger>Invite</InviteTrigger>
      <InviteContent sideOffset={6}>
        <p>Anyone with the link can join.</p>
        <input readOnly aria-label="Link" value="https://example.com/s/7f3a" />
        <InviteClose data-variant="solid" data-size="sm">
          Done
        </InviteClose>
      </InviteContent>
    </Invite>
  )
}

export {
  Example,
  invite,
  Invite,
  InviteClose,
  InviteContent,
  type InviteOptions,
  InviteTrigger,
}
Press Invite, then close it with Escape, a press outside, or Done. Escape and Done return focus to Invite.

state.open tells you whether the popup is open. state.mounted stays true during its exit animation. Use the content builder to keep that presence behavior when rendering your popup.

The default slot names are content and trigger. If yours have different names, pass slot and trigger to createPopup.

Pick Its Behavior

Change the preset to change the interaction. For example, this makes the popup a modal dialog:

import { dialogPreset } from "@ui-sdk/core/dialog"

api: (context) => createPopup(context, { preset: dialogPreset }),
import * as React from "react"
import { CircleHelpIcon } from "lucide-react"
import {
  createPopup,
  definePrimitive,
  popupClose,
  popupContent,
  popupState,
  popupTitle,
  popupTrigger,
  type ChangeDetails,
  type PopupPreset,
} from "@ui-sdk/core"
import { dialogPreset } from "@ui-sdk/core/dialog"
import { popoverPreset } from "@ui-sdk/core/popover"
import { tooltipPreset } from "@ui-sdk/core/tooltip"
import { createParts } from "@ui-sdk/react/primitive"

type PanelOptions = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean, details: ChangeDetails<string>) => void
}

const popupWith = (name: string, preset: PopupPreset) =>
  definePrimitive({
    name,
    options: {
      open: "boolean",
      defaultOpen: "boolean",
      onOpenChange: "callback",
    },
    state: (options: PanelOptions) => popupState(options),
    sync: (options) =>
      options.open === undefined ? {} : { open: options.open },
    api: (context) => createPopup(context, { preset }),
    slots: (slot) => ({
      trigger: popupTrigger(slot),
      content: popupContent(slot),
      title: popupTitle(slot),
      close: popupClose(slot),
    }),
  })

const tags = {
  trigger: "button",
  content: "div",
  title: "h2",
  close: "button",
} as const

const filters = createParts(popupWith("filters", popoverPreset), tags)
const rename = createParts(popupWith("rename", dialogPreset), tags)
const help = createParts(popupWith("help", tooltipPreset), tags)

function Example() {
  return (
    <div data-slot="row">
      <filters.Root>
        <filters.Trigger>Filters</filters.Trigger>
        <filters.Content sideOffset={6}>
          <filters.Title>Filters</filters.Title>
          <label data-slot="label">
            <input type="checkbox" defaultChecked /> Unread only
          </label>
          <filters.Close data-size="sm">Done</filters.Close>
        </filters.Content>
      </filters.Root>
      <rename.Root>
        <rename.Trigger>Rename</rename.Trigger>
        <rename.Content>
          <rename.Title>Rename file</rename.Title>
          <input aria-label="Name" defaultValue="notes.md" />
          <rename.Close data-variant="solid" data-size="sm">
            Save
          </rename.Close>
        </rename.Content>
      </rename.Root>
      <help.Root>
        <help.Trigger aria-label="Help">
          <CircleHelpIcon aria-hidden="true" />
        </help.Trigger>
        <help.Content sideOffset={6}>
          Filters narrow the list, and Rename changes the file's name.
        </help.Content>
      </help.Root>
    </div>
  )
}

export { Example, filters, help, rename }
Open Filters, then Rename, and compare how Tab behaves. Hover or focus Help to open the tooltip.

A preset chooses the content's role, how it opens and closes, whether it is positioned beside a trigger, and whether it is modal. Our components use the same presets, so dialogPreset behaves like Dialog: an untitled dialog takes its trigger's name, since a modal dialog makes the page inert, its trigger included.

PresetImport FromUse It For
popoverPreset@ui-sdk/core/popoverInteractive content beside a trigger
dialogPreset@ui-sdk/core/dialogA modal task that holds focus
tooltipPreset@ui-sdk/core/tooltipA short label shown on hover or focus
hoverCardPreset@ui-sdk/core/hover-cardA preview shown on hover or focus

The role option can override the preset's content role. A role alone doesn't add a pattern's behavior; use Alert Dialog, for example, when a confirmation must follow that pattern.

The Options It Reads

Your primitive can expose popup options alongside its own. Add the fields you need to its options type and schema; createPopup reads them for you.

For example, dismissible: false keeps Escape and outside presses from closing a popup with unsaved work. onDismissAttempt tells you why someone tried to dismiss it:

<Cart
  dismissible={false}
  onDismissAttempt={(details) => setStatus(`Kept open: ${details.reason}`)}
>
  {children}
</Cart>
Console
import * as React from "react"
import {
  createPopup,
  definePrimitive,
  popupClose,
  popupContent,
  popupState,
  popupTrigger,
  type ChangeDetails,
  type Modal,
} from "@ui-sdk/core"
import { popoverPreset } from "@ui-sdk/core/popover"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type CartOptions = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean, details: ChangeDetails<string>) => void
  modal?: Modal
  openOnHover?: boolean
  delay?: number
  dismissible?: boolean
  onDismissAttempt?: (details: { reason: string }) => void
}

const cart = definePrimitive({
  name: "cart",
  options: {
    open: "boolean",
    defaultOpen: "boolean",
    onOpenChange: "callback",
    modal: "value",
    openOnHover: "boolean",
    delay: "number",
    dismissible: "boolean",
    onDismissAttempt: "callback",
  },
  state: (options: CartOptions) => popupState(options),
  sync: (options) => (options.open === undefined ? {} : { open: options.open }),
  api: (context) => createPopup(context, { preset: popoverPreset }),
  slots: (slot) => ({
    trigger: popupTrigger(slot),
    content: popupContent(slot),
    close: popupClose(slot),
  }),
})

const parts = createParts(cart, {
  trigger: "button",
  content: "div",
  close: "button",
})

const Cart = parts.Root

const CartTrigger = parts.Trigger

const CartContent = parts.Content

const CartClose = parts.Close

function Example() {
  const [open, setOpen] = React.useState(false)
  const [modal, setModal] = React.useState(false)
  const [openOnHover, setOpenOnHover] = React.useState(false)
  const [dismissible, setDismissible] = React.useState(true)
  const log = useLog()
  const [count, setCount] = React.useState(0)
  return (
    <div data-slot="stack">
      <div data-slot="row">
        <Cart
          open={open}
          onOpenChange={(next: boolean, details: ChangeDetails<string>) => {
            setOpen(next)
            log(`${next ? "Opened" : "Closed"} by ${details.reason}`)
          }}
          modal={modal}
          openOnHover={openOnHover}
          delay={200}
          dismissible={dismissible}
          onDismissAttempt={(details: { reason: string }) =>
            log(`Kept open: ${details.reason}`)
          }
        >
          <CartTrigger>Cart</CartTrigger>
          <CartContent sideOffset={6}>
            <p>2 items, $48</p>
            <CartClose data-variant="solid" data-size="sm">
              Check Out
            </CartClose>
          </CartContent>
        </Cart>
        <button type="button" onClick={() => setCount(count + 1)}>
          Likes {count}
        </button>
      </div>
      <div data-slot="row">
        <label data-slot="label">
          <input
            type="checkbox"
            checked={modal}
            onChange={(event) => setModal(event.target.checked)}
          />
          Modal
        </label>
        <label data-slot="label">
          <input
            type="checkbox"
            checked={openOnHover}
            onChange={(event) => setOpenOnHover(event.target.checked)}
          />
          Open on hover
        </label>
        <label data-slot="label">
          <input
            type="checkbox"
            checked={dismissible}
            onChange={(event) => setDismissible(event.target.checked)}
          />
          Dismissible
        </label>
      </div>
    </div>
  )
}

export {
  Cart,
  cart,
  CartClose,
  CartContent,
  type CartOptions,
  CartTrigger,
  Example,
}
Open Cart and watch the reason when it closes. Try Modal, Open on hover, and clearing Dismissible. Check Out still closes the cart.
OptionWhat It Changes
open, defaultOpen, onOpenChangeControl the open state or observe changes, with reasons such as trigger-press and escape-key.
modalOverride the preset's modality. true makes the page outside inert while the popup is open.
openOnHover, delay, closeDelayAdd hover opening to a press-opened popup and set the opening and closing delays in milliseconds.
dismissible, onDismissAttemptRefuse dismissals and hear the attempted reason. Triggers, Close parts, and controllers can still close it.
disabledPrevent opening.

Controlled open uses the same change and sync pattern as other state. The cart's source shows those fields together.

One Popup, Many Triggers

Several triggers can share one popup. Give each a payload prop, such as the person its card should show:

<TeamCardTrigger payload={person}>{person.initials}</TeamCardTrigger>

The content receives that value as state.payload. Opening another trigger updates the payload and moves the popup to it. onPayloadChange reports the change.

Console
import * as React from "react"
import {
  createPopup,
  definePrimitive,
  popupArrow,
  popupContent,
  popupDescription,
  popupState,
  popupTitle,
  popupTrigger,
  type ChangeDetails,
} from "@ui-sdk/core"
import { popoverPreset } from "@ui-sdk/core/popover"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type Person = { name: string; role: string; initials: string }

type TeamCardOptions = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean, details: ChangeDetails<string>) => void
  onPayloadChange?: (person: unknown, details: ChangeDetails<string>) => void
}

const teamCard = definePrimitive({
  name: "team-card",
  options: {
    open: "boolean",
    defaultOpen: "boolean",
    onOpenChange: "callback",
    onPayloadChange: "callback",
  },
  state: (options: TeamCardOptions) => popupState(options),
  sync: (options) => (options.open === undefined ? {} : { open: options.open }),
  api: (context) => createPopup(context, { preset: popoverPreset }),
  slots: (slot) => ({
    trigger: popupTrigger(slot),
    content: popupContent(slot),
    title: popupTitle(slot),
    description: popupDescription(slot),
    arrow: popupArrow(slot),
  }),
})

const parts = createParts(teamCard, {
  trigger: "button",
  content: "div",
  title: "p",
  description: "p",
  arrow: "span",
})

const TeamCard = parts.Root

const TeamCardTrigger = parts.Trigger

const TeamCardContent = parts.Content

const TeamCardTitle = parts.Title

const TeamCardDescription = parts.Description

const TeamCardArrow = parts.Arrow

const team: Person[] = [
  { name: "Hollis Grant", role: "Engineering", initials: "HG" },
  { name: "Inรจs Moreau", role: "Design", initials: "IM" },
  { name: "Kofi Mensah", role: "Research", initials: "KM" },
]

function Example() {
  const log = useLog()
  return (
    <TeamCard
      onPayloadChange={(person: unknown, details: ChangeDetails<string>) =>
        log(`${(person as Person).name}, by ${details.reason}`)
      }
    >
      <div data-slot="row">
        {team.map((person) => (
          <TeamCardTrigger
            key={person.initials}
            payload={person}
            aria-label={person.name}
          >
            {person.initials}
          </TeamCardTrigger>
        ))}
      </div>
      <TeamCardContent sideOffset={8}>
        {(state) => {
          const person = state.payload as Person | undefined
          return (
            <>
              <TeamCardArrow />
              <TeamCardTitle>{person?.name}</TeamCardTitle>
              <TeamCardDescription>{person?.role}</TeamCardDescription>
            </>
          )
        }}
      </TeamCardContent>
    </TeamCard>
  )
}

export {
  Example,
  type Person,
  teamCard,
  TeamCard,
  TeamCardArrow,
  TeamCardContent,
  TeamCardDescription,
  type TeamCardOptions,
  TeamCardTitle,
  TeamCardTrigger,
}
Open one person's card, then press another avatar. The same popup moves to the new trigger and shows that person's details.

Add popupTitle(slot) and popupDescription(slot) to connect a visible title and description to the content. popupArrow(slot) adds an arrow whose data-side follows its placement.

Every Part, and Parts of Your Own

popupSlots(slot) gives you the standard parts together: trigger, content, title, description, close, arrow, backdrop, and viewport.

You can add another slot beside them. This link preview adds an image that only renders when the payload has one:

slots: (slot) => ({
  ...popupSlots(slot),
  image: slot({
    present: (state) => !!(state.payload as LinkPreviewPage | undefined)?.image,
    attrs: (state) => ({
      src: (state.payload as LinkPreviewPage | undefined)?.image,
      alt: "",
    }),
  }),
}),

Use hoverCardPreset with the same popupState and createPopup setup as before. In this example, LinkPreviewPage describes the payload's title, description, and optional image:

import * as React from "react"
import { createParts } from "@ui-sdk/react/primitive"
import { linkPreview, type LinkPreviewPage } from "../link-preview"

const parts = createParts(linkPreview, {
  trigger: "a",
  content: "div",
  title: "p",
  description: "p",
  image: "img",
})

const LinkPreview = parts.Root

const LinkPreviewTrigger = parts.Trigger

const LinkPreviewContent = parts.Content

const LinkPreviewTitle = parts.Title

const LinkPreviewDescription = parts.Description

const LinkPreviewImage = parts.Image

const cover = (from: string, to: string) =>
  `data:image/svg+xml,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 120"><defs><linearGradient id="g" x2="1" y2="1"><stop stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="320" height="120" fill="url(#g)"/></svg>`
  )}`

const pages = {
  popups: {
    title: "Popups",
    description:
      "The top layer, placement, presence, dismissal and focus, behind every popup.",
    image: cover("#262626", "#737373"),
  },
  focus: {
    title: "Focus",
    description:
      "Roving focus, focus recovery and keeping focus where people expect it.",
    image: cover("#525252", "#a3a3a3"),
  },
  motion: {
    title: "Motion",
    description:
      "Presence and gesture physics: what animates in, out and with a finger.",
  },
} satisfies Record<string, LinkPreviewPage>

function Example() {
  return (
    <LinkPreview>
      <p>
        Every popup sits on the{" "}
        <LinkPreviewTrigger
          href="/docs/core/behaviors/popups"
          payload={pages.popups}
        >
          popup layer
        </LinkPreviewTrigger>
        , moves{" "}
        <LinkPreviewTrigger
          href="/docs/core/behaviors/focus"
          payload={pages.focus}
        >
          focus
        </LinkPreviewTrigger>{" "}
        in and back, and animates with{" "}
        <LinkPreviewTrigger
          href="/docs/core/behaviors/motion"
          payload={pages.motion}
        >
          motion
        </LinkPreviewTrigger>{" "}
        it shares with the rest of the page.
      </p>
      <LinkPreviewContent sideOffset={6}>
        {(state) => {
          const page = state.payload as LinkPreviewPage | undefined
          return (
            <>
              <LinkPreviewImage />
              <div data-slot="stack">
                <LinkPreviewTitle>{page?.title}</LinkPreviewTitle>
                <LinkPreviewDescription>
                  {page?.description}
                </LinkPreviewDescription>
              </div>
            </>
          )
        }}
      </LinkPreviewContent>
    </LinkPreview>
  )
}

export {
  Example,
  LinkPreview,
  LinkPreviewContent,
  LinkPreviewDescription,
  LinkPreviewImage,
  LinkPreviewTitle,
  LinkPreviewTrigger,
}
Hover or focus popup layer, focus, and motion. The preview follows each link; motion has no image, so its image part doesn't render.

The backdrop part sits behind a modal popup; a press on it counts as outside. viewport provides the full-screen container for a sheet. Use individual builders when you only need a few parts, or popupSlots when you need the set.

Creation Options

presetPopupPreset
The behavior, like popoverPreset or hoverCardPreset.
Example: A Popup of Your Own
slot?string
The slot that is the popup.
Example: A Popup of Your Own
trigger?string
The slot that opens it.
Example: A Popup of Your Own
role?"alertdialog" | "dialog" | "menu" | "tooltip"
The content's role, like "alertdialog".
Example: Pick Its Behavior

Compose Ours

A picker can own its selection while an existing Popover handles opening, placement, and dismissal. Choose the component that fits what you're showing:

You NeedRender
Interactive content beside a triggerPopover
A list of actionsMenu
A modal task or confirmationDialog, Alert Dialog
A sheet from the screen's edgeDrawer
A hover label or previewTooltip, Hover Card
A choice from a listSelect, Combobox

A Picker in a Popover

This emoji picker already owns open. Its React renderer passes that state to Popover and forwards Popover's change details into the picker's setOpen method:

<Popover
  open={state.open}
  onOpenChange={(open, details) =>
    handle.api.setOpen(open, {
      reason: details.reason,
      event: details.event,
      source: details.source,
    })
  }
>
  {children}
</Popover>

Forwarding the reason, event, and source keeps the change meaningful to the picker's caller. Picking an emoji closes the same state with its own item-press reason.

Console
import * as React from "react"
import {
  createChange,
  definePrimitive,
  type ChangeDetails,
  type ChangeSource,
} from "@ui-sdk/core"
import type { PopoverOpenChangeReason } from "@ui-sdk/core/popover"
import { Popover, PopoverContent, PopoverTrigger } from "@ui-sdk/react/popover"
import { createParts, usePart, usePrimitive } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type EmojiPickerOpenReason = PopoverOpenChangeReason | "item-press"

type EmojiPickerOptions = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (
    open: boolean,
    details: ChangeDetails<EmojiPickerOpenReason>
  ) => void
  onPick?: (emoji: string) => void
}

const emojiPicker = definePrimitive({
  name: "emoji-picker",
  options: {
    open: "boolean",
    defaultOpen: "boolean",
    onOpenChange: "callback",
    onPick: "callback",
  },
  state: (options: EmojiPickerOptions) => ({
    open: options.open ?? options.defaultOpen ?? false,
  }),
  sync: (options) => (options.open === undefined ? {} : { open: options.open }),
  api: (context) => {
    const change = createChange(context)
    return {
      setOpen: (
        open: boolean,
        details: {
          reason: EmojiPickerOpenReason
          event?: Event
          source?: ChangeSource
        }
      ) => change("open", open, details),
      pick(emoji: string, event?: Event) {
        context.options().onPick?.(emoji)
        change("open", false, { reason: "item-press", event })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { open: boolean }>({
      state: (state) => ({ open: state.open }),
    }),
    emoji: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (_state, { key }) => ({ type: "button", "aria-label": key }),
      press: ({ api, key }, event) => api.pick(key, event),
    }),
  }),
})

const parts = createParts(emojiPicker, { emoji: "button" })

function EmojiPicker({
  children,
  ...options
}: EmojiPickerOptions & { children: React.ReactNode }) {
  const handle = usePrimitive(emojiPicker, options)
  return usePart(
    handle,
    "root",
    {},
    {
      render: (_props, state) => (
        <Popover
          open={state.open}
          onOpenChange={(open, details) =>
            handle.api.setOpen(open, {
              reason: details.reason,
              event: details.event,
              source: details.source,
            })
          }
        >
          {children}
        </Popover>
      ),
    }
  )
}

const EmojiPickerItem = parts.Emoji

const emojis = ["๐ŸŽ‰", "๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐Ÿ‘€", "๐Ÿš€"]

function Example() {
  const log = useLog()
  return (
    <EmojiPicker onPick={(emoji) => log(`Reacted with ${emoji}`)}>
      <PopoverTrigger>React</PopoverTrigger>
      <PopoverContent>
        {emojis.map((emoji) => (
          <EmojiPickerItem key={emoji} value={emoji}>
            {emoji}
          </EmojiPickerItem>
        ))}
      </PopoverContent>
    </EmojiPicker>
  )
}

export {
  emojiPicker,
  EmojiPicker,
  EmojiPickerItem,
  Example,
  type EmojiPickerOpenReason,
  type EmojiPickerOptions,
}
Open React and choose an emoji. The picker records the reaction and closes its Popover.

The demo's Code tab includes the React renderer. Custom Parts covers the rendering helpers, and the Web Components guide shows the connection in custom elements. The popup's content props, such as initialFocus, still work normally.

Open from Code

A popup controller lets an event handler open a popup and wait for its result. Each popup exports a factory, such as createPopoverController, from its core entry and its framework adapter.

Open, Wait, and Close

Create a controller and pass it to the popup. Here the payload is a point:

import {
  createPopoverController,
  Popover,
  PopoverContent,
} from "@ui-sdk/react/popover"

const note = createPopoverController<{ x: number; y: number }>()

function Note() {
  return (
    <Popover controller={note}>
      <PopoverContent>Leave a note at this point.</PopoverContent>
    </Popover>
  )
}

From a click handler, give open an anchor and payload. The anchor uses viewport coordinates; the payload can be any data your content needs:

const result = await note.open({
  anchor: { x: event.clientX, y: event.clientY },
  payload: { x: event.clientX, y: event.clientY },
})

The content reads the payload from its render state. open resolves when the popup closes, with reason, source, and an optional returnValue. To return a value from a button inside the popup:

note.close({ returnValue: "kept" })
Console
import * as React from "react"
import {
  createPopoverController,
  Popover,
  PopoverContent,
} from "@ui-sdk/react/popover"
import { useLog } from "./log"

type Spot = { x: number; y: number }

const note = createPopoverController<Spot>()

function Example() {
  const [timed, setTimed] = React.useState(false)
  const log = useLog()
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <label data-slot="label">
          <input
            type="checkbox"
            checked={timed}
            onChange={(event) => setTimed(event.target.checked)}
          />
          Close by itself
        </label>
      </div>
      <div
        role="button"
        tabIndex={0}
        aria-label="Card"
        onClick={async (event) => {
          const box = event.currentTarget.getBoundingClientRect()
          const result = await note.open({
            anchor: { x: event.clientX, y: event.clientY },
            payload: {
              x: Math.round(event.clientX - box.left),
              y: Math.round(event.clientY - box.top),
            },
            signal: timed ? AbortSignal.timeout(2000) : undefined,
          })
          log(
            `Closed by ${result.reason} (${result.source})${result.returnValue ? `, ${result.returnValue}` : ""}`
          )
        }}
        data-slot="popup-surface"
      >
        Click to leave a note
      </div>
      <Popover controller={note}>
        <PopoverContent<Spot> align="start" sideOffset={4}>
          {(state) => {
            const spot = state.payload
            return (
              <>
                <p data-spot>{spot ? `A note at ${spot.x}, ${spot.y}` : ""}</p>
                <button
                  type="button"
                  data-variant="solid"
                  data-size="sm"
                  onClick={() => note.close({ returnValue: "kept" })}
                >
                  Keep
                </button>
              </>
            )
          }}
        </PopoverContent>
      </Popover>
    </div>
  )
}

export { Example, note, type Spot }
Click the card to open a note at that point. Press Keep or Escape and read the result. Select Close by itself to close after two seconds.

Pass signal: AbortSignal.timeout(2000) to open for a timed close. An abort closes with reason imperative; removing the popup's component resolves with unmount. The promise resolves once and doesn't reject.

To expose your own component's API this way, see Controllers.

Placing a Popup

position(anchor, element, options) places an element beside an anchor, watches for movement, and flips it when there isn't room. It doesn't open or dismiss the element.

Beside an Anchor

An anchor can be an element, a point such as { x, y }, or an object with getBoundingClientRect, such as a text selection's range.

import { position } from "@ui-sdk/core"

const stop = position(anchor, popup, {
  side: "bottom",
  align: "start",
  sideOffset: 4,
})

Call stop() when the element goes away. In a primitive, return the cleanup from the slot's bind:

card: slot({
  present: (state) => state.open,
  bind: (el, { store }) => position(() => store.get().anchor, el),
}),

The getter lets the anchor change while the element stays mounted. A virtual anchor's rectangle is checked each frame, so a toolbar can follow a changing text selection.

import * as React from "react"
import {
  bindLayer,
  css,
  definePrimitive,
  keepFocus,
  position,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type Point = { x: number; y: number }

type PinsState = {
  open: boolean
  anchor: Point | HTMLElement | null
  pin: Point | null
}

const pins = definePrimitive({
  name: "pins",
  state: (): PinsState => ({ open: false, anchor: null, pin: null }),
  api: (context) => {
    const { store } = context
    const close = () => {
      store.set({ open: false })
      keepFocus(() => context.element("map"))
    }
    return {
      close,
      press(event: MouseEvent, map: HTMLElement) {
        const box = map.getBoundingClientRect()
        const pin = {
          x: Math.round(event.clientX - box.left),
          y: Math.round(event.clientY - box.top),
        }
        store.set({
          open: true,
          anchor: { x: event.clientX, y: event.clientY },
          pin,
        })
      },
      keydown(event: KeyboardEvent, map: HTMLElement) {
        if (event.key !== "Enter") {
          return
        }
        event.preventDefault()
        const pin = {
          x: Math.round(map.clientWidth / 2),
          y: Math.round(map.clientHeight / 2),
        }
        store.set({ open: true, anchor: map, pin })
      },
    }
  },
  slots: (slot) => ({
    map: slot({
      attrs: () => ({ role: "button", tabindex: "0" }),
      on: {
        click: (event, { el, api }) => api.press(event, el!),
        keydown: (event, { el, api }) => api.keydown(event, el!),
      },
    }),
    card: slot<{}, { pin: Point | null }>({
      present: (state) => state.open,
      state: (state) => ({ pin: state.pin }),
      attrs: () => ({
        role: "dialog",
        style: css({ inset: "auto", margin: "0" }),
      }),
      bind: (el, { api, store }) => {
        const stopLayer = bindLayer(el, {
          topLayer: true,
          onDismiss: () => api.close(),
        })
        const stopPosition = position(() => store.get().anchor, el, {
          side: "bottom",
          align: "start",
          sideOffset: 4,
        })
        el.querySelector("button")?.focus({ preventScroll: true })
        return () => {
          stopPosition()
          stopLayer()
        }
      },
    }),
    close: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.close(),
    }),
  }),
})

const parts = createParts(pins, { map: "div", card: "div", close: "button" })

const Pins = parts.Root

const PinsMap = parts.Map

const PinsCard = parts.Card

const PinsClose = parts.Close

function Example() {
  return (
    <Pins>
      <PinsMap aria-label="Map, press to drop a pin" />
      <PinsCard aria-label="Pin">
        {({ pin }) => (
          <>
            <span>
              Pin at {pin?.x}, {pin?.y}
            </span>
            <PinsClose data-size="sm">Done</PinsClose>
          </>
        )}
      </PinsCard>
    </Pins>
  )
}

export {
  Example,
  pins,
  Pins,
  PinsCard,
  PinsClose,
  PinsMap,
  type PinsState,
  type Point,
}
Click the map to place a pin, then close it with Escape or Done. Focus the map and press Enter to open the card from the keyboard.

The demo combines position with a layer for dismissal, and handles focus when opening and closing.

Sides, Gaps and an Arrow

Pass placement options directly, or return them from a getter when they can change while open. The arrow can also be a getter, so it can mount after the content:

bubble: slot({
  bind: (el, { api }) =>
    position(api.target, el, () => ({
      side: "top",
      align: "center",
      sideOffset: 8,
      arrow: () => api.arrow(),
      arrowPadding: 8,
    })),
}),
import * as React from "react"
import {
  css,
  definePrimitive,
  position,
  type Align,
  type Side,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type CalloutOptions = {
  side?: Side
  align?: Align
  sideOffset?: number
  alignOffset?: number
  collisionPadding?: number
  arrowPadding?: number
}

type CalloutState = { open: boolean }

const callout = definePrimitive({
  name: "callout",
  options: {
    side: "string",
    align: "string",
    sideOffset: "number",
    alignOffset: "number",
    collisionPadding: "number",
    arrowPadding: "number",
  },
  state: (_options: CalloutOptions): CalloutState => ({ open: true }),
  api: (context) => ({
    toggle: () => context.store.set({ open: !context.store.get().open }),
    target: () => context.element("target"),
    placement: () => ({
      ...context.options(),
      arrow: () => context.element("arrow"),
    }),
  }),
  slots: (slot) => ({
    root: slot({}),
    target: slot({
      attrs: (state) => ({
        type: "button",
        "aria-expanded": String(state.open),
      }),
      press: ({ api }) => api.toggle(),
    }),
    bubble: slot({
      present: (state) => state.open,
      attrs: () => ({ role: "tooltip" }),
      bind: (el, { api }) => position(api.target, el, api.placement),
    }),
    arrow: slot({
      attrs: () => ({
        "aria-hidden": "true",
        style: css({ position: "absolute" }),
      }),
    }),
  }),
})

const parts = createParts(callout, {
  root: "div",
  target: "button",
  bubble: "div",
  arrow: "span",
})

const Callout = parts.Root

const CalloutTarget = parts.Target

const CalloutBubble = parts.Bubble

const CalloutArrow = parts.Arrow

const sides: Side[] = ["top", "right", "bottom", "left", "start", "end"]
const aligns: Align[] = ["start", "center", "end"]

function Example() {
  const [side, setSide] = React.useState<Side>("top")
  const [align, setAlign] = React.useState<Align>("center")
  const [offsets, setOffsets] = React.useState({
    sideOffset: 8,
    alignOffset: 0,
    collisionPadding: 8,
  })
  return (
    <Callout {...{ side, align, ...offsets, arrowPadding: 8 }}>
      <div data-slot="position-stage">
        <CalloutTarget>Shortcuts</CalloutTarget>
      </div>
      <CalloutBubble>
        <CalloutArrow />
      </CalloutBubble>
      <div data-slot="grid">
        <label data-slot="label">
          Side
          <select
            value={side}
            onChange={(event) => setSide(event.target.value as Side)}
          >
            {sides.map((value) => (
              <option key={value}>{value}</option>
            ))}
          </select>
        </label>
        <label data-slot="label">
          Align
          <select
            value={align}
            onChange={(event) => setAlign(event.target.value as Align)}
          >
            {aligns.map((value) => (
              <option key={value}>{value}</option>
            ))}
          </select>
        </label>
        {(["sideOffset", "alignOffset", "collisionPadding"] as const).map(
          (key) => (
            <label key={key} data-slot="label">
              {key}
              <input
                type="range"
                min={key === "alignOffset" ? -40 : 0}
                max={40}
                value={offsets[key]}
                onChange={(event) =>
                  setOffsets({ ...offsets, [key]: Number(event.target.value) })
                }
              />
            </label>
          )
        )}
      </div>
    </Callout>
  )
}

export {
  Example,
  callout,
  Callout,
  CalloutArrow,
  CalloutBubble,
  type CalloutOptions,
  type CalloutState,
  CalloutTarget,
}
Change Side, Align, and the offsets. With the hint above its button, scroll the button near the top of the window to see the hint flip below.

data-side and data-align report the final placement on both the popup and arrow. Use --available-width and --available-height to constrain the content, and --transform-origin for its animation origin. The returned cleanup also has a place() method to recalculate immediately.

Position Options

side?Side
Which side of the anchor it opens on.
Example: Sides, Gaps and an Arrow
align?Align
How it lines up with the anchor along that side.
Example: Sides, Gaps and an Arrow
sideOffset?number
Gap between the anchor and the element, in pixels.
Example: Sides, Gaps and an Arrow
alignOffset?number
Shift along the anchor, in pixels.
Example: Sides, Gaps and an Arrow
collisionPadding?number
Space to keep from the viewport edges, in pixels.
Example: Sides, Gaps and an Arrow
arrow?HTMLElement | (() => HTMLElement | null | undefined) | null
An element inside that points at the anchor, like an arrow, placed along the edge facing it. A function is read on every check, so an arrow that mounts later is still found.
Example: Sides, Gaps and an Arrow
arrowPadding?number
Space to keep between the arrow and the corners, in pixels.
Example: Sides, Gaps and an Arrow

Layers of Your Own

bindLayer registers an element in the same dismissal stack as the SDK's popups. Use it when you're building a floating UI with its own shape and behavior, such as a ring of actions or a toolbar over a selection.

A Layer in the Stack

Bind the layer while its part is present, and return the cleanup. contains lets you count another element, such as its trigger, as inside the layer:

import { bindLayer } from "@ui-sdk/core"

panel: slot({
  present: (state) => state.open,
  bind: (el, { api }) =>
    bindLayer(el, {
      topLayer: true,
      contains: (target) => !!api.trigger()?.contains(target),
      onDismiss: () => api.close(),
    }),
}),

Escape goes to the topmost layer. Nested popups count as inside their parent, so interacting with a menu inside your panel keeps the panel open:

import * as React from "react"
import {
  bindLayer,
  css,
  definePrimitive,
  keepFocus,
  position,
} from "@ui-sdk/core"
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@ui-sdk/react/menu"
import {
  usePart,
  usePrimitive,
  type PartProps,
  type PrimitiveHandle,
} from "@ui-sdk/react/primitive"

type Access = "view" | "comment" | "edit"

type ShareState = { open: boolean; access: Access }

const share = definePrimitive({
  name: "share",
  state: (): ShareState => ({ open: false, access: "view" }),
  api: (context) => {
    const { store } = context
    return {
      trigger: () => context.element("trigger"),
      toggle: () => store.set({ open: !store.get().open }),
      close() {
        store.set({ open: false })
        keepFocus(() => context.element("trigger"))
      },
      setAccess: (access: Access) => store.set({ access }),
    }
  },
  slots: (slot) => ({
    trigger: slot({
      attrs: (state, { id }) => ({
        type: "button",
        "aria-expanded": String(state.open),
        "aria-controls": `${id}-panel`,
      }),
      press: ({ api }) => api.toggle(),
    }),
    panel: slot<{}, { access: Access }>({
      present: (state) => state.open,
      state: (state) => ({ access: state.access }),
      attrs: (_state, { id }) => ({
        id: `${id}-panel`,
        role: "dialog",
        style: css({ inset: "auto", margin: "0" }),
      }),
      bind: (el, { api }) => {
        const stopLayer = bindLayer(el, {
          topLayer: true,
          contains: (target) => !!api.trigger()?.contains(target),
          onDismiss: () => api.close(),
        })
        const stopPosition = position(() => api.trigger(), el, {
          side: "bottom",
          align: "start",
          sideOffset: 4,
        })
        return () => {
          stopPosition()
          stopLayer()
        }
      },
    }),
  }),
})

const ShareContext = React.createContext<PrimitiveHandle<typeof share> | null>(
  null
)

function useShare() {
  const context = React.useContext(ShareContext)
  if (!context) {
    throw new Error("Share parts must be used inside <Share>.")
  }
  return context
}

function Share({ children }: { children: React.ReactNode }) {
  const handle = usePrimitive(share, {})
  return (
    <ShareContext.Provider value={handle}>{children}</ShareContext.Provider>
  )
}

function ShareTrigger(props: PartProps<"button">) {
  return usePart(useShare(), "trigger", {}, props, "button")
}

function SharePanel(props: PartProps<"div", { access: Access }>) {
  return usePart(useShare(), "panel", {}, props, "div")
}

const labels: Record<Access, string> = {
  view: "Can view",
  comment: "Can comment",
  edit: "Can edit",
}

function AccessMenu({ access }: { access: Access }) {
  const { api } = useShare()
  return (
    <Menu onAction={(value) => api.setAccess(value as Access)}>
      <MenuTrigger data-size="sm">{labels[access]}</MenuTrigger>
      <MenuContent>
        {(Object.keys(labels) as Access[]).map((value) => (
          <MenuItem key={value} value={value}>
            {labels[value]}
          </MenuItem>
        ))}
      </MenuContent>
    </Menu>
  )
}

function Example() {
  return (
    <Share>
      <ShareTrigger>Share</ShareTrigger>
      <SharePanel aria-label="Share">
        {({ access }) => (
          <>
            <span>Anyone with the link</span>
            <AccessMenu access={access} />
          </>
        )}
      </SharePanel>
    </Share>
  )
}

export {
  Example,
  share,
  Share,
  SharePanel,
  ShareTrigger,
  type Access,
  type ShareState,
}
Open Share, then its access menu. Press Escape once to close the menu and again to close Share. Choosing an access level keeps Share open.

onDismiss(reason, event) asks your code to close the layer. You still own its state, role, focus behavior, and placement. topLayer: true uses the browser's top layer when supported, so the element can appear above clipping ancestors.

For bindLayer, modal: true blocks an outside press from reaching the page beneath. closeOnBlur asks it to close when the window loses focus:

bindLayer(el, {
  topLayer: true,
  modal: true,
  closeOnBlur: true,
  onDismiss: (reason) => api.close(reason),
})

This layer option handles outside presses. Use a dialog preset when you also need modal focus handling and the rest of the page made inert.

Console
import * as React from "react"
import {
  bindLayer,
  bindPersistentLayer,
  css,
  definePrimitive,
  isDismissal,
  keepFocus,
  position,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type TimerOptions = {
  modal?: boolean
  onClose?: (reason: string) => void
}

type TimerState = { open: boolean }

const timer = definePrimitive({
  name: "timer",
  options: { modal: "boolean", onClose: "callback" },
  state: (_options: TimerOptions): TimerState => ({ open: false }),
  api: (context) => {
    const { store } = context
    return {
      trigger: () => context.element("trigger"),
      modal: () => !!context.options().modal,
      open: () => store.set({ open: true }),
      close(reason: string) {
        store.set({ open: false })
        context.options().onClose?.(reason)
        keepFocus(() => context.element("trigger"))
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    trigger: slot({
      attrs: (state) => ({
        type: "button",
        "aria-expanded": String(state.open),
      }),
      press: ({ api }) => api.open(),
    }),
    panel: slot({
      present: (state) => state.open,
      attrs: () => ({
        role: "dialog",
        style: css({ inset: "auto", margin: "0" }),
      }),
      bind: (el, { api }) => {
        const stopLayer = bindLayer(el, {
          topLayer: true,
          modal: api.modal(),
          closeOnBlur: true,
          contains: (target) => !!api.trigger()?.contains(target),
          onDismiss: (reason) => api.close(reason),
        })
        const stopPosition = position(api.trigger, el, {
          side: "bottom",
          align: "start",
        })
        el.querySelector("button")?.focus({ preventScroll: true })
        return () => {
          stopPosition()
          stopLayer()
        }
      },
    }),
    done: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.close("done-press"),
    }),
    player: slot({ bind: (el) => bindPersistentLayer(el) }),
  }),
})

const parts = createParts(timer, {
  root: "div",
  trigger: "button",
  panel: "div",
  done: "button",
  player: "div",
})

const Timer = parts.Root

const TimerTrigger = parts.Trigger

const TimerPanel = parts.Panel

const TimerDone = parts.Done

const TimerPlayer = parts.Player

function Example() {
  const [modal, setModal] = React.useState(true)
  const [count, setCount] = React.useState(0)
  const [playing, setPlaying] = React.useState(true)
  const log = useLog()
  return (
    <Timer
      modal={modal}
      onClose={(reason: string) =>
        log(
          isDismissal(reason) ? `Dismissed by ${reason}` : `Closed by ${reason}`
        )
      }
    >
      <div data-slot="controls">
        <label data-slot="label">
          <input
            type="checkbox"
            checked={modal}
            onChange={(event) => setModal(event.target.checked)}
          />
          Modal
        </label>
      </div>
      <div data-slot="row">
        <TimerTrigger>Set a Timer</TimerTrigger>
        <button type="button" onClick={() => setCount(count + 1)}>
          Count {count}
        </button>
      </div>
      <TimerPanel>
        <p>Remind me in 10 minutes.</p>
        <TimerDone data-variant="solid" data-size="sm">
          Done
        </TimerDone>
      </TimerPanel>
      <TimerPlayer>
        {playing ? "Playing: Rain sounds" : "Paused"}
        <button
          type="button"
          data-variant="ghost"
          data-size="sm"
          onClick={() => setPlaying(!playing)}
        >
          {playing ? "Pause" : "Play"}
        </button>
      </TimerPlayer>
    </Timer>
  )
}

export {
  Example,
  timer,
  Timer,
  TimerDone,
  TimerPanel,
  TimerPlayer,
  type TimerOptions,
  type TimerState,
  TimerTrigger,
}
Open the timer and press Count with Modal on, then off. Try Pause while the panel is open: the player stays usable. Switching windows dismisses the panel too.

bindPersistentLayer keeps an element usable across layers, including modal popups. It works well for a toast region or player; presses inside it don't dismiss other layers:

player: slot({ bind: (el) => bindPersistentLayer(el) }),

Use isDismissal(reason) to distinguish a dismissal, such as Escape or an outside press, from a choice made inside your UI:

onClose: (reason) => {
  setStatus(isDismissal(reason) ? "Dismissed" : "Closed")
},

Layer Options

onDismiss(reason: LayerDismissReason, event: Event) => void
Runs when Escape, a press outside or the window losing focus asks the layer to close.
Example: A Layer in the Stack
contains?((target: Node) => boolean)
Other elements that count as inside, like the trigger that opened it.
Example: A Layer in the Stack
modal?boolean
Whether a press outside only closes the layer, without reaching the page beneath. Defaults to false.
Example: Modal Layers, and What Stays Usable
topLayer?boolean
Whether the element joins the browser's top layer. Defaults to false.
Example: A Layer in the Stack
closeOnBlur?boolean
Whether the window losing focus closes it, like a click into an iframe. Defaults to false.
Example: Modal Layers, and What Stays Usable

Types and the Top Layer

SDK popups render in place using the browser's top layer. When it isn't supported, adapters use a portal to the body. A container prop lets you choose a container explicitly. supportsTopLayer() checks support.

A raw element passed to bindLayer needs its own placement and styles. Reset the browser's default popover inset, margin, padding, border, and background before applying your design.

Use PositionProps for the placement props you forward to a popup, and Anchor for an element or virtual anchor. Side and Align type individual placement values; PopupController describes the methods returned by a popup controller factory.