# Popups and Layers

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](#compose-ours) into your component, or use [positioning](#placing-a-popup) and [layers](#layers-of-your-own) 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:

```ts
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](/docs/core/build-a-component). The popover preset provides placement beside the trigger, dismissal, and focus handling:

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:

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

api: (context) => createPopup(context, { preset: dialogPreset }),
```

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](/docs/components/dialog): an untitled dialog takes its trigger's name, since a modal dialog makes the page inert, its trigger included.

| Preset            | Import From               | Use It For                            |
| ----------------- | ------------------------- | ------------------------------------- |
| `popoverPreset`   | `@ui-sdk/core/popover`    | Interactive content beside a trigger  |
| `dialogPreset`    | `@ui-sdk/core/dialog`     | A modal task that holds focus         |
| `tooltipPreset`   | `@ui-sdk/core/tooltip`    | A short label shown on hover or focus |
| `hoverCardPreset` | `@ui-sdk/core/hover-card` | A 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](/docs/components/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:

```tsx
<Cart
  dismissible={false}
  onDismissAttempt={(details) => setStatus(`Kept open: ${details.reason}`)}
>
  {children}
</Cart>
```

Open Cart and watch the reason when it closes. Try Modal, Open on hover, and clearing Dismissible. Check Out still closes the cart.

| Option                                | What It Changes                                                                                             |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `open`, `defaultOpen`, `onOpenChange` | Control the open state or observe changes, with reasons such as `trigger-press` and `escape-key`.           |
| `modal`                               | Override the preset's modality. `true` makes the page outside inert while the popup is open.                |
| `openOnHover`, `delay`, `closeDelay`  | Add hover opening to a press-opened popup and set the opening and closing delays in milliseconds.           |
| `dismissible`, `onDismissAttempt`     | Refuse dismissals and hear the attempted reason. Triggers, Close parts, and controllers can still close it. |
| `disabled`                            | Prevent opening.                                                                                            |

Controlled `open` uses the same [change and sync pattern](/docs/core/kernel/handle-changes#state-a-user-controls) 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:

```tsx
<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.

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:

```ts
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:

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

- `preset` (`PopupPreset`): The behavior, like `popoverPreset` or `hoverCardPreset`. Example: [A Popup of Your Own](#a-popup-of-your-own).
- `slot?` (`string`): The slot that is the popup. Example: [A Popup of Your Own](#a-popup-of-your-own).
- `trigger?` (`string`): The slot that opens it. Example: [A Popup of Your Own](#a-popup-of-your-own).
- `role?` (`"alertdialog" | "dialog" | "menu" | "tooltip"`): The content's role, like `"alertdialog"`. Example: [Pick Its Behavior](#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 Need                             | Render                                                                           |
| ------------------------------------ | -------------------------------------------------------------------------------- |
| Interactive content beside a trigger | [Popover](/docs/components/popover)                                              |
| A list of actions                    | [Menu](/docs/components/menu)                                                    |
| A modal task or confirmation         | [Dialog](/docs/components/dialog), [Alert Dialog](/docs/components/alert-dialog) |
| A sheet from the screen's edge       | [Drawer](/docs/components/drawer)                                                |
| A hover label or preview             | [Tooltip](/docs/components/tooltip), [Hover Card](/docs/components/hover-card)   |
| A choice from a list                 | [Select](/docs/components/select), [Combobox](/docs/components/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:

```tsx
<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.

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](/docs/react/custom-parts) covers the rendering helpers, and the [Web Components guide](/docs/web-components/custom-parts#compose-a-popup) 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:

```tsx
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:

```ts
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:

```ts
note.close({ returnValue: "kept" })
```

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](/docs/core/kernel/handle-changes#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.

```ts
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`:

```ts
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.

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](#layers-of-your-own) 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:

```ts
bubble: slot({
  bind: (el, { api }) =>
    position(api.target, el, () => ({
      side: "top",
      align: "center",
      sideOffset: 8,
      arrow: () => api.arrow(),
      arrowPadding: 8,
    })),
}),
```

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](#sides-gaps-and-an-arrow).
- `align?` (`Align`): How it lines up with the anchor along that side. Example: [Sides, Gaps and an Arrow](#sides-gaps-and-an-arrow).
- `sideOffset?` (`number`): Gap between the anchor and the element, in pixels. Example: [Sides, Gaps and an Arrow](#sides-gaps-and-an-arrow).
- `alignOffset?` (`number`): Shift along the anchor, in pixels. Example: [Sides, Gaps and an Arrow](#sides-gaps-and-an-arrow).
- `collisionPadding?` (`number`): Space to keep from the viewport edges, in pixels. Example: [Sides, Gaps and an Arrow](#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](#sides-gaps-and-an-arrow).
- `arrowPadding?` (`number`): Space to keep between the arrow and the corners, in pixels. Example: [Sides, Gaps and an Arrow](#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:

```ts
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:

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.

### Modal Layers, and What Stays Usable

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

```ts
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.

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:

```ts
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:

```ts
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](#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](#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](#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](#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](#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.

### Popup Types

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.
