PixelDosa
core

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.

Overview
View code

Installation

bash
npx shadcn@latest add @pixeldosa/overlay

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.

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.

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.

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.

Source

The exact file shadcn add writes into your project.

overlay.tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, type Variants } from "motion/react";

import { duration, easing, reducedMotion } from "@pixeldosa/tokens";
import { cn } from "@/lib/utils";

/**
 * The one enter/exit signature every overlay in the system uses. Dialog, Sheet, Popover
 * and Drawer are all "content appears above a scrim, anchored to a side or centered" —
 * variant strings differ only in the axis and distance they translate from.
 */
type OverlayPlacement = "center" | "top" | "bottom" | "left" | "right";

const distance = 12;

function placementVariants(placement: OverlayPlacement): Variants {
  const offset: Record<OverlayPlacement, { x?: number; y?: number }> = {
    center: {},
    top: { y: -distance },
    bottom: { y: distance },
    left: { x: -distance },
    right: { x: distance },
  };

  return {
    hidden: { opacity: 0, scale: placement === "center" ? 0.98 : 1, ...offset[placement] },
    visible: {
      opacity: 1,
      scale: 1,
      x: 0,
      y: 0,
      transition: { duration: duration.fast, ease: easing.decelerate },
    },
    exit: {
      opacity: 0,
      scale: placement === "center" ? 0.98 : 1,
      ...offset[placement],
      transition: { duration: duration.instant, ease: easing.accelerate },
    },
  };
}

const scrimVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { duration: duration.fast, ease: easing.standard } },
  exit: { opacity: 0, transition: { duration: duration.instant, ease: easing.standard } },
};

/**
 * Reads prefers-reduced-motion and returns the fallback transition from motion tokens
 * when it is set — a near-zero duration rather than skipping the animation outright, so
 * Motion's exit-completion callbacks (and therefore unmount timing in AnimatePresence)
 * still fire correctly.
 */
function usePrefersReducedMotion() {
  const [reduced, setReduced] = React.useState(false);

  React.useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(query.matches);
    const listener = (event: MediaQueryListEvent) => setReduced(event.matches);
    query.addEventListener("change", listener);
    return () => query.removeEventListener("change", listener);
  }, []);

  return reduced;
}

export interface OverlayScrimProps extends React.ComponentPropsWithoutRef<typeof motion.div> {}

/**
 * The backdrop layer. Render inside an `AnimatePresence` alongside `OverlayContent` —
 * neither component owns mount/unmount timing itself, so it composes under any
 * primitive (Radix Dialog.Overlay, Sheet, Popover) that controls when it's in the tree.
 */
const OverlayScrim = React.forwardRef<HTMLDivElement, OverlayScrimProps>(function OverlayScrim(
  { className, ...props },
  ref
) {
  const reduced = usePrefersReducedMotion();

  return (
    <motion.div
      ref={ref}
      variants={scrimVariants}
      initial="hidden"
      animate="visible"
      exit="exit"
      transition={reduced ? reducedMotion : undefined}
      className={cn("fixed inset-0 z-50 bg-background/70 backdrop-blur-sm", className)}
      {...props}
    />
  );
});

export interface OverlayContentProps extends React.ComponentPropsWithoutRef<typeof motion.div> {
  /** Which edge the content enters from. `center` is Dialog/Popover; the rest are Sheet/Drawer. */
  placement?: OverlayPlacement;
}

const OverlayContent = React.forwardRef<HTMLDivElement, OverlayContentProps>(function OverlayContent(
  { placement = "center", className, ...props },
  ref
) {
  const reduced = usePrefersReducedMotion();
  const variants = React.useMemo(() => placementVariants(placement), [placement]);

  return (
    <motion.div
      ref={ref}
      variants={variants}
      initial="hidden"
      animate="visible"
      exit="exit"
      transition={reduced ? reducedMotion : undefined}
      className={cn(
        "z-50 rounded-lg border bg-popover text-popover-foreground shadow-lg",
        className
      )}
      {...props}
    />
  );
});

export { AnimatePresence, OverlayScrim, OverlayContent, type OverlayPlacement };