---
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`).
