PixelDosa UI

Overlay Motion Primitive

The shared enter/exit animation layer for Dialog, Sheet, Popover and Drawer — a scrim plus a placement-aware content panel, both driven by motion tokens.

Installation

bash
npx shadcn@latest add @pixeldosa/overlay

The example above also uses Button. To copy it as-is, install everything it needs:

bash
npx shadcn@latest add @pixeldosa/overlay @pixeldosa/button

Render OverlayScrim and OverlayContent inside AnimatePresence, controlled by the same boolean/state that drives your Radix primitive's open prop. Neither component owns mount/unmount timing — AnimatePresence does, via whatever is conditionally rendering them.

Usage

Compose it underneath a Radix primitive — Radix owns focus trapping, dismiss-on-outside- click and portalling; Overlay owns only the motion.

import * as Dialog from "@radix-ui/react-dialog";
import { AnimatePresence, OverlayContent, OverlayScrim } from "@/components/ui/overlay";

export function ConfirmDialog({ open, onOpenChange, children }: Props) {
  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <AnimatePresence>
        {open ? (
          <Dialog.Portal forceMount>
            <Dialog.Overlay asChild>
              <OverlayScrim />
            </Dialog.Overlay>
            <Dialog.Content asChild>
              <OverlayContent placement="center" className="fixed left-1/2 top-1/2 w-96 -translate-x-1/2 -translate-y-1/2 p-6">
                {children}
              </OverlayContent>
            </Dialog.Content>
          </Dialog.Portal>
        ) : null}
      </AnimatePresence>
    </Dialog.Root>
  );
}

For a Sheet or Drawer, swap placement to "right", "left", "top" or "bottom" and position OverlayContent against that edge instead of centering it — the translate distance and easing stay identical, only the axis changes.

Features

  • One motion layer for four patterns. Dialog, Sheet, Popover and Drawer share the same scrim and panel transition — only placement changes, so they can't drift apart into four slightly different fades.
  • A strict division of labour: Radix owns focus trapping, dismissal and portalling; Overlay owns motion and nothing else. It adds no accessibility semantics of its own precisely so it can't contradict the primitive it wraps.
  • Exit animations that actually run, via AnimatePresence plus forceMount — the element stays mounted long enough to animate out instead of vanishing.
  • Token-driven throughout — duration and easing come from the motion tokens, so retuning the system's rhythm retunes every overlay at once.

Examples

Five placements, one component. center adds a slight scale-in; the edge placements translate in from their edge with identical distance and easing:

<OverlayContent placement="center" className="fixed left-1/2 top-1/2 w-96 -translate-x-1/2 -translate-y-1/2 p-6" />

A right-hand sheet — position it against the edge and switch placement; nothing else changes:

<OverlayContent placement="right" className="fixed inset-y-0 right-0 w-80 p-6">
  {children}
</OverlayContent>

A bottom drawer, which is usually the right mobile counterpart to a centered dialog:

<OverlayContent placement="bottom" className="fixed inset-x-0 bottom-0 rounded-b-none p-6">
  {children}
</OverlayContent>

forceMount is not optional — without it Radix unmounts the content immediately and AnimatePresence never gets to run the exit:

<Dialog.Portal forceMount>
  <Dialog.Overlay asChild>
    <OverlayScrim />
  </Dialog.Overlay>
  {/* … */}
</Dialog.Portal>

Props

OverlayScrim

Forwards all motion.div props. No component-specific props.

OverlayContent

PropTypeDefaultDescription
placement"center" | "top" | "bottom" | "left" | "right""center"Which edge the panel translates in from. center also applies a slight scale-in.

All other props forward to motion.div.

Accessibility

This primitive is motion-only and carries no accessibility semantics of its own — those come entirely from whichever Radix primitive (Dialog, Popover, custom Sheet) you render it inside of. Always use forceMount on the Radix portal/content so AnimatePresence can run the exit animation before the element actually unmounts.

why it's built this way

Engineering Notes

Extracted as its own primitive rather than duplicated inside Dialog, Sheet, Popover and Drawer because those four are the same motion problem (scrim fade + panel translate/scale) wearing four different Radix primitives underneath. Without this, four components would each hand-roll slightly different durations the first time someone patches one of them, and the system's visual coherence — the actual point of a token-driven motion scale — degrades one component at a time. Deliberately excludes dismiss-on-click-outside, focus trapping and portalling: those are interaction semantics Radix already solves correctly, and re-solving them here would create two competing sources of truth for overlay behaviour.

how it moves, and when it doesn't

Motion Notes

Two states only: scrim opacity, and content opacity+scale+translate along one axis determined by `placement`. Enter uses duration.fast with easing.decelerate (content is arriving, should feel like it's settling in); exit uses duration.instant with easing.accelerate (dismissal should feel immediate, not lingering) — this asymmetry is deliberate, not an oversight. The motion solves orientation: a Sheet sliding in from the right tells the user where it came from and, on exit, where it's returning to, which a plain fade does not. Reduced motion swaps the whole transition object for the shared `reducedMotion` token (near-zero duration) rather than removing the animation, so Motion's exit callback still fires and AnimatePresence still unmounts the content correctly.