@atlaskit/top-layer
Low-level top-layer primitives using the native
Popover API (popover="auto") and
<dialog> element.
Use a Design System component first, such as Popup, Modal dialog, Tooltip or Dropdown menu. Use
these primitives only if no Design System component fits, behind the platform-dst-top-layer
feature flag. Do not use a native popover or <dialog> element directly: the
@atlaskit/design-system/no-native-popover-or-dialog ESLint rule blocks it.
Popover
The primary primitive: a <div> with the popover attribute, plus a small lifecycle (animations,
role-based focus management, light-dismiss, nested-focus restoration). It does not know about
placement or size — compose it with the useAnchoredPopover hook when you need anchor-positioned
content.
import { useRef, useState } from 'react';
import { getAriaForTrigger } from '@atlaskit/top-layer/get-aria-for-trigger';
import { Popover } from '@atlaskit/top-layer/popover/popover';
import { PopoverSurface } from '@atlaskit/top-layer/popover-surface';
import { usePopoverId } from '@atlaskit/top-layer/use-popover-id';
import { useAnchoredPopover } from '@atlaskit/top-layer/use-anchored-popover';
function MyPopup() {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const popoverId = usePopoverId();
useAnchoredPopover({
anchorRef: triggerRef,
popoverRef,
placement: { axis: 'block', edge: 'end', align: 'start' },
isOpen,
});
return (
<>
<button
ref={triggerRef}
onClick={() => setIsOpen((value) => !value)}
{...getAriaForTrigger({ role: 'dialog', isOpen, popoverId })}
>
Open
</button>
<Popover
ref={popoverRef}
id={popoverId}
role="dialog"
label="Settings"
isOpen={isOpen}
shouldAnimate
onClose={() => setIsOpen(false)}
>
<PopoverSurface>Content here</PopoverSurface>
</Popover>
</>
);
}
- Focus restoration is automatic. The browser handles it for outermost popovers;
Popoversnapshotsdocument.activeElementon open and restores it on close for nested cases. - For default overlay styling (background, shadow, border-radius), wrap content in
PopoverSurfacefrom@atlaskit/top-layer/popover-surface. - For trigger-less or custom-positioned UI (flag, tooltip, react-select menu portal), use
Popoveron its own withoutuseAnchoredPopover, or passisEnabled: falseto turn positioning off. useAnchoredPopoveralso owns anchor-relative sizing viainlineSize/blockSize('content' | 'match-anchor' | 'min-anchor' | 'max-available', both defaulting to'content'). A viewport cap is written on both axes whatever you pass, and only a placement-axis minimum can beat it. A'content'inline axis is the content's natural width, so a popover too wide for the space beside its anchor slides to a roomier side rather than wrapping;shouldPreserveInlineSizeopts an element you own and have styled out of that write. See the hook's JSDoc andnotes/decisions/fit-available-space.md.
Dialog
A modal dialog on the native <dialog> element. isOpen calls showModal() and close(). It has
no visual opinions (no width, surface or layout); consumers such as @atlaskit/modal-dialog add
their own. A label or labelledBy is required.
import { Dialog } from '@atlaskit/top-layer/dialog-content';
<Dialog isOpen={isOpen} onClose={() => setIsOpen(false)} labelledBy={titleId}>
<h2 id={titleId}>Heading</h2>
Content
</Dialog>;
onClose({ reason })fires after an allowed Escape ('escape') or backdrop click ('overlay-click'). SetisOpen={false}in response.dismissedBylimits user dismissal:'escape-and-outside-click'(default),'escape'or'none'. To allow only backdrop clicks, use the default and ignoreonClosecalls withreason: 'escape'.- The
<dialog>hasclosedby="none":Dialoghandles Escape and backdrop clicks itself. Other close requests (for example Android back) do not close the dialog and do not callonClose.
Page surfaces
Utilities for page surfaces: surfaces that are not in the top layer (for example a side panel, a
grid or the side nav) and must work alongside top-layer surfaces. They replace the
@atlaskit/layering open layer observer.
import { hasOpenDialogOrPopover } from '@atlaskit/top-layer/page-surface/has-open-dialog-or-popover';
import { requestClosePopovers } from '@atlaskit/top-layer/page-surface/request-close-popovers';
import { useCloseOnUnclaimedEscape } from '@atlaskit/top-layer/page-surface/use-close-on-unclaimed-escape';
// A side panel that closes on Escape, unless an open dialog or popover claimed it.
useCloseOnUnclaimedEscape({ isEnabled: isOpen, onClose: () => setIsOpen(false) });
// A grid that ignores keys while a dialog or popover opened from it is open.
if (hasOpenDialogOrPopover({ within: event.currentTarget })) {
return;
}
useCloseOnUnclaimedEscape({ isEnabled, onClose, shouldClose, targetRef })closes a page surface (one that is not in the top layer, for example a side panel) on Escape. The browser closes top-layer surfaces on Escape itself, but a page surface also receives that Escape. The hook ignores a claimed Escape: whenevent.defaultPreventedistrue, whenevent.repeatistrue, when another Escape owner is above the surface (an open layer that closes on Escape, or a marked page surface; see the Escape stack innotes/decisions/manual-light-dismiss-escape-stacking.md), or whenshouldClosereturnsfalse. PasssurfaceRefto name the surface element: a layer around it is below it. It listens ondocument, or ontargetRefwhen you pass one (then only while focus is inside it). ThetargetRefelement must be mounted whenisEnabledbecomestrue. Do not use the hook for aPopoveror aDialog: they already close on Escape.hasOpenDialogOrPopover({ within, type })returnstruewhen an open<dialog>, or an openPopoverthat has anonClose, is insidewithin(default:document). Tooltips and popovers withoutonClosenever count.type: 'modal'counts only modal dialogs. Call it at event time: akeydownlistener still sees a popover that the same Escape will close.requestClosePopovers({ within })callsonClose({ reason: 'programmatic' })on each open closable popover insidewithin. It never hides anything itself, and it does not ask dialogs. Use it when the layout changes, for example when a side nav collapses.- Top-layer surfaces render inline, so a surface opened from inside an element is a DOM descendant
of that element. Use
withinwhere you used observer namespaces.
Animations
Both Popover and Dialog support CSS-based entry/exit animations via the shouldAnimate prop.
Animations use @starting-style
for entry and
allow-discrete on
display/overlay for exit — no JavaScript animation coordination required.
Popover animation
Pass shouldAnimate to enable the default popover entry and exit animation:
import { Popover } from '@atlaskit/top-layer/popover/popover';
<Popover shouldAnimate role="dialog" label="My popover" isOpen={isOpen} onClose={onClose}>
Content
</Popover>;
For overlay styling (background, shadow, border-radius), wrap children in PopoverSurface from
@atlaskit/top-layer/popover-surface.
Use enteringAnimationXcss and exitingAnimationXcss when a composed component needs to override
the phase-specific animation styles.
Dialog animation
Pass shouldAnimate to enable the default dialog entry and exit animation:
import { Dialog } from '@atlaskit/top-layer/dialog-content';
<Dialog shouldAnimate isOpen={isOpen} onClose={onClose} label="My dialog">
Content
</Dialog>;
Use enteringAnimationXcss and exitingAnimationXcss when a composed component needs to override
the phase-specific animation styles.
Progressive enhancement
Browsers without @starting-style support will show/hide elements instantly — no broken state. The
prefers-reduced-motion: reduce media query sets all transition durations to 0s.
Accessibility
Browser tests in __tests__/playwright/ verify the following WCAG 2.2 success criteria. See
notes/goals/accessibility-criteria.md and
notes/decisions/accessibility-audit-report.md for
details.
| A11y criterion | Browser test |
|---|---|
| 1.3.1 Info and Relationships | ✓ |
| 1.3.2 Meaningful Sequence | ✓ |
| 2.1.1 Keyboard | ✓ |
| 2.1.2 No Keyboard Trap | ✓ |
| 2.4.3 Focus Order | ✓ |
| 2.4.7 Focus Visible | ✓ |
| 2.4.11 Focus Not Obscured | ✓ |
| 3.2.1 On Focus | ✓ |
| 4.1.2 Name, Role, Value | ✓ |
| 4.1.3 Status Messages | ✓ |
| Background inertness (modal dialogs) | ✓ |
| Close reasons (Dialog) | ✓ |
Known limitations
- Safari
<dialog>flex-collapse. On WebKit, amax-height: 100%flex column with aflex: 1 1 auto; overflow: autochild placed directly inside aDialogcollapses to0px(the block-axisfit-contentuser-agent default is an indefinite height).Popoversetsheight: autoto avoid this;Dialogdeliberately does not, to keep the primitive free of layout opinions (seenotes/decisions/safari-popover-flex-collapse.md). A consumer that builds this shape inside aDialogshould give the column a definite height or apply its ownheight: autoreset on the surface.- TODO: expand this into full public consumer documentation (the recommended workaround and a
worked example) before the top-layer
Dialogis broadly adopted.
- TODO: expand this into full public consumer documentation (the recommended workaround and a
worked example) before the top-layer
Documentation
See notes/ for project documentation, architecture decisions, migration records, and
audit reports. The notes/README.md provides a full index, including current
migration status and links to
notes/decisions/migration-roadmap.md.