Dialog Emerge

Dialog

A native <dialog> modal that grows out of the control that opened it and returns into it on close.

Install
npx shadcn add https://design.helpmarq.com/r/dialog-emerge.json
Source
registry/core/dialog-emerge/component.tsx
"use client";

import { useCallback, useEffect, useId, useRef, type ReactNode, type RefObject } from "react";

// A modal on the native <dialog> element: focus trap, background inertness to
// pointer and focus, Escape-to-close, top-layer stacking and ::backdrop all
// come from the platform, so none of that is reimplemented here. showModal()
// does NOT stop the page behind it from scrolling, though, so that one piece
// of inertness is hand-rolled: body scroll is locked for the duration the
// dialog is open and the scrollbar's width is compensated with padding so the
// page doesn't jump when the scrollbar disappears. The twist is the entrance —
// the panel emerges from the control that opened it. The trigger's bounding
// rect is measured at open time (not at mount; it moves on scroll and resize),
// inverted into one transform, and played out to identity (FLIP: no layout
// thrash, one composited property). Closing runs the same transform backwards
// so the panel returns into the trigger and the user never loses the thread of
// what they clicked. No trigger — opened programmatically — falls back to a
// centered scale-in. The close is timer-driven rather than transitionend-driven
// so a backgrounded tab can't strand an un-closed dialog. Reduced motion skips
// the morph entirely and opens instantly, still fully functional.

const OPEN_MS = 340;
const CLOSE_MS = 220;
const OPEN_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const CLOSE_EASE = "cubic-bezier(0.4, 0, 0.9, 0.4)";
const PANEL_RADIUS = 12; // --radius-md, kept visually constant through the scale
const FALLBACK_SCALE = 0.92; // centered scale-in when there is no trigger

export interface EmergeDialogProps {
  /** Controlled open state. The dialog is opened with `showModal()`. */
  open: boolean;
  /** Called for every close the platform can originate: Escape, backdrop, close controls. */
  onOpenChange: (open: boolean) => void;
  /** The control the panel emerges from and returns into. Omit for a centered scale-in. */
  triggerRef?: RefObject<HTMLElement | null>;
  title?: ReactNode;
  description?: ReactNode;
  /** Body and actions. Rendered inside the panel, below the title block. */
  children?: ReactNode;
  /** Clicking the dimmed backdrop closes. Default true. */
  dismissOnBackdrop?: boolean;
  className?: string;
}

