Editor Overlay Primitives.
Anything that pops over the canvas composes from here. Context menus, popovers, tooltips, dialogs: they are not built from scratch each time. They are assembled from seven leaf primitives that each own one part of the problem. Not a floating-UI framework. A layer of focused contracts.
block-context-menu.
packages/ui/src/primitives/block-context-menu.ts
Right-click and Shift+F10 both open the context menu for a focused block. Shift+F10 positions the menu at the center of the focused block’s bounding rect; right-click uses the pointer coordinates. Either way, positionMenu clamps to a 4px gap from every viewport edge so the menu never clips.
The primitive manages its own focus: on open, focus moves to the first [role="menuitem"]. Arrow keys, Home, and End navigate; Enter or Space activates. Escape dismisses. Focus is trapped inside via createFocusTrap and restored on close. Blocks are identified by [data-block-id] attributes; the primitive walks up the DOM from the event target to find the nearest one.
const menu = createBlockContextMenu({
container: canvasEl,
menu: menuEl,
onAction: (itemId, blockId) => dispatch({ itemId, blockId }),
});
menu.open(blockId, { x: 120, y: 340 });
dismissable-layer.
packages/ui/src/primitives/dismissable-layer.ts
One primitive unifying three dismissal signals: pointer down outside, escape key, and focus leaving the layer. Consumers get one onDismiss callback instead of wiring three separate listeners.
When disableOutsidePointerEvents is true, a <style> element is injected into <head> that sets pointer-events: none on all body > * except elements marked [data-dismissable-layer]. The style is reference-counted: the first open layer adds it, the last closed layer removes it.
Stacking is handled by createDismissableLayerStack. Each layer is pushed onto a module-level array. Escape, pointer down, and focus events are only processed by the topmost layer; inner layers see the events but return early. A dropdown inside a dialog closes the dropdown first, then the dialog.
Touch deduplication uses a per-layer WeakMap<HTMLElement, number> timestamp; events within 50ms of a prior touch are ignored to prevent double-firing.
// Modal dialog
const cleanup = createDismissableLayer(dialogEl, {
disableOutsidePointerEvents: true,
onDismiss: () => closeDialog(),
});
// Nested: dropdown inside dialog
const nestedCleanup = createDismissableLayerStack(dropdownEl, {
onDismiss: () => closeDropdown(),
});
collision-detector.
packages/ui/src/primitives/collision-detector.ts
Positions a floating element relative to an anchor, then adjusts if the preferred position would clip against the boundary. Prefers bottom by default; flips to top when the bottom edge collides and the opposite side has clearance. Horizontal overflow is clamped, not flipped. The result carries hasCollision and per-side collisions flags so consumers can conditionally animate the flip.
Arrow positioning is clamped to element bounds with configurable arrowPadding so the arrow never hangs off a corner.
autoPosition wraps applyPosition in a ResizeObserver on both anchor and floating elements, plus scroll and resize listeners. It returns a cleanup function.
Side, Align, and Position types are imported from ./types; see editor data model for their definitions.
const cleanup = autoPosition(anchorEl, tooltipEl, {
side: 'bottom',
align: 'center',
sideOffset: 8,
avoidCollisions: true,
arrowElement: arrowEl,
});
outside-click.
packages/ui/src/primitives/outside-click.ts
Two functions. onOutsideClick listens on mousedown and touchstart in capture phase, firing when the event target is outside the element. onPointerDownOutside uses the unified pointerdown API with touchstart as a fallback for environments that do not synthesize pointer events from touch (Playwright component tests, for example).
Touch deduplication in onPointerDownOutside uses a closure-level lastTouchTime timestamp with a 50ms threshold, preventing the touchstart fallback from double-firing when pointerdown also fires.
Both return a CleanupFunction. Neither has state. This is the lowest-level primitive in the overlay layer.
const cleanup = onPointerDownOutside(dropdownEl, (event) => {
event.preventDefault();
close();
});
hover-delay.
packages/ui/src/primitives/hover-delay.ts
Delay management for hover-triggered overlays. createHoverDelay attaches listeners directly to a trigger element. createControlledHoverDelay returns handler objects instead, for framework integration where you supply the event plumbing.
A module-level globalOpenTimestamp coordinates skip-delay behavior across instances. When a tooltip closes, the timestamp is set. Any tooltip that opens within 300ms sees shouldSkipOpenDelay() return true and skips the openDelay. Moving quickly between a row of toolbar buttons opens each tooltip immediately; pause for 300ms and the full delay resumes.
createHoverIntent is the lower-level variant: it polls mouse position every 100ms and only fires onEnter when movement drops below 7px between intervals. Useful for navigation menus where triggering on cursor pass-through would be noisy.
const cleanup = createHoverDelay(triggerEl, {
openDelay: 700,
closeDelay: 300,
contentElement: tooltipEl,
onOpen: () => show(),
onClose: () => hide(),
});
dialog-aria.
packages/ui/src/primitives/dialog-aria.ts
Pure functions. No DOM access, no side effects, SSR-safe. Three functions return ARIA prop bags as const objects for use directly in JSX or attribute-setting code.
getDialogAriaProps returns role="dialog", aria-modal, aria-labelledby, aria-describedby, and data-state. getOverlayAriaProps returns aria-hidden="true" and data-state. getTriggerAriaProps returns aria-expanded, aria-controls, aria-haspopup="dialog", and data-state. The data-state field drives CSS transitions; the overlay layer uses [data-state="open"] and [data-state="closed"] selectors rather than class toggling.
AriaAttributes, referenced in aria-manager, is defined in ./types; see editor data model for the full type.
const dialogProps = getDialogAriaProps({
open: isOpen,
labelId: 'dialog-title',
descriptionId: 'dialog-description',
modal: true,
});
// { role: 'dialog', aria-modal: 'true', aria-labelledby: 'dialog-title', ... }
aria-manager.
packages/ui/src/primitives/aria-manager.ts
The rest of the overlay layer produces ARIA attributes; this primitive manages them. setAriaAttributes stores original values before writing and returns a cleanup function that restores them, making it safe to apply ARIA from multiple primitives on the same element without conflicts.
Validation runs against a static map of ARIA 1.2 definitions covering widget attributes, live region attributes, and relationship attributes. Boolean types are coerced from JS booleans to "true"/"false" strings. Token types are checked against allowed values. Unknown aria-* attributes log a warning in development but are not rejected.
createAriaRelationship handles aria-labelledby, aria-describedby, aria-controls, and aria-owns. It generates IDs for target elements that lack them, tracks the generated IDs, and removes them on cleanup.
const cleanup = setAriaAttributes(triggerEl, {
'aria-expanded': true,
'aria-controls': 'menu-id',
'aria-haspopup': 'menu',
});
// Restore on close
cleanup();
Composition notes.
All seven are leaf primitives. None imports nanostores. State lives in closures, not atoms. When you compose them into a popover, the popover owns the state; each primitive owns one behavior.
Stacking semantics matter. createDismissableLayerStack is the right choice whenever layers can nest. Use createDismissableLayer for standalone overlays that will not have children; use the stack variant for anything that might be opened while another overlay is already open. The distinction is not about depth in the DOM. It is about which layer owns the escape key at a given moment.