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 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,
}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 }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.
| 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, 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>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,
}| 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 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.
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,
}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,
}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
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 Need | Render |
|---|---|
| Interactive content beside a trigger | Popover |
| A list of actions | Menu |
| A modal task or confirmation | Dialog, Alert Dialog |
| A sheet from the screen's edge | Drawer |
| A hover label or preview | Tooltip, Hover Card |
| A choice from a list | Select, 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.
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,
}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" })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 }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,
}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,
}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
sideOffset?number- Gap between the anchor and the element, 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,
}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:
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.
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,
}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.
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.