ns-ui
Dialog Emerge
A native <dialog> modal that grows out of the control that opened it and returns into it on close.
Use when a modal that should visibly grow out of the control that opened it and return into it on close, built on the native dialog element so the focus trap, background inertness and Escape come from the platform; pick drawer-counterweight instead when the panel is a side sheet the user drags, or popover-pendulum when the content is non-modal and anchored rather than blocking.
Install
npx shadcn add https://design.helpmarq.com /r/dialog-emerge.jsonSource
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>;
/** dialog heading */
title?: ReactNode;
/** supporting copy under the title */
description?: ReactNode;
/** Body and actions. Rendered inside the panel, below the title block. */
children?: ReactNode;
/** Clicking the dimmed backdrop closes. Default true. */
dismissOnBackdrop?: boolean;
/** extra classes merged onto the rendered root element */
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-ns-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.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. The dialog is opened with `showModal()`. |
| onOpenChange | (open: boolean) => void | — | Called for every close the platform can originate: Escape, backdrop, close controls. |
| triggerRef? | RefObject<HTMLElement | null> | — | The control the panel emerges from and returns into. Omit for a centered scale-in. |
| title? | ReactNode | — | dialog heading |
| description? | ReactNode | — | supporting copy under the title |
| children? | ReactNode | — | Body and actions. Rendered inside the panel, below the title block. |
| dismissOnBackdrop? | boolean | true | Clicking the dimmed backdrop closes. Default true. |
| className? | string | — | extra classes merged onto the rendered root element |