Popup Primitive
Primitive Popup provides popup state, anchor registration, trigger behavior, portal rendering, and Floating UI positioning.
Usage
import {
Popup,
PopupAnchor,
PopupTrigger,
PopupElement,
usePopup,
createPopupController,
createClickAway,
createHoverAway,
} from '@suis-ui/primitives';The recommended primitive structure combines an anchor or trigger with a popup element rendered in a portal.
Popup
├── PopupAnchor | PopupTrigger
│ └── HTMLElement child
└── PopupElement
└── Portal
└── popup content<Popup placement="bottom-start" offset={4} flip>
<Popup.Trigger>
<button type="button">Open</button>
</Popup.Trigger>
<Popup.Element>
{(style) => (
<div style={style()}>
Popup content
</div>
)}
</Popup.Element>
</Popup>Props
State And Content
| Name | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Controlled open request value. Changes are reflected in popup open state. |
children | JSX.Element | Required | Popup composition. |
Positioning
| Name | Type | Default | Description |
|---|---|---|---|
placement | Placement | Floating UI default | Preferred placement. |
strategy | Strategy | Floating UI default | CSS positioning strategy. |
offset | OffsetOptions | - | Enables offset middleware. |
shift | ShiftOptions | boolean | - | Enables shift middleware. true uses the default options. |
flip | FlipOptions | boolean | - | Enables flip middleware. true uses the default options. |
autoUpdate | AutoUpdateOptions | boolean | true | Recomputes position when layout changes. |
middleware | Middleware[] | - | Additional Floating UI middleware. |
Component
Popup.Anchor
Registers a DOM child as the positioning anchor. Use it for controlled popups or custom trigger behavior.
| Name | Type | Default | Description |
|---|---|---|---|
children | JSX.Element | Required | Single DOM element registered as the anchor. |
If the child is not a DOM Element, it logs a warning.
Popup.Trigger
Wraps Popup.Anchor and toggles the popup when the anchor is clicked.
| Name | Type | Default | Description |
|---|---|---|---|
children | JSX.Element | - | DOM element registered as the trigger anchor. |
Popup.Element
Renders mounted popup content in a portal. The child is a render function that receives the computed style accessor.
| Name | Type | Default | Description |
|---|---|---|---|
children | (style: Accessor<JSX.CSSProperties>) => JSX.Element | Required | Function that renders popup content. The first returned DOM element is registered as the popup element. |
<Popup.Element>
{(style) => <div style={style()}>Content</div>}
</Popup.Element>Hooks
usePopup
Use usePopup under a Popup provider to read popup state and request open-state changes. It is intended for custom triggers, close buttons, or content components rendered inside Popup.
Signature
const [context, actions] = usePopup();const [context, actions]: readonly [
{
anchor: Element | null;
element: HTMLElement | null;
position: ComputePositionReturn | null;
open: boolean;
mount: boolean;
},
{
requestOpen: (open: boolean) => void;
},
] = usePopup();Calling it outside a Popup provider fails because there is no context to read.
Context
| Name | Type | Description |
|---|---|---|
context.anchor | Element | null | Positioning reference element registered by Popup.Anchor or Popup.Trigger. |
context.element | HTMLElement | null | First DOM element rendered inside Popup.Element. |
context.position | ComputePositionReturn | null | Floating UI result with x, y, placement, strategy, and middleware data. It is null before position is computed. |
context.open | boolean | Most recently requested open state. It updates immediately when requestOpen is called. |
context.mount | boolean | Mount state that controls whether Popup.Element renders portal content. If a controller is registered, this updates from the controller result. |
Public customization code should depend only on the state fields listed above.
Actions
| Name | Type | Description |
|---|---|---|
actions.requestOpen | (open: boolean) => void | Requests popup open state. Each request increments an id so stale async controller results cannot overwrite newer requests. |
Behavior
open is the intended state, while mount is the actual rendering state. A controller can delay mount updates when a close request should keep the DOM mounted briefly for animation.
position is computed after mount is true and both anchor and popup element are registered. Custom content that reads position should handle null.
requestOpen(false) requests closing, but it does not install document click-away or hover-away listeners by itself. Wire outside click, hover away, or custom dismissal behavior by calling requestOpen(false) from createClickAway, createHoverAway, or your own event handler.
Example
const CloseButton = () => {
const [, { requestOpen }] = usePopup();
return (
<button type="button" onClick={() => requestOpen(false)}>
Close
</button>
);
};createPopupController
Use createPopupController under a Popup provider to put an async controller between open requests and the actual mount state. It is useful for enter and exit animation where content should stay mounted briefly after a close request.
Signature
createPopupController(controller: (open: boolean) => Promise<boolean>): void;Parameters
| Name | Type | Description |
|---|---|---|
controller | (open: boolean) => Promise<boolean> | Runs when requestOpen(open) is called. The resolved boolean becomes the next mount state. |
Behavior
Call it under a Popup provider. Without a controller, mount follows the requested open value.
The controller receives the requested open state. Resolving true mounts Popup.Element; resolving false unmounts it.
If multiple open requests happen quickly, only the latest request is applied. Older Promises that resolve later cannot overwrite the latest mount state.
Example
const AnimatedMount = () => {
createPopupController(async (open) => {
if (open) return true;
await new Promise((resolve) => window.setTimeout(resolve, 150));
return false;
});
return null;
};createClickAway
createClickAway runs a dismissal handler when a document click happens outside the target element.
Signature
const register = createClickAway(
onClickAway: (cleanUp: () => void) => void,
);
const cleanUp = register(
element: Element | null | undefined | Accessor<Element | null | undefined>,
);Parameters And Return
| Name | Type | Description |
|---|---|---|
onClickAway | (cleanUp: () => void) => void | Called when a click happens outside the target. Receives cleanUp so the listener can be removed. |
element | Element | null | undefined | Accessor<Element | null | undefined> | Target used for click-away detection. If an accessor is passed, the latest target is resolved for each click event. |
cleanUp | () => void | Removes the registered document click listener. Returns a no-op cleanup if there is no target. |
Behavior
If there is no target at registration time, no listener is installed and a no-op cleanup is returned. If there is a target, a document click listener is installed. onClickAway(cleanUp) runs when the click event's composed path does not include the target.
Call cleanup when the owner component is disposed. If a single outside click should close the popup and remove the listener, call the provided cleanUp inside onClickAway.
Example
const ClickAwayCloser = () => {
const [context, { requestOpen }] = usePopup();
const register = createClickAway((cleanUp) => {
requestOpen(false);
cleanUp();
});
createEffect(() => {
if (!context.element) return;
const cleanUp = register(() => context.element);
onCleanup(cleanUp);
});
return null;
};createHoverAway
createHoverAway runs a dismissal handler after the pointer leaves the target and does not re-enter.
Signature
const register = createHoverAway(
onHoverAway: (cleanUp: () => void) => void,
);
const cleanUp = register(
element: Element | null | undefined | Accessor<Element | null | undefined>,
options?: { delay?: number },
);Parameters And Return
| Name | Type | Description |
|---|---|---|
onHoverAway | (cleanUp: () => void) => void | Called when the pointer leaves the target and does not re-enter before the delay ends. Receives cleanUp so listeners can be removed. |
element | Element | null | undefined | Accessor<Element | null | undefined> | Target used for hover-away detection. |
options.delay | number | Time to wait after pointerleave before running the handler. Default is 0. |
cleanUp | () => void | Removes the pointerleave and pointerenter listeners registered on the target. Returns a no-op cleanup if there is no target. |
Behavior
If there is no target at registration time, no listener is installed and a no-op cleanup is returned. If there is a target, pointerleave starts a delay timer, and pointerenter before the delay ends cancels the timer.
onHoverAway(cleanUp) runs after the delay. For tooltip-like dismissal, call both requestOpen(false) and cleanUp() inside the handler.
Example
const HoverAwayCloser = () => {
const [context, { requestOpen }] = usePopup();
const register = createHoverAway((cleanUp) => {
requestOpen(false);
cleanUp();
});
createEffect(() => {
if (!context.anchor) return;
const cleanUp = register(() => context.anchor, { delay: 120 });
onCleanup(cleanUp);
});
return null;
};Examples
Basic Trigger
<Popup placement="bottom-start" offset={4}>
<Popup.Trigger>
<button type="button">Open</button>
</Popup.Trigger>
<Popup.Element>
{(style) => (
<div style={style()}>
Popup content
</div>
)}
</Popup.Element>
</Popup>Custom Anchor
const ManualTrigger = () => {
const [, { requestOpen }] = usePopup();
return (
<Popup.Anchor>
<button type="button" onClick={() => requestOpen(true)}>
Open manually
</button>
</Popup.Anchor>
);
};
<Popup placement="right" shift>
<ManualTrigger />
<Popup.Element>
{(style) => <div style={style()}>Manual popup</div>}
</Popup.Element>
</Popup>Positioning Middleware
<Popup
placement="top-start"
offset={8}
shift
flip
autoUpdate={{ animationFrame: true }}
>
<Popup.Trigger>
<button type="button">Open</button>
</Popup.Trigger>
<Popup.Element>
{(style) => <div style={style()}>Positioned popup</div>}
</Popup.Element>
</Popup>