Component
Dialog with Custom Footer
The footer as a free composition area: a real checklist link and draft metadata on the leading edge, cancel/publish actions on the trailing edge.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/dialogs/dialog-with-custom-footer/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Dialogs/dialog-with-custom-footer React/Components/Dialogs/dialog-with-custom-footer 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 — Custom Footer.
*
* The shared dialog core with the footer used as a free composition area:
* a real link and draft metadata on the leading edge, cancel/publish
* actions on the trailing edge, stacked on small screens.
*/
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; # Dialog with Custom Footer
The footer as a free composition area: a real checklist link and draft metadata on the leading edge, cancel/publish actions on the trailing edge.
## Usage
```tsx
import Dialog, {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "./dialog-with-custom-footer";
<DialogFooter className="sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<a href="#checklist">View release checklist</a>
<span>Draft saved 2 min ago</span>
</div>
<div className="flex gap-2">
<DialogClose>Cancel</DialogClose>
<DialogClose variant="primary" onClick={publish}>Publish release</DialogClose>
</div>
</DialogFooter>
```
## 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,
DialogDescription,
DialogFooter,
DialogClose,
} from "./dialog-with-custom-footer";
<DialogFooter className="sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<a href="#checklist">View release checklist</a>
<span>Draft saved 2 min ago</span>
</div>
<div className="flex gap-2">
<DialogClose>Cancel</DialogClose>
<DialogClose variant="primary" onClick={publish}>Publish release</DialogClose>
</div>
</DialogFooter>
```
## 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"`).
`DialogFooter` is a plain flex container — the default right-aligned action row is just its base classes. This variant overrides the `sm` justification (`sm:justify-between`) and composes two groups inside: a leading link/metadata cluster and the trailing action pair. Below `sm` the column layout stacks both groups naturally.
## Dialog Behavior
Everything behavioral is the reference dialog; only the footer composition differs. The checklist link is a real `<a href>` — it participates in the focus trap like any other focusable element and activates with normal browser navigation.
When overriding the footer, keep the action pair in a trailing `<div className="flex gap-2">` rather than restyling the buttons — the `DialogClose` variants already carry the correct action styling.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Enter` / `Space` (trigger) | Open the dialog, focus the first focusable element inside |
| `Tab` (modal dialog) | Move to the next focusable element; wraps from the last element back to the first |
| `Shift+Tab` (modal dialog) | Move to the previous focusable element; wraps from the first element to the last |
| `Escape` | Close the top-most open dialog and restore focus to its trigger |
| `Tab` (non-modal dialog) | Moves forward naturally — the page stays reachable |
The trigger and every action are native `<button>` elements, so Enter/Space activation follows normal browser behavior. Disabled actions use the native `disabled` attribute: they are skipped by Tab and cannot be activated.
## 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.
The checklist link is a real anchor with an href (keyboard-focusable, announced as a link); the draft timestamp is supplementary text, not required to understand the actions.
## States
- **Trigger (idle)** — bordered surface button per the shared control system (36px, radius-sm, `shadow-xs`).
- **Trigger (open)** — `aria-expanded="true"`; keeps the hover surface.
- **Overlay (modal)** — `var(--ds-color-overlay)` backdrop covering the viewport; pointer down on it closes the dialog.
- **Panel** — `--ds-color-surface-elevated` with a 1px `--ds-color-border` and the `--ds-shadow-lg` elevation, radius-md, centered, capped at `100dvh - 2rem` with internal column layout, per the Dialog token rules.
- **Title / description** — heading-md (18px, 600) on foreground; body-sm on `--ds-color-muted-foreground`.
- **Footer actions** — full-width stacked below `sm`, right-aligned inline from `sm` up.
- **Disabled actions** — native `disabled`: 50% opacity, no pointer events, out of the tab order.
## 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
Footer composition is deliberately un-opinionated: any content can live there. This variant documents the one pattern every product eventually needs — navigation/metadata on the left, actions on the right. 500 lines UTF-8 · LF · Spaces: 2
Continue browsing