Installation
npx shadcn@latest add @pixeldosa/overlayThe example above also uses Button. To copy it as-is, install everything it needs:
npx shadcn@latest add @pixeldosa/overlay @pixeldosa/buttonRender 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
placementchanges, 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
AnimatePresenceplusforceMount— 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
| Prop | Type | Default | Description |
|---|---|---|---|
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.