Component
Non-Modal Dialog
`modal={false}`: a floating keyboard-shortcuts reference panel — no overlay, no scroll lock, no focus trap; the page behind stays interactive and the panel closes on Escape, outside pointer down, or its close actions.
- Component
- React
- TSX
- Tailwind CSS
- MIT
What it does
Capabilities
- Role=dialog semantics
- Focus trap
- Focus restoration
- Scroll lock
- Escape to close
- Modal={false}
- No overlay or scroll lock
- Page stays interactive
- Aria-haspopup="dialog" trigger
- Role="dialog" panel
- Aria-modal when modal
- Aria-labelledby + aria-describedby wiring
Preview
Live component
9:41 100%
devsnips.site/library/react/components/dialogs/dialog-non-modal/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Dialogs/dialog-non-modal React/Components/Dialogs/dialog-non-modal import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
RefObject,
} from "react";
/**
* DevSnips React Dialog — Non-Modal.
*
* The shared dialog core with `modal={false}`: a floating panel without an
* overlay, scroll lock, or focus trap. The page behind stays interactive;
* the panel closes on Escape, an outside pointer down, the trigger toggle,
* or DialogClose — and focus still restores to the trigger.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ------------------------------------------------------------------------ */
/* Module-level dialog stack + scroll lock */
/* ------------------------------------------------------------------------ */
/**
* Every open dialog registers its key here; the last entry is the top-most
* dialog. Escape handling uses the stack so only the top-most dialog closes.
*/
const openDialogKeys: string[] = [];
let scrollLockCount = 0;
let previousOverflow = "";
let previousPaddingRight = "";
function lockScroll(): void {
scrollLockCount += 1;
if (scrollLockCount === 1) {
previousOverflow = document.body.style.overflow;
previousPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
document.body.style.overflow = "hidden";
}
}
function unlockScroll(): void {
scrollLockCount -= 1;
if (scrollLockCount === 0) {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPaddingRight;
}
}
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** Focusable elements inside `root` (excludes hidden / aria-hidden nodes). */
function focusableElements(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute("disabled") && !el.hidden && el.getAttribute("aria-hidden") !== "true",
);
}
const TRIGGER_CLASSES =
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)]";
const CONTENT_CLASSES =
"fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] shadow-[var(--ds-shadow-lg)] focus:outline-none";
const HEADER_CLASSES = "flex flex-col gap-1.5 px-5 pt-5";
const TITLE_CLASSES =
"text-lg font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--ds-color-foreground)]";
const DESCRIPTION_CLASSES = "text-sm leading-5 text-[var(--ds-color-muted-foreground)]";
const FOOTER_CLASSES =
"flex flex-col-reverse gap-2 px-5 pb-5 pt-4 sm:flex-row sm:justify-end [&>button]:w-full sm:[&>button]:w-auto";
// One class constant per close-action kind — the kinds never rely on
// conflicting-utility overrides (Tailwind resolves conflicts by stylesheet
// order, not class order, so overrides of bg-*/border-* are unreliable).
const CLOSE_VARIANT_CLASSES: Record<DialogCloseVariant, string> = {
outline: TRIGGER_CLASSES,
primary:
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-primary)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-primary-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-primary)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
destructive:
"inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-destructive)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-destructive-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-destructive)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-destructive)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
ghost:
"absolute right-3 top-3 inline-flex size-8 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
};
interface DialogContextValue {
open: boolean;
modal: boolean;
contentId: string;
titleId: string;
descriptionId: string;
hasTitle: boolean;
hasDescription: boolean;
registerTitle(): () => void;
registerDescription(): () => void;
rememberFocus(element: HTMLElement | null): void;
requestOpen(next: boolean): void;
requestClose(): void;
triggerRef: RefObject<HTMLButtonElement>;
}
const DialogContext = createContext<DialogContextValue | null>(null);
function useDialog(component: string): DialogContextValue {
const context = useContext(DialogContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Dialog>.`);
}
return context;
}
/* ------------------------------------------------------------------------ */
/* Dialog (root) */
/* ------------------------------------------------------------------------ */
export interface DialogProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called whenever the dialog requests to open or close. */
onOpenChange?: (open: boolean) => void;
/**
* Modal behavior (default true): overlay, scroll lock, focus trap, and
* `aria-modal`. Set false for a non-modal floating panel (page stays
* interactive; close on Escape or outside pointer down).
*/
modal?: boolean;
children?: ReactNode;
}
export function Dialog({
open,
defaultOpen = false,
onOpenChange,
modal = true,
children,
}: DialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const reactId = useId();
const dialogKey = `ds-dialog${reactId}`;
const contentId = `ds-dialog-content${reactId}`;
const titleId = `ds-dialog-title${reactId}`;
const descriptionId = `ds-dialog-description${reactId}`;
const [hasTitle, setHasTitle] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
const openRef = useRef(actualOpen);
openRef.current = actualOpen;
const requestOpen = useCallback(
(next: boolean) => {
// Remember the focused element at the moment the open is REQUESTED
// (the trigger, or a row action). Capturing here — not in the open
// effect — because mounted-child effects run before the root's, so by
// effect time focus has already moved into the dialog.
if (next && !openRef.current) {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
}
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isControlled, onOpenChange],
);
const requestClose = useCallback(() => requestOpen(false), [requestOpen]);
const requestCloseRef = useRef(requestClose);
requestCloseRef.current = requestClose;
const registerTitle = useCallback(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, []);
const registerDescription = useCallback(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, []);
// Fallback focus capture for opens that never pass through requestOpen
// (e.g. a parent flipping controlled `open` directly): DialogContent calls
// this from its open effect — the first code that moves focus — while the
// pre-open focused element is still active.
const rememberFocus = useCallback((element: HTMLElement | null) => {
if (restoreFocusRef.current === null) {
restoreFocusRef.current = element;
}
}, []);
// While open: register on the dialog stack and lock body scroll (modal
// only); on close restore focus to the trigger (or whatever was focused
// when the dialog opened — captured in requestOpen).
useEffect(() => {
if (!actualOpen) return;
openDialogKeys.push(dialogKey);
if (modal) lockScroll();
return () => {
const index = openDialogKeys.indexOf(dialogKey);
if (index !== -1) openDialogKeys.splice(index, 1);
if (modal) unlockScroll();
const element = restoreFocusRef.current;
restoreFocusRef.current = null;
if (element && element.isConnected) element.focus();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen, modal, dialogKey]);
// Escape closes only the top-most open dialog (nesting-safe).
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && openDialogKeys[openDialogKeys.length - 1] === dialogKey) {
event.preventDefault();
requestCloseRef.current();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen, dialogKey]);
const value: DialogContextValue = {
open: actualOpen,
modal,
contentId,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
rememberFocus,
requestOpen,
requestClose,
triggerRef,
};
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
}
export default Dialog;
/* ------------------------------------------------------------------------ */
/* DialogTrigger */
/* ------------------------------------------------------------------------ */
export interface DialogTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode;
}
export function DialogTrigger({ children, className, onClick, ...rest }: DialogTriggerProps) {
const context = useDialog("DialogTrigger");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.requestClose();
} else {
context.requestOpen(true);
}
}
return (
<button
type="button"
ref={context.triggerRef}
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
onClick={handleClick}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{children}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* DialogContent (portal to document.body) */
/* ------------------------------------------------------------------------ */
export interface DialogContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogContent({ children, className, onKeyDown, ...rest }: DialogContentProps) {
const context = useDialog("DialogContent");
const contentRef = useRef<HTMLDivElement>(null);
const open = context.open;
// Move focus into the dialog on open: the first focusable element, or the
// dialog container itself when it has no focusable children. This effect
// is the first code that moves focus (child effects run before the root's
// open effect), so it also captures the pre-open focused element for
// externally-controlled opens that never passed through requestOpen.
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
context.rememberFocus(document.activeElement as HTMLElement | null);
const items = focusableElements(node);
(items[0] ?? node).focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Non-modal mode: an outside pointer down closes the dialog (modal closes
// via its overlay instead).
useEffect(() => {
if (!open || context.modal) return;
function onPointerDown(event: PointerEvent) {
const node = contentRef.current;
const target = event.target as Node;
const trigger = context.triggerRef.current;
if (node && !node.contains(target) && !(trigger && trigger.contains(target))) {
context.requestClose();
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, context.modal]);
// Focus trap (modal only): wrap Tab / Shift+Tab at the first / last
// focusable element. Skipped while focus lives inside a NESTED dialog —
// the nested dialog traps its own keys.
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || event.key !== "Tab" || !context.modal) return;
const node = contentRef.current;
if (!node || !node.contains(document.activeElement)) return;
const items = focusableElements(node);
if (items.length === 0) {
event.preventDefault();
node.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<>
{context.modal ? (
<div
aria-hidden="true"
data-ds-dialog-overlay=""
className={OVERLAY_CLASSES}
// Canceling pointerdown suppresses the compatibility mousedown, so
// the browser's default "focus the clicked surface" behavior never
// steals focus from the trigger the close is about to restore.
onPointerDown={(event) => {
event.preventDefault();
context.requestClose();
}}
/>
) : null}
<div
ref={contentRef}
id={context.contentId}
role="dialog"
aria-modal={context.modal ? true : undefined}
aria-labelledby={context.hasTitle ? context.titleId : undefined}
aria-describedby={context.hasDescription ? context.descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cx(CONTENT_CLASSES, className)}
{...rest}
>
{children}
</div>
</>,
document.body,
);
}
/* ------------------------------------------------------------------------ */
/* DialogHeader / DialogTitle / DialogDescription */
/* ------------------------------------------------------------------------ */
export interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogHeader({ className, children, ...rest }: DialogHeaderProps) {
return (
<div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export interface DialogTitleProps extends HTMLAttributes<HTMLHeadingElement> {
children?: ReactNode;
}
export function DialogTitle({ className, children, ...rest }: DialogTitleProps) {
const context = useDialog("DialogTitle");
// Register so DialogContent can wire `aria-labelledby` — and omit it when
// no title is rendered (content then relies on an explicit `aria-label`).
useEffect(() => context.registerTitle(), [context]);
return (
<h2 id={context.titleId} className={cx(TITLE_CLASSES, className)} {...rest}>
{children}
</h2>
);
}
export interface DialogDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
children?: ReactNode;
}
export function DialogDescription({ className, children, ...rest }: DialogDescriptionProps) {
const context = useDialog("DialogDescription");
// Register so DialogContent can wire `aria-describedby`.
useEffect(() => context.registerDescription(), [context]);
return (
<p id={context.descriptionId} className={cx(DESCRIPTION_CLASSES, className)} {...rest}>
{children}
</p>
);
}
/* ------------------------------------------------------------------------ */
/* DialogFooter / DialogClose */
/* ------------------------------------------------------------------------ */
export interface DialogFooterProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DialogFooter({ className, children, ...rest }: DialogFooterProps) {
return (
<div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export type DialogCloseVariant = "outline" | "primary" | "destructive" | "ghost";
export interface DialogCloseProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* `outline` (default) is the bordered footer cancel action; `primary` /
* `destructive` are confirming actions that also close; `ghost` is the
* icon-sized corner close button (positioned top-right of the panel —
* give it an `aria-label`).
*/
variant?: DialogCloseVariant;
children?: ReactNode;
}
export function DialogClose({ children, className, onClick, variant = "outline", ...rest }: DialogCloseProps) {
const context = useDialog("DialogClose");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
context.requestClose();
}
return (
<button
type="button"
onClick={handleClick}
className={cx(CLOSE_VARIANT_CLASSES[variant], className)}
{...rest}
>
{children}
</button>
);
} /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState
} from "react";
import { createPortal } from "react-dom";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const openDialogKeys = [];
let scrollLockCount = 0;
let previousOverflow = "";
let previousPaddingRight = "";
function lockScroll() {
scrollLockCount += 1;
if (scrollLockCount === 1) {
previousOverflow = document.body.style.overflow;
previousPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
document.body.style.overflow = "hidden";
}
}
function unlockScroll() {
scrollLockCount -= 1;
if (scrollLockCount === 0) {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPaddingRight;
}
}
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function focusableElements(root) {
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute("disabled") && !el.hidden && el.getAttribute("aria-hidden") !== "true"
);
}
const TRIGGER_CLASSES = "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)]";
const CONTENT_CLASSES = "fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] shadow-[var(--ds-shadow-lg)] focus:outline-none";
const HEADER_CLASSES = "flex flex-col gap-1.5 px-5 pt-5";
const TITLE_CLASSES = "text-lg font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--ds-color-foreground)]";
const DESCRIPTION_CLASSES = "text-sm leading-5 text-[var(--ds-color-muted-foreground)]";
const FOOTER_CLASSES = "flex flex-col-reverse gap-2 px-5 pb-5 pt-4 sm:flex-row sm:justify-end [&>button]:w-full sm:[&>button]:w-auto";
const CLOSE_VARIANT_CLASSES = {
outline: TRIGGER_CLASSES,
primary: "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-primary)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-primary-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-primary)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
destructive: "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-destructive)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-destructive-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-destructive)_88%,#000)] active:bg-[color-mix(in_srgb,var(--ds-color-destructive)_80%,#000)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
ghost: "absolute right-3 top-3 inline-flex size-8 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"
};
const DialogContext = createContext(null);
function useDialog(component) {
const context = useContext(DialogContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Dialog>.`);
}
return context;
}
function Dialog({
open,
defaultOpen = false,
onOpenChange,
modal = true,
children
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef(null);
const restoreFocusRef = useRef(null);
const reactId = useId();
const dialogKey = `ds-dialog${reactId}`;
const contentId = `ds-dialog-content${reactId}`;
const titleId = `ds-dialog-title${reactId}`;
const descriptionId = `ds-dialog-description${reactId}`;
const [hasTitle, setHasTitle] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
const openRef = useRef(actualOpen);
openRef.current = actualOpen;
const requestOpen = useCallback(
(next) => {
if (next && !openRef.current) {
restoreFocusRef.current = document.activeElement;
}
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isControlled, onOpenChange]
);
const requestClose = useCallback(() => requestOpen(false), [requestOpen]);
const requestCloseRef = useRef(requestClose);
requestCloseRef.current = requestClose;
const registerTitle = useCallback(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, []);
const registerDescription = useCallback(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, []);
const rememberFocus = useCallback((element) => {
if (restoreFocusRef.current === null) {
restoreFocusRef.current = element;
}
}, []);
useEffect(() => {
if (!actualOpen) return;
openDialogKeys.push(dialogKey);
if (modal) lockScroll();
return () => {
const index = openDialogKeys.indexOf(dialogKey);
if (index !== -1) openDialogKeys.splice(index, 1);
if (modal) unlockScroll();
const element = restoreFocusRef.current;
restoreFocusRef.current = null;
if (element && element.isConnected) element.focus();
};
}, [actualOpen, modal, dialogKey]);
useEffect(() => {
if (!actualOpen) return;
function onKeyDown(event) {
if (event.key === "Escape" && openDialogKeys[openDialogKeys.length - 1] === dialogKey) {
event.preventDefault();
requestCloseRef.current();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [actualOpen, dialogKey]);
const value = {
open: actualOpen,
modal,
contentId,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
rememberFocus,
requestOpen,
requestClose,
triggerRef
};
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
}
function DialogTrigger({ children, className, onClick, ...rest }) {
const context = useDialog("DialogTrigger");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.requestClose();
} else {
context.requestOpen(true);
}
}
return <button
type="button"
ref={context.triggerRef}
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
onClick={handleClick}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{children}
</button>;
}
function DialogContent({ children, className, onKeyDown, ...rest }) {
const context = useDialog("DialogContent");
const contentRef = useRef(null);
const open = context.open;
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
context.rememberFocus(document.activeElement);
const items = focusableElements(node);
(items[0] ?? node).focus();
}, [open]);
useEffect(() => {
if (!open || context.modal) return;
function onPointerDown(event) {
const node = contentRef.current;
const target = event.target;
const trigger = context.triggerRef.current;
if (node && !node.contains(target) && !(trigger && trigger.contains(target))) {
context.requestClose();
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, context.modal]);
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented || event.key !== "Tab" || !context.modal) return;
const node = contentRef.current;
if (!node || !node.contains(document.activeElement)) return;
const items = focusableElements(node);
if (items.length === 0) {
event.preventDefault();
node.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<>
{context.modal ? <div
aria-hidden="true"
data-ds-dialog-overlay=""
className={OVERLAY_CLASSES}
onPointerDown={(event) => {
event.preventDefault();
context.requestClose();
}}
/> : null}
<div
ref={contentRef}
id={context.contentId}
role="dialog"
aria-modal={context.modal ? true : undefined}
aria-labelledby={context.hasTitle ? context.titleId : undefined}
aria-describedby={context.hasDescription ? context.descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cx(CONTENT_CLASSES, className)}
{...rest}
>
{children}
</div>
</>,
document.body
);
}
function DialogHeader({ className, children, ...rest }) {
return <div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DialogTitle({ className, children, ...rest }) {
const context = useDialog("DialogTitle");
useEffect(() => context.registerTitle(), [context]);
return <h2 id={context.titleId} className={cx(TITLE_CLASSES, className)} {...rest}>
{children}
</h2>;
}
function DialogDescription({ className, children, ...rest }) {
const context = useDialog("DialogDescription");
useEffect(() => context.registerDescription(), [context]);
return <p id={context.descriptionId} className={cx(DESCRIPTION_CLASSES, className)} {...rest}>
{children}
</p>;
}
function DialogFooter({ className, children, ...rest }) {
return <div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DialogClose({ children, className, onClick, variant = "outline", ...rest }) {
const context = useDialog("DialogClose");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
context.requestClose();
}
return <button
type="button"
onClick={handleClick}
className={cx(CLOSE_VARIANT_CLASSES[variant], className)}
{...rest}
>
{children}
</button>;
}
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose };
export default Dialog; # Non-Modal Dialog
`modal={false}`: a floating keyboard-shortcuts reference panel — no overlay, no scroll lock, no focus trap; the page behind stays interactive and the panel closes on Escape, outside pointer down, or its close actions.
## Usage
```tsx
import Dialog, {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "./dialog-non-modal";
<Dialog modal={false}>
<DialogTrigger>Keyboard shortcuts</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<div className="px-5 py-4">…shortcut table…</div>
<DialogFooter>
<DialogClose>Close</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Dialog, {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "./dialog-non-modal";
<Dialog modal={false}>
<DialogTrigger>Keyboard shortcuts</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<div className="px-5 py-4">…shortcut table…</div>
<DialogFooter>
<DialogClose>Close</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
```
## Props
### `<Dialog>`
| Name | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | — | Open state (controlled). |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the dialog requests to open or close. |
| `modal` | `boolean` | `true` | Modal behavior: overlay, scroll lock, focus trap, `aria-modal`. `false` renders a non-modal floating panel (no overlay; closes on Escape / outside pointer down). |
| `children` | `ReactNode` | — | `DialogTrigger` + `DialogContent`. |
### `<DialogTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | Visible trigger label. |
A real `<button type="button">` with `aria-haspopup="dialog"` + `aria-expanded`; every native button attribute (`disabled`, `aria-label`, …) is forwarded. Focus is restored here when the dialog closes.
### `<DialogContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `role="dialog"` panel (e.g. a larger `max-w-*`). |
| `children` | `ReactNode` | — | Header, body content, footer, close button. |
Portaled to `document.body` and rendered only while open. Labelled by `DialogTitle` / described by `DialogDescription` automatically when they are rendered; pass `aria-label` when a dialog intentionally has no visible title, and `role="alertdialog"` for confirmation dialogs (forwarded via `...rest`).
### `<DialogHeader>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the header. |
| `children` | `ReactNode` | — | `DialogTitle` + `DialogDescription`. |
### `<DialogTitle>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<h2>`. |
| `children` | `ReactNode` | — | Title text. |
Registers itself so `DialogContent` sets `aria-labelledby` only while a title exists.
### `<DialogDescription>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `<p>`. |
| `children` | `ReactNode` | — | Supporting description text. |
Registers itself so `DialogContent` sets `aria-describedby` only while a description exists.
### `<DialogFooter>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the action row. |
| `children` | `ReactNode` | — | Footer actions (`DialogClose`, plain buttons, links). |
Buttons stack full-width below `sm` and lay out right-aligned inline from `sm` up.
### `<DialogClose>`
| Name | Type | Default | Description |
|---|---|---|---|
| `variant` | `"outline" \| "primary" \| "destructive" \| "ghost"` | `"outline"` | `outline` is the bordered footer cancel action; `primary` / `destructive` are confirming actions that also close; `ghost` is the icon-sized corner close button (positioned absolute top-right — give it an `aria-label` such as `"Close"`). |
| `onClick` | `(event) => void` | — | Called before the dialog closes; `event.preventDefault()` keeps the dialog open. |
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | Visible label (or the close icon for `ghost`). |
A real `<button type="button">` that requests close; every native button attribute is forwarded.
## Composition
- `Dialog` — the root provider. Owns the open state (controlled `open` + `onOpenChange`, or uncontrolled `defaultOpen`), the `modal` behavior switch, the generated ids, and the focus-restore memory.
- `DialogTrigger` — a real `<button type="button">` with `aria-haspopup="dialog"` + `aria-expanded`. Click toggles the dialog.
- `DialogContent` — the portaled `role="dialog"` panel (`aria-modal` when modal) plus, in modal mode, the overlay that blocks the background. Rendered only while open; moves focus inside on open and traps Tab while modal.
- `DialogHeader` — the header layout slot (`DialogTitle` + `DialogDescription`).
- `DialogTitle` — an `<h2>` that registers itself so the panel is labelled by it.
- `DialogDescription` — a `<p>` that registers itself so the panel is described by it.
- `DialogFooter` — the action row; buttons go full-width stacked on small screens, right-aligned inline from `sm` up.
- `DialogClose` — a real `<button>` that closes the dialog (footer cancel actions, or the header close with `variant="ghost"`).
Non-modal is a prop, not a different component: the same primitives with `modal={false}` on the root. The panel keeps the shared dialog geometry but renders without the overlay and without `aria-modal`.
## Dialog Behavior
`modal={false}` switches three behaviors off: no overlay is rendered (the page stays clickable), body scroll is not locked, and Tab is not trapped — focus can leave the panel and reach the page behind it. `aria-modal` is omitted, since assistive technology must not be told the background is inert when it is not.
Closing still works from every direction: Escape (the panel registers on the same top-most stack), a pointer down outside the panel, the trigger toggle, and `DialogClose`. Focus is still moved into the panel on open and restored to the trigger on close.
Use non-modal for reference material the user consults while working elsewhere on the page — shortcuts, inspectors, help panels. Use the default modal dialog whenever the surrounding page must wait for a decision.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Enter` / `Space` (trigger) | Open the panel, focus the first focusable element inside |
| `Tab` / `Shift+Tab` | Move naturally — no trap; the page behind stays reachable |
| `Escape` | Close the panel and restore focus to the trigger |
The trigger and close actions are native `<button>` elements, so Enter/Space activation follows normal browser behavior.
## Accessibility
The structure follows the WAI-ARIA dialog (modal) pattern.
- The trigger is a native `<button>` with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` pointing at the panel.
- The panel is `role="dialog"` with `aria-modal="true"` in modal mode. `DialogTitle` / `DialogDescription` register themselves, and the panel wires `aria-labelledby` / `aria-describedby` only when they are present — a dialog without a visible title must pass `aria-label` to `DialogContent` (the attributes are omitted, never left pointing at nothing). Confirmation-style dialogs can pass `role="alertdialog"` through `DialogContent`.
- Focus is real DOM focus: opening moves focus into the dialog, Tab is trapped while modal, and closing restores focus to the trigger — focus is never left on an unmounted element (the restore target is checked against `isConnected`).
- The background is unreachable while modal: the overlay blocks pointer interaction, the focus trap blocks keyboard access, and `aria-modal` tells assistive technology the rest of the page is inert.
- Disabled actions carry the native `disabled` attribute, which assistive technology announces as unavailable.
Without `aria-modal`, screen-reader users can still reach the page behind the panel — matching the actual, non-blocking behavior. Because there is no focus trap, keyboard users can Tab out of the panel at any time.
## States
- **Trigger (idle / open)** — per the shared control system; `aria-expanded` reflects the panel.
- **Panel** — same surface/border/elevation geometry as the modal dialog, centered; no overlay behind it, so the page remains visible and interactive.
- **Page** — never scroll-locked; clicking outside the panel dismisses it.
## Responsive Behavior
The panel is `width: calc(100vw - 2rem)` up to `max-w-lg` (512px, inside the 400–640px dialog token range) and capped at `max-height: calc(100dvh - 2rem)`, so it stays inside the viewport at every width from 375px up — including small landscape screens — without page overflow. Long content scrolls inside a `min-h-0 flex-1 overflow-y-auto` body region while the header and footer stay pinned (see `dialog-scrollable`). Footer actions stack full-width below `sm` and lay out inline from `sm` up. The trigger keeps the shared 36px control height — a comfortable touch target — at every breakpoint.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface-elevated)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This dialog variant uses the semantic color, radius, shadow, typography, and motion tokens — including `color.overlay` for the backdrop and the Dialog row of the component token rules (radius-md, shadow-lg).
## Notes
The demo page is intentionally tall: while the non-modal panel is open the page keeps scrolling, and clicking the background dismisses the panel. 501 lines UTF-8 · LF · Spaces: 2
DevSnips AI ready Claude · Cursor · Windsurf
Agent skill & Model Context Protocol
Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.
devsnips://components/dialogs/dialog-non-modal I want to use this DevSnips resource in production:
Page:
https://devsnips.site/library/react/components/dialogs/dialog-non-modal/
Install: npx devsnips add
React/Components/Dialogs/dialog-non-modal
This is the Non-Modal Dialog — `modal={false}`: a floating keyboard-shortcuts reference panel — no overlay, no scroll lock, no focus trap; the page behind stays interactive and the panel closes on Escape, outside pointer down, or its close actions.
Use this DevSnips resource as the starting point for my project.
Adapt it to my project's stack and conventions, fix any issues,
and make it production-ready while preserving its intended design
and behavior. Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.
Resource URI
devsnips://components/dialogs/dialog-non-modal