@loke/ui
Version:
305 lines (247 loc) • 10.9 kB
Markdown
---
name: overlay-infrastructure
type: core
domain: composition
requires: [loke-ui]
description: >
Six shared infrastructure modules under all overlay components. DismissableLayer
(stacking, outside dismiss, pointer event blocking, Branch for anchor exemption).
FocusScope (trapping, looping, auto-focus on mount/unmount). FocusGuards (sentinel
spans for portaled content). Portal (SSR-safe createPortal via useLayoutEffect).
Popper (Floating UI positioning, CSS custom properties, collision/flip/arrow).
Presence (animation state machine: mounted/unmountSuspended/unmounted).
Use this skill when building a custom overlay; prefer component skills (dialog,
popover) when using existing primitives.
references:
- references/infrastructure-modules.md
---
# Overlay Infrastructure
This skill covers the six modules shared by all overlay components. You rarely use these directly — Dialog, Popover, Tooltip, and DropdownMenu already compose them. Use this skill when you are **building a new overlay primitive** from scratch.
## Setup
A minimal custom overlay combining Portal + FocusScope + DismissableLayer:
```tsx
import { DismissableLayer } from "@loke/ui/dismissable-layer";
import { FocusScope } from "@loke/ui/focus-scope";
import { Portal } from "@loke/ui/portal";
import { Presence } from "@loke/ui/presence";
import { useControllableState } from "@loke/ui/use-controllable-state";
interface FloatingPanelProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
modal?: boolean;
}
function FloatingPanel({
open: openProp,
defaultOpen = false,
onOpenChange,
children,
modal = false,
}: FloatingPanelProps) {
const [open, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
caller: "FloatingPanel",
});
return (
<Presence present={open}>
<Portal>
<DismissableLayer
disableOutsidePointerEvents={modal}
onDismiss={() => setOpen(false)}
onEscapeKeyDown={() => setOpen(false)}
>
<FocusScope trapped={modal} loop>
{children}
</FocusScope>
</DismissableLayer>
</Portal>
</Presence>
);
}
```
## Core Patterns
### Presence animation state machine
`Presence` controls mount/unmount while keeping the element in the DOM long enough for CSS exit animations to complete. It reads `animation-name` from computed styles to detect whether an animation is running.
```tsx
import { Presence } from "@loke/ui/presence";
// forceMount variant — element always in DOM; you control visibility via CSS
<Presence present={open}>
<div
data-state={open ? "open" : "closed"}
style={{
// Entry: triggered when present becomes true
// Exit: Presence delays unmount until animationend fires
animation: "fadeIn 150ms ease",
}}
>
content
</div>
</Presence>
// Function-child variant — present prop passed to child for manual control
<Presence present={open}>
{({ present }) => (
<div
data-state={present ? "open" : "closed"}
style={{ display: present ? "block" : "none" }}
/>
)}
</Presence>
```
State machine transitions:
- `unmounted` + MOUNT → `mounted`
- `mounted` + ANIMATION_OUT → `unmountSuspended` (exit animation running)
- `unmountSuspended` + ANIMATION_END → `unmounted`
- `unmountSuspended` + MOUNT → `mounted` (interrupted — re-opened during exit)
- `mounted` + UNMOUNT → `unmounted` (no animation, instant)
### Popper positioning with CSS custom properties
`PopperContent` sets four CSS custom properties on its wrapper that you can use in styles:
| Property | Value |
|---|---|
| `--loke-popper-available-width` | Available width before collision boundary |
| `--loke-popper-available-height` | Available height before collision boundary |
| `--loke-popper-anchor-width` | Width of the anchor element |
| `--loke-popper-anchor-height` | Height of the anchor element |
| `--loke-popper-transform-origin` | Transform origin for scale animations |
```tsx
import { Popper, PopperAnchor, PopperContent, PopperArrow } from "@loke/ui/popper";
function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
return (
<Popper>
<PopperAnchor asChild>{children}</PopperAnchor>
<PopperContent
side="top"
sideOffset={4}
align="center"
avoidCollisions
style={{
// Constrain to available space
maxWidth: "var(--loke-popper-available-width)",
// Animate from anchor origin
transformOrigin: "var(--loke-popper-transform-origin)",
}}
>
{content}
<PopperArrow />
</PopperContent>
</Popper>
);
}
```
For virtual anchors (e.g., cursor position):
```tsx
const virtualRef = React.useRef<{ getBoundingClientRect: () => DOMRect }>({
getBoundingClientRect: () => DOMRect.fromRect({ x: cursorX, y: cursorY, width: 0, height: 0 }),
});
<PopperAnchor virtualRef={virtualRef} />
```
### Focus guards for portaled content
Portaled content sits outside the normal DOM tree. Without sentinels at the document body edges, Tab from the last focusable element in a portal exits the browser chrome instead of wrapping. `FocusGuards` injects two `[data-loke-focus-guard]` spans — one at `afterbegin` and one at `beforeend` of `document.body` — which act as tab stops that DismissableLayer's `focusin` listener can intercept.
```tsx
import { FocusGuards } from "@loke/ui/focus-guards";
import { Portal } from "@loke/ui/portal";
// Wrap the portal root with FocusGuards — one instance covers all portals
function AppRoot({ children }: { children: React.ReactNode }) {
return (
<FocusGuards>
{children}
</FocusGuards>
);
}
// Or use useFocusGuards() directly in a component that manages its own lifecycle
import { useFocusGuards } from "@loke/ui/focus-guards";
function DialogContent() {
useFocusGuards(); // injects guards on mount, removes when last consumer unmounts
return <div>...</div>;
}
```
Guards are reference-counted: they persist as long as at least one consumer is mounted. The second guard insertion is idempotent (it reuses the existing element via `edgeGuards[1] ?? createFocusGuard()`).
## Common Mistakes
### 1. DismissableLayer stacking with disableOutsidePointerEvents
Only the highest layer with `disableOutsidePointerEvents={true}` blocks pointer events. Lower layers have `pointer-events: none` set on them automatically. If you add a second `DismissableLayer` without understanding the stack order, clicks on the lower layer's content may be eaten by the upper layer.
```tsx
// WRONG — inner layer steals clicks from outer layer unexpectedly
<DismissableLayer disableOutsidePointerEvents>
<div>Outer</div>
<DismissableLayer disableOutsidePointerEvents>
<div>Inner</div>
</DismissableLayer>
</DismissableLayer>
// CORRECT — only the topmost modal layer needs disableOutsidePointerEvents
<DismissableLayer> {/* non-modal outer */}
<div>Outer</div>
<DismissableLayer disableOutsidePointerEvents> {/* modal inner */}
<div>Inner</div>
</DismissableLayer>
</DismissableLayer>
```
Source: `dismissable-layer.tsx` — `isPointerEventsEnabled` is only true for layers at or above the highest `disableOutsidePointerEvents` layer index.
### 2. Missing DismissableLayerBranch for anchor elements
By default, a click on any element outside the `DismissableLayer` triggers `onPointerDownOutside`. If your overlay has an anchor (e.g., the trigger button) that should NOT dismiss the overlay when clicked, wrap the anchor in `DismissableLayerBranch`. Elements inside a Branch are excluded from the "outside" check.
```tsx
import { DismissableLayer, DismissableLayerBranch } from "@loke/ui/dismissable-layer";
<>
<DismissableLayerBranch>
{/* Clicking this trigger will NOT fire onPointerDownOutside */}
<button onClick={() => setOpen(true)}>Open</button>
</DismissableLayerBranch>
{open && (
<DismissableLayer onDismiss={() => setOpen(false)}>
<div>Overlay content</div>
</DismissableLayer>
)}
</>
```
Source: `dismissable-layer.tsx` — `isPointerDownOnBranch` check in `usePointerDownOutside`.
### 3. Presence animation model — exit animation not running
Presence detects exit animations by comparing `animation-name` before and after `present` changes to `false`. If the element's `animation-name` is `none` when `present` becomes false, Presence unmounts immediately without waiting. This means:
- CSS transitions (`transition:`) do **not** trigger the suspended state — only `animation:` keyframes do
- The animation must be applied to the **direct child** of `<Presence>`, not a nested element
```tsx
// WRONG — transition does not trigger unmountSuspended
<Presence present={open}>
<div style={{ transition: "opacity 150ms", opacity: open ? 1 : 0 }}>
content
</div>
</Presence>
// CORRECT — use keyframe animation on the direct child
<Presence present={open}>
<div style={{ animation: open ? "fadeIn 150ms" : "fadeOut 150ms" }}>
content
</div>
</Presence>
```
Source: `presence.tsx` — `getAnimationName` reads `styles?.animationName`, not transition properties.
### 4. Missing FocusGuards when using FocusScope inside a Portal
`FocusScope` with `trapped={true}` intercepts focus via document-level `focusin` listeners. Without `FocusGuards`, Tab from the last item exits to the browser chrome, and the `focusin` listener never fires to bring it back. This produces a broken focus trap where Tab appears to "escape."
```tsx
// WRONG — Tab can escape portal to browser chrome
<Portal>
<FocusScope trapped loop>
<dialog>
<input />
<button>Submit</button>
</dialog>
</FocusScope>
</Portal>
// CORRECT — FocusGuards creates sentinel tab stops at body edges
<FocusGuards>
<Portal>
<FocusScope trapped loop>
<dialog>
<input />
<button>Submit</button>
</dialog>
</FocusScope>
</Portal>
</FocusGuards>
```
Source: `focus-guards.tsx` — sentinels at `afterbegin`/`beforeend` of body ensure focus cycles back into the document before DismissableLayer's listener re-traps it.
## Tension: Infrastructure vs Per-Component Skills
This skill teaches infrastructure internals. If you are using Dialog, Popover, or Tooltip, you do not need to understand these modules — those components already compose them correctly. Come here only when:
- Building a new overlay primitive not covered by existing components
- Debugging unexpected dismiss/focus behavior in a custom overlay
- Composing two overlay-type components that need to share scope
For usage of existing overlay components, read the component-specific skills (`dialog`, `popover`, `tooltip`, `dropdown-menu`).