export function EmergeDialog({
  open,
  onOpenChange,
  triggerRef,
  title,
  description,
  children,
  dismissOnBackdrop = true,
  className = "",
}: EmergeDialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef(0);
  const timerRef = useRef(0);
  const titleId = useId();
  const descId = useId();
  // Captured inline body styles from just before the lock was applied, so
  // close/unmount restores whatever a host page had set rather than clobbering
  // it. Null means "not currently locked".
  const scrollLockRef = useRef<{ overflow: string; paddingRight: string } | null>(null);

  // read in listeners that are bound once, so their deps stay empty
  const openRef = useRef(open);
  openRef.current = open;
  const onOpenChangeRef = useRef(onOpenChange);
  onOpenChangeRef.current = onOpenChange;
  const dismissRef = useRef(dismissOnBackdrop);
  dismissRef.current = dismissOnBackdrop;

  const reset = useCallback(() => {
    const dlg = dialogRef.current;
    const panel = panelRef.current;
    if (dlg) {
      dlg.removeAttribute("data-closing");
      dlg.style.transition = "";
      dlg.style.transform = "";
      dlg.style.borderRadius = "";
    }
    if (panel) {
      panel.style.transition = "";
      panel.style.opacity = "";
      panel.style.transform = "";
    }
  }, []);

  // The inverted transform that maps the dialog's final rect onto the trigger's.
  const invert = useCallback(() => {
    const dlg = dialogRef.current;
    if (!dlg) return null;
    const d = dlg.getBoundingClientRect();
    if (!d.width || !d.height) return null;
    const t = triggerRef?.current?.getBoundingClientRect();
    if (!t || !t.width || !t.height) {
      return { sx: FALLBACK_SCALE, sy: FALLBACK_SCALE, dx: 0, dy: 0 };
    }
    return {
      sx: Math.max(0.05, t.width / d.width),
      sy: Math.max(0.05, t.height / d.height),
      dx: t.left + t.width / 2 - (d.left + d.width / 2),
      dy: t.top + t.height / 2 - (d.top + d.height / 2),
    };
  }, [triggerRef]);

  // Counter the scale so the corner radius reads the same size all the way
  // through the morph instead of ballooning as the panel shrinks.
  const morph = (dlg: HTMLDialogElement, m: { sx: number; sy: number; dx: number; dy: number }) => {
    dlg.style.transform = `translate(${m.dx.toFixed(1)}px, ${m.dy.toFixed(1)}px) scale(${m.sx.toFixed(4)}, ${m.sy.toFixed(4)})`;
    dlg.style.borderRadius = `${(PANEL_RADIUS / ((m.sx + m.sy) / 2)).toFixed(1)}px`;
  };

  const reduced = () =>
    typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  // showModal() and CSS inertness stop pointer and focus from reaching the
  // background, but the page itself keeps scrolling under it. Lock it here;
  // the scrollbar's width is added back as body padding so the page doesn't
  // reflow/jump when the scrollbar disappears. Idempotent: a re-open landing
  // mid-close finds it already locked and no-ops.
  const lockScroll = useCallback(() => {
    if (scrollLockRef.current) return;
    const body = document.body;
    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
    scrollLockRef.current = { overflow: body.style.overflow, paddingRight: body.style.paddingRight };
    body.style.overflow = "hidden";
    if (scrollbarWidth > 0) {
      const currentPadding = parseFloat(window.getComputedStyle(body).paddingRight) || 0;
      body.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
    }
  }, []);

  const unlockScroll = useCallback(() => {
    const saved = scrollLockRef.current;
    if (!saved) return;
    const body = document.body;
    body.style.overflow = saved.overflow;
    body.style.paddingRight = saved.paddingRight;
    scrollLockRef.current = null;
  }, []);

  const openNow = useCallback(() => {
    const dlg = dialogRef.current;
    const panel = panelRef.current;
    if (!dlg) return;
    if (dlg.open && !dlg.hasAttribute("data-closing")) return;
    // a re-open landing mid-close must not inherit the close's transform, and
    // must kill the pending timer that would otherwise close it a frame later
    clearTimeout(timerRef.current);
    cancelAnimationFrame(rafRef.current);
    reset();
    lockScroll();
    if (!dlg.open) dlg.showModal();
    if (reduced()) return;

    const m = invert();
    if (!m) return;
    dlg.style.transition = "none";
    morph(dlg, m);
    if (panel) {
      panel.style.transition = "none";
      panel.style.opacity = "0";
      panel.style.transform = "translateY(6px)";
    }
    // rect measurement above already flushed layout, so one frame is enough
    rafRef.current = requestAnimationFrame(() => {
      dlg.style.transition = `transform ${OPEN_MS}ms ${OPEN_EASE}, border-radius ${OPEN_MS}ms ${OPEN_EASE}`;
      dlg.style.transform = "translate(0px, 0px) scale(1, 1)";
      dlg.style.borderRadius = "";
      if (panel) {
        // content arrives after the box has most of its size — the squash of
        // real text through a 0.2 scale is what makes a morph look cheap
        panel.style.transition = `opacity 200ms ease-out 110ms, transform 240ms ${OPEN_EASE} 90ms`;
        panel.style.opacity = "1";
        panel.style.transform = "translateY(0px)";
      }
    });
  }, [invert, reset, lockScroll]);

  const closeNow = useCallback(() => {
    const dlg = dialogRef.current;
    const panel = panelRef.current;
    if (!dlg || !dlg.open || dlg.hasAttribute("data-closing")) return;
    cancelAnimationFrame(rafRef.current);
    if (reduced()) {
      reset();
      dlg.close();
      return;
    }
    const m = invert();
    if (!m) {
      dlg.close();
      return;
    }
    dlg.setAttribute("data-closing", "");
    dlg.style.transition = `transform ${CLOSE_MS}ms ${CLOSE_EASE}, border-radius ${CLOSE_MS}ms ${CLOSE_EASE}`;
    morph(dlg, m);
    if (panel) {
      panel.style.transition = "opacity 130ms ease-in";
      panel.style.opacity = "0";
    }
    timerRef.current = window.setTimeout(() => {
      reset();
      dlg.close();
    }, CLOSE_MS + 40);
  }, [invert, reset]);

  useEffect(() => {
    if (open) openNow();
    else closeNow();
  }, [open, openNow, closeNow]);

  useEffect(() => {
    const dlg = dialogRef.current;
    if (!dlg) return;
    // Escape fires `cancel`; take it over so the return-to-trigger still plays.
    const onCancel = (e: Event) => {
      e.preventDefault();
      onOpenChangeRef.current(false);
    };
    // anything that closed the dialog without going through the prop. The
    // native `close` event fires for every path the dialog can close through
    // (our own dlg.close() calls included), so it's the single place the
    // scroll lock is released rather than duplicating that call at every
    // dlg.close() site.
    const onClose = () => {
      unlockScroll();
      if (openRef.current) onOpenChangeRef.current(false);
    };
    // a click on the backdrop targets the dialog itself; the panel covers the rest
    const onClick = (e: MouseEvent) => {
      if (dismissRef.current && e.target === dlg) onOpenChangeRef.current(false);
    };
    dlg.addEventListener("cancel", onCancel);
    dlg.addEventListener("close", onClose);
    dlg.addEventListener("click", onClick);
    return () => {
      dlg.removeEventListener("cancel", onCancel);
      dlg.removeEventListener("close", onClose);
      dlg.removeEventListener("click", onClick);
    };
  }, [unlockScroll]);

  useEffect(
    () => () => {
      clearTimeout(timerRef.current);
      cancelAnimationFrame(rafRef.current);
      // Unmounting mid-animation (before the close timer fires dlg.close())
      // must not strand the page scroll-locked forever.
      unlockScroll();
    },
    [unlockScroll]
  );

  return (
    <dialog
      ref={dialogRef}
      aria-labelledby={title ? titleId : undefined}
      aria-describedby={description ? descId : undefined}
      className={[
        "ns-emerge m-auto w-[min(30rem,calc(100vw-2rem))] overflow-hidden p-0",
        "rounded-md border border-border bg-surface text-foreground shadow-2xl",
        "will-change-transform",
        className,
      ].join(" ")}
    >
      {/* showModal() focuses the first focusable descendant unless something
          claims it. Left alone that is whatever control happens to come first —
          on a destructive dialog it pre-selects an option the user never chose,
          and paints a focus ring on it at rest. Take it here: focus lands
          inside the dialog (screen readers announce the title, Tab starts at
          the top) without preselecting anything. */}
      <div
        ref={panelRef}
        autoFocus
        tabIndex={-1}
        className="flex flex-col gap-5 p-6 outline-none"
      >
        {(title || description) && (
          <div className="flex flex-col gap-2">
            {title && (
              <h2 id={titleId} className="text-base font-semibold tracking-tight">
                {title}
              </h2>
            )}
            {description && (
              <p id={descId} className="text-sm leading-relaxed text-muted">
                {description}
              </p>
            )}
          </div>
        )}
        {children}
      </div>

      {/* ::backdrop inherits custom properties from its originating element, so
          the scrim stays token-derived: ink-over-paper in light, paper's own
          near-black in dark. Dim only — a blur costs a compositor layer and
          reads as generic. */}
      <style>{`
        .ns-emerge { --ns-scrim: color-mix(in srgb, var(--foreground) 22%, transparent); }
        :where(.dark) .ns-emerge { --ns-scrim: color-mix(in srgb, var(--background) 66%, transparent); }
        .ns-emerge::backdrop { background: var(--ns-scrim); animation: ns-emerge-scrim-in ${OPEN_MS}ms ease-out forwards; }
        .ns-emerge[data-closing]::backdrop { animation: ns-emerge-scrim-out ${CLOSE_MS}ms ease-in forwards; }
        @keyframes ns-emerge-scrim-in { from { opacity: 0 } to { opacity: 1 } }
        @keyframes ns-emerge-scrim-out { from { opacity: 1 } to { opacity: 0 } }
        @media (prefers-reduced-motion: reduce) {
          .ns-emerge::backdrop, .ns-emerge[data-closing]::backdrop { animation: none; opacity: 1 }
        }
      `}</style>
    </dialog>
  );
}
Build spec

