@hive-ui/overlay-primitive
v0.11.1
Published
Native dialog and popover foundations for Hive UI
Readme
Native overlay primitives
@hive-ui/overlay-primitive provides native modal dialogs and non-modal popovers with a shared React state API. It has no Reach, Reakit, Popper, focus-lock or animation dependency.
This is the foundation for a new API, not a compatibility adapter. Existing Hive Modal, Popover, Menu and other components still use their existing implementations. Migrate those consumers separately; do not assume legacy portals work inside a native dialog.
import { DialogRoot, DialogTrigger, DialogContent, DialogClose, PopoverRoot, PopoverTrigger, PopoverContent, PopoverClose } from "@hive-ui/overlay-primitive";
// The same exports are available from @hive-ui/core/overlay-primitive.Dialog
import { useId, useRef } from "react";
import { DialogRoot, DialogTrigger, DialogContent, DialogClose } from "@hive-ui/overlay-primitive";
function EditRecord() {
const titleId = useId();
const nameRef = useRef<HTMLInputElement>(null);
return (
<DialogRoot>
<DialogTrigger>Edit record</DialogTrigger>
<DialogContent aria-labelledby={titleId} initialFocusRef={nameRef}>
<h2 id={titleId}>Edit record</h2>
<label>
Name <input ref={nameRef} />
</label>
<DialogClose>Cancel</DialogClose>
</DialogContent>
</DialogRoot>
);
}DialogContent renders a real dialog, opens it with showModal(), and uses the browser's top layer, modal focus handling and background inertness. It does not accept as, a focus-lock bypass, a controlled HTML open attribute, or an overlay/content wrapper pair. Its ref is HTMLDialogElement.
| Prop | Default | Meaning |
| --------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
| aria-label or aria-labelledby | Required | Accessible name; prefer the visible heading's ID |
| role | dialog | dialog or alertdialog |
| dismissOnEscape | True for dialog; false for alertdialog | Requests closure on native cancel/Escape |
| dismissOnOutsidePointer | false | Requests closure only when pointer-down and click are both on the backdrop outside content bounds |
| lockScroll | true | Reference-counted document scrolling lock; turning it off does not remove native inertness |
| initialFocusRef | Browser's initial focus | Optional focus target after opening |
| finalFocusRef | Browser's focus restoration | Optional connected target after closure/unmount |
| element | DIALOG_CONTENT | data-hive-element marker |
Dialog native lifecycle props (onCancel, onClose, onBeforeToggle, onToggle, closedBy) belong to the foundation. Use onOpenChange on the root. Other dialog HTML props, className, style, children and ordinary pointer handlers are forwarded. Consumer pointer handlers can prevent the primitive's outside-dismiss request with preventDefault().
For destructive confirmations, use role="alertdialog", provide a description when useful, and explicitly focus the least destructive action. The primitive does not infer which button is safest. It requires an explicit close action by default.
Popover
import { PopoverRoot, PopoverTrigger, PopoverContent, PopoverClose } from "@hive-ui/overlay-primitive";
function RecordHelp() {
return (
<PopoverRoot>
<PopoverTrigger>Help</PopoverTrigger>
<PopoverContent role="region" aria-label="Record help">
<p>Use a name that people will recognise.</p>
<PopoverClose>Close help</PopoverClose>
</PopoverContent>
</PopoverRoot>
);
}PopoverContent renders div popover="auto" by default. Background content remains interactive; there is no focus trap or scroll lock. It has no default ARIA role: the consumer supplies semantics appropriate to its contents. Its ref is HTMLDivElement.
| Prop | Default | Meaning |
| ----------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
| mode | auto | auto uses browser light dismissal/stacking; manual closes only through owner state, controls or ancestor closure |
| initialFocusRef | No explicit focus move | Optional target; native descendant autofocus still applies |
| finalFocusRef | Active trigger | Restoration target when focus was inside the closing surface and has not intentionally moved outside |
| element | POPOVER_CONTENT | data-hive-element marker |
mode="manual" has no automatic Escape or outside dismissal and allows independent popovers to coexist. Use it when a controlled owner must be able to refuse all close requests. Change mode only while closed. Native auto exclusivity may close another auto popover; nesting follows the browser's DOM relationships.
The root owns id, popover, onBeforeToggle and onToggle. Consumers supply other div attributes and styling, but must not add modal semantics to a popover. Native popovers do not implement menu keyboard navigation, tooltip delays, combobox selection, or anchoring to a trigger.
Shared state and controls
Each family has Root, Trigger, Content and Close components. Use exactly one Content per Root. Keep Content mounted and toggle root state; closed content is hidden but its React child state persists. Root renders no DOM wrapper.
| Root prop | Contract |
| ----------------------------- | -------------------------------------------------------------------------------------------- |
| open | Controlled boolean; requires onOpenChange |
| defaultOpen | Uncontrolled initial state, default false; mutually exclusive with open |
| onOpenChange(open, details) | A request from controls/dismissal, or a notification of irreversible native/ancestor closure |
| id | Optional stable content ID; generated with React useId otherwise |
| children | Compound components and application content |
Do not switch controlled/uncontrolled mode during a root's lifetime. State changes made directly by the owner do not call onOpenChange. defaultOpen is read only at initial mount. Nested roots are subordinate to their parent root: closing the ancestor closes descendants and resets uncontrolled child state. Controlled children must accept ancestor-close so they do not reopen later with stale owner state.
details contains reason and an optional native event:
| Reason | Behaviour |
| ----------------- | ----------------------------------------------------------------------------------------------------------- |
| trigger | Trigger requests toggling the root |
| close-button | Close requests false |
| escape-key | Dialog native cancel requests false |
| outside-pointer | Dialog backdrop gesture requests false |
| native-close | Dialog already closed, e.g. a form method="dialog" submission |
| native-dismiss | Popover already entering native closure, including Escape, light dismissal, auto exclusivity or native hide |
| ancestor-close | Parent root closed; descendant cannot remain open |
Controlled owners may refuse requests; they must accept native and ancestor closure notifications. A native popover close cannot be prevented through beforetoggle. The implementation does not close then reopen a refused auto popover. Use manual mode if refusal is needed. Do not call show/hide/close through DOM refs as an alternative state API; refs are for inspection, focus and positioning integration. Native dialog form closure is supported as a notification and must be reflected in owner state.
const [open, setOpen] = useState(false);
const [dirty, setDirty] = useState(false);
<DialogRoot
open={open}
onOpenChange={(next, details) => {
const notification = details.reason === "native-close" || details.reason === "ancestor-close";
if (!next && dirty && !notification) return;
setOpen(next);
}}>
<DialogTrigger>Edit</DialogTrigger>
<DialogContent aria-label="Edit record">
<input onChange={() => setDirty(true)} />
<DialogClose>Close</DialogClose>
</DialogContent>
</DialogRoot>;Triggers/Close components always render button type="button", including inside forms. They accept OverlayButtonProps (button HTML props plus element), an HTMLButtonElement ref, and a consumer onClick that runs first and can prevent the action. Trigger manages aria-controls/aria-expanded; DialogTrigger also supplies aria-haspopup="dialog". PopoverTrigger leaves aria-haspopup to the consumer. Native invoker/command props are excluded to avoid two owners toggling the same surface. Use a controlled root for custom triggers.
Public types: OverlayRootProps, DialogRootProps, PopoverRootProps, OverlayButtonProps, DialogContentProps, PopoverContentProps, OverlayOpenChange, OverlayChangeDetails, OverlayChangeReason.
Composition, styling and lifecycle
Render nested roots inside the parent's Content. Native top-layer rendering does not require a body portal, so inherited theme variables and DOM ancestry are preserved. An existing library that portals to document.body needs a descendant container inside the dialog or its own native-compatible integration. These foundations do not relocate third-party portals.
The surfaces retain UA dialog/popover appearance until styled. Use styled from @hive-ui/css-library or class names; theme tokens remain available through normal DOM inheritance. For example:
import { styled } from "@hive-ui/css-library";
const Content = styled(DialogContent)`
background: var(--colorBackgroundBody);
color: var(--colorText);
border: none;
border-radius: var(--borderRadius30);
padding: var(--space80);
&::backdrop {
background: var(--colorBackgroundOverlay);
}
`;Content exposes data-state="open|closed" for styling. The foundation forces inline display:none before hydration and while closed, even if the caller supplies style.display. Do not override that with author !important rules. SSR never emits dialog open or invokes browser APIs; initially open roots activate after hydration. Closed content remains mounted. Ordinary form controls are not automatically disabled just because their surface is closed.
Closure is immediate; no exit-animation promise or presence abstraction is provided. Avoid replacing display/visibility state, mutating open/popover, or moving an open surface between DOM parents. Focus-lock bypasses, Reakit state/ref fields, portal flags, placement/gutter/arrow APIs and polymorphic as are deliberately absent. A drawer is a styled DialogContent, not a positioned popover.
Browser support and validation
Native APIs are required; there is no polyfill or legacy implementation bundled here. Opening an unsupported surface throws a descriptive error. Keep existing components for applications requiring their current compatibility path.
The engineering target is Chrome/Edge 114+, Firefox 125+, Safari macOS 17+, and Safari iOS/iPadOS 18.3+. API availability is not complete qualification: the oldest versions, WebViews and assistive technology still need release testing. iOS's higher target reflects its historical outside-tap dismissal defect. See the compatibility contract and MDN compatibility data.
No dependency on closedby, dialog toggle events, popover hint, invoker commands, CSS anchor positioning or discrete CSS transitions is introduced. Dialog uses cancel/close events; Popover uses beforetoggle/toggle. The cancellation distinction follows the native beforetoggle contract.
Run from the repository root:
vp run --filter './packages/overlay-primitive' test
vp run --filter './packages/overlay-primitive' check
vp run --filter './packages/overlay-primitive' build
vp run devThe browser examples now live in the documentation site at /docs/primitives/overlay and run in Strict Mode. Open that path on the URL printed by vp run dev. It exercises controlled refusal, nested dialogs/popovers, ancestor closure, native form closure, unmounting, alert policy, manual popovers, background focus and scrolling. Node tests cover SSR/markup contracts and scroll-lock ownership; they do not prove native browser behaviour. Browser qualification results are recorded in BROWSER_TESTS.md.