A controlled modal built on the native <dialog> element opened with showModal(), so the focus trap, background inertness to pointer and focus, Escape-to-close, top-layer stacking and ::backdrop come from the platform rather than a hand-rolled implementation. One piece of background inertness the platform does not provide is scroll: showModal() leaves the page behind the dialog free to scroll, so that is hand-rolled — body scroll is locked for as long as the dialog is open (captured/restored against whatever inline style was already on the body, not clobbered) and released on close and on unmount even mid-close-animation, with the vanishing scrollbar's width compensated as body padding so the page doesn't jump width when the lock engages. The entrance is a FLIP morph: the trigger element's bounding rect is measured at open time (never at mount — it moves on scroll and resize), inverted into a single translate+scale transform on the dialog, and played out to identity, with the corner radius counter-scaled so it reads constant and the panel content fading in slightly late so real text never squashes through a small scale. Closing runs the same transform backwards so the panel returns into the trigger; the close is timer-driven rather than transitionend-driven, so a backgrounded tab can never strand an un-closed dialog, and a re-open landing mid-close cancels the pending timer and resets styles first. With no triggerRef (opened programmatically) it falls back to a centered scale-in. Escape's `cancel` event is intercepted so the return-to-origin still plays, backdrop clicks dismiss (opt-out via dismissOnBackdrop), and any close the component didn't originate is reported back through onOpenChange. The backdrop is a token-derived dim with no blur — ink-over-paper in light, the background token's own near-black in dark — set via a custom property that ::backdrop inherits from the dialog. prefers-reduced-motion skips the morph and the scrim fade entirely and opens instantly, fully functional.

Tags
dialogmodaloverlayflipaccessibility