Composite
Modal
Modal is a prop-driven overlay dialog (`show` / `onClose`) with integrated Panel header, size variants, enter/exit animations, body scroll lock, and configurable dismissal.
Installation
npx shadcn add https://rck.vibeboxph.com//r/modal.jsonLive preview & controls
Click to open the modal preview
Controls
<>
<Button variant="outline" onClick={() => setOpen(true)}>
Open dialog
</Button>
<Modal
show={open}
onClose={() => setOpen(false)}
title="Playground modal"
description="Resize and toggle the close button using the controls, then reopen to see the change."
footer={
<>
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={() => setOpen(false)}>
Save changes
</Button>
</>
}
>
<p className="text-sm text-muted-foreground">
Adjust the controls, then reopen to preview size, close button, and scroll behavior.
</p>
</Modal>
</>Anatomy
ModalThe full dialog — overlay, panel, header, and body in one component.Panel (internal)Header slots (title, icons, details), body, and optional footer.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| show | boolean | — | Controlled visibility. |
| onClose | () => void | — | Called when the user dismisses the dialog. |
| children | ReactNode | — | Dialog body content. |
| title / description | ReactNode | — | Panel header text. |
| modalWidth | "sm" | "md" | "lg" | "xl" | "auto" | "full" | md | Max width (and height for `full`). |
| closeable | boolean | true | Whether Escape and overlay click dismiss the modal. |
| showCloseIcon | boolean | true | Shows the header close icon when `onClose` is provided. |
| scrollable | boolean | false | Scrolls long body content inside the panel. |
| zIndex | number | 50 | Stacking order for the overlay and panel. |
| hideHeader | boolean | false | Hides the panel header entirely. |
| bodyNoPadding | boolean | false | Removes default padding from the body region. |
| bodyClassName | string | — | Merged onto the scrollable body container. |
| overlayClassName | string | — | Merged onto the backdrop overlay. |
| className | string | — | Merged onto the panel container. |
| unmountOnHide | boolean | true | Remove from the DOM after the exit animation. |
| initialFocusRef | RefObject<HTMLElement> | — | Element to focus when the dialog opens. |
| onOpening / onOpened / onClosing / onClosed | () => void | — | Animation lifecycle callbacks. |
| footer | ReactNode | — | Footer action row via Panel. |
| leftIcon / rightIcons / leftDetails / rightDetails | Panel slots | — | Rich header configuration (see Panel). |
| leftContent / rightContent | ReactNode | — | Custom header regions flanking the title block. |
Accessibility
- Uses `role="dialog"` with `aria-modal="true"` on the focusable container.
- Traps focus inside the panel while open; focus returns to the previously focused element on close.
- Provide `title` (or `aria-label` via string title) so the dialog has an accessible name.
- Use `scrollable` for long content so the dialog stays within the viewport.
- Blocking flows can set `closeable={false}` and `showCloseIcon={false}`.
Source
registry/rck/ui/modal.tsx
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { cva, type VariantProps } from "@/lib/cva";
import { X } from "@/lib/icons";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/registry/rck/primitives/focus-trap";
import { Button } from "@/registry/rck/ui/button";
import { Panel, type PanelProps } from "@/registry/rck/ui/panel";
import {
createAsyncCleanup,
MODAL_ANIMATION_DURATION,
useBodyScrollLock,
type ModalPhase,
} from "@/registry/rck/ui/modal-utils";
const modalWidthVariants = cva("mx-auto flex w-full max-w-[90%] flex-col outline-none", {
variants: {
modalWidth: {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
auto: "w-auto max-w-[90%]",
full: "h-[calc(100vh-2.5rem)] max-w-[calc(100vw-2rem)]",
},
},
defaultVariants: {
modalWidth: "md",
},
});
export interface ModalProps
extends
Omit<PanelProps, "className" | "children" | "noPadding">,
VariantProps<typeof modalWidthVariants> {
show: boolean;
children: React.ReactNode;
closeable?: boolean;
showCloseIcon?: boolean;
onClose?: () => void;
zIndex?: number;
unmountOnHide?: boolean;
onOpening?: () => void;
onClosing?: () => void;
onOpened?: () => void;
onClosed?: () => void;
initialFocusRef?: React.RefObject<HTMLElement | null>;
hideHeader?: boolean;
bodyNoPadding?: boolean;
bodyClassName?: string;
overlayClassName?: string;
className?: string;
/** When true, the body scrolls inside the panel instead of growing past the viewport. */
scrollable?: boolean;
}
export function Modal(props: ModalProps) {
const {
show,
closeable = true,
showCloseIcon = true,
children,
onClose,
modalWidth = "md",
zIndex = 50,
unmountOnHide = true,
onOpening,
onClosing,
onOpened,
onClosed,
initialFocusRef,
hideHeader = false,
bodyNoPadding = false,
bodyClassName,
overlayClassName,
className,
scrollable = false,
title,
description,
footer,
leftIcon,
rightIcons,
leftContent,
rightContent,
leftDetails,
rightDetails,
onHeaderClick,
disabled = false,
variant,
collapsible,
open,
defaultOpen,
onOpenChange,
...rest
} = props;
const [phase, setPhase] = React.useState<ModalPhase>("exited");
const [mounted, setMounted] = React.useState<boolean>(show || !unmountOnHide);
const isOpen = phase === "entering" || phase === "entered";
const isClosing = phase === "exiting";
const isInteractive = isOpen || isClosing;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const overlayRef = React.useRef<HTMLDivElement | null>(null);
const prevFocusRef = React.useRef<HTMLElement | null>(null);
const downOnOverlayRef = React.useRef(false);
const asyncRef = React.useRef(createAsyncCleanup());
useBodyScrollLock((show && mounted) || isClosing);
useFocusTrap(isOpen, containerRef, {
restoreFocus: false,
autoFocus: false,
});
React.useEffect(() => {
if (!unmountOnHide) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- keep mounted for exit transitions
setMounted(true);
} else if (!show && phase === "exited") setMounted(false);
}, [unmountOnHide, show, phase]);
const restorePrevFocus = React.useCallback(() => {
if (prevFocusRef.current && typeof prevFocusRef.current.focus === "function") {
prevFocusRef.current.focus();
} else {
(document.body as HTMLElement & { focus?: () => void }).focus?.();
}
}, []);
const requestClose = React.useCallback(() => {
asyncRef.current.installClickShield();
restorePrevFocus();
onClose?.();
}, [onClose, restorePrevFocus]);
React.useLayoutEffect(() => {
const async = asyncRef.current;
async.clear();
if (show) {
if (!mounted) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount before enter animation
setMounted(true);
}
onOpening?.();
prevFocusRef.current = document.activeElement as HTMLElement | null;
async.scheduleRaf(() => {
const overlay = overlayRef.current;
const container = containerRef.current;
if (overlay) {
if (typeof overlay.scrollTo === "function") overlay.scrollTo(0, 0);
else overlay.scrollTop = 0;
}
void container?.getBoundingClientRect();
async.scheduleRaf(() => {
setPhase("entering");
const target = initialFocusRef?.current ?? containerRef.current;
target?.focus();
async.scheduleTimeout(() => {
setPhase("entered");
onOpened?.();
}, MODAL_ANIMATION_DURATION + 16);
});
});
} else if (mounted) {
restorePrevFocus();
setPhase("exiting");
onClosing?.();
async.installClickShield();
async.scheduleTimeout(() => {
setPhase("exited");
onClosed?.();
if (unmountOnHide) setMounted(false);
}, MODAL_ANIMATION_DURATION + 16);
}
return async.clear;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show]);
React.useEffect(() => {
if (!mounted) return;
const node = containerRef.current;
if (!node) return;
const onEnd = (event: TransitionEvent) => {
if (event.target !== node) return;
if (phase === "entering") {
setPhase("entered");
onOpened?.();
} else if (phase === "exiting") {
setPhase("exited");
onClosed?.();
if (unmountOnHide) setMounted(false);
}
};
node.addEventListener("transitionend", onEnd);
return () => node.removeEventListener("transitionend", onEnd);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mounted, phase, unmountOnHide]);
React.useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && closeable && show) {
asyncRef.current.installClickShield();
restorePrevFocus();
onClose?.();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [closeable, onClose, restorePrevFocus, show]);
React.useEffect(() => () => asyncRef.current.clear(), []);
const onOverlayMouseDown = (event: React.MouseEvent) => {
downOnOverlayRef.current = event.target === event.currentTarget;
};
const onOverlayMouseUp = (event: React.MouseEvent) => {
if (!closeable) return;
if (downOnOverlayRef.current && event.target === event.currentTarget) {
requestClose();
}
downOnOverlayRef.current = false;
};
if (!mounted) return null;
const mergedRightIcons = rightIcons;
const closeControl =
showCloseIcon && onClose ? (
<Button
type="button"
variant="ghost"
size="sm"
aria-label="Close"
onClick={requestClose}
>
<X className="size-4" aria-hidden="true" />
</Button>
) : null;
const mergedRightContent =
closeControl || rightContent ? (
<>
{rightContent}
{closeControl}
</>
) : undefined;
const footerNode = footer ? (
<div className="flex w-full flex-wrap items-center justify-end gap-2">{footer}</div>
) : undefined;
const body = scrollable ? (
<div
data-slot="modal-scroll"
className={cn("max-h-[min(70vh,32rem)] overflow-y-auto pr-1", bodyClassName)}
>
{children}
</div>
) : (
<div className={cn("modal-content", bodyClassName)}>{children}</div>
);
const content = (
<>
<div
ref={overlayRef}
role="presentation"
data-testid="modal-overlay"
data-slot="modal-overlay"
data-state={phase}
className={cn(
"fixed inset-0 box-border flex items-center justify-center overflow-y-auto bg-black/50 px-0 py-5",
"transition-[opacity,backdrop-filter] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] will-change-[opacity,backdrop-filter]",
isOpen && "opacity-100 backdrop-blur-[2px]",
(isClosing || phase === "exited") && "opacity-0 backdrop-blur-none",
!isInteractive && "pointer-events-none",
!unmountOnHide &&
phase === "exited" &&
"pointer-events-none invisible -z-10 opacity-0",
overlayClassName,
)}
style={{ zIndex }}
onMouseDown={onOverlayMouseDown}
onMouseUp={onOverlayMouseUp}
>
<div
ref={containerRef}
data-testid="modal-container"
data-slot="modal-container"
data-state={phase}
role="dialog"
aria-modal="true"
aria-label={typeof title === "string" ? title : undefined}
tabIndex={-1}
className={cn(
modalWidthVariants({ modalWidth }),
"transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] will-change-[opacity,transform]",
isOpen && "translate-y-0 scale-100 opacity-100",
(isClosing || phase === "exited") && "translate-y-3 scale-[0.97] opacity-0",
isClosing && "pointer-events-none",
!unmountOnHide &&
phase === "exited" &&
"!translate-y-3 !scale-[0.97] !opacity-0 !transition-none",
"focus-visible:ring-ring/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className,
)}
style={{ zIndex: zIndex + 1 }}
onClick={(event) => event.stopPropagation()}
{...rest}
>
<Panel
variant={variant ?? "elevated"}
className={cn(
"overflow-hidden border-0 shadow-none",
"[&_[data-slot=panel-header]]:items-start [&_[data-slot=panel-header]]:gap-4",
"[&_[data-slot=panel-header]]:border-border [&_[data-slot=panel-header]]:border-b",
"[&_[data-slot=panel-header]]:bg-surface-2/40 [&_[data-slot=panel-header]]:px-5 [&_[data-slot=panel-header]]:py-4",
"[&_[data-slot=panel-title]]:text-base [&_[data-slot=panel-title]]:font-semibold [&_[data-slot=panel-title]]:tracking-tight",
"[&_[data-slot=panel-description]]:mt-1.5 [&_[data-slot=panel-description]]:text-xs [&_[data-slot=panel-description]]:leading-relaxed",
"[&_[data-slot=panel-footer]]:bg-surface-2/30 [&_[data-slot=panel-footer]]:px-5 [&_[data-slot=panel-footer]]:py-3.5",
"[&_[data-slot=panel-footer]_[data-slot=button]]:h-8 [&_[data-slot=panel-footer]_[data-slot=button]]:min-h-8",
"[&_[data-slot=panel-footer]_[data-slot=button]]:rounded-lg [&_[data-slot=panel-footer]_[data-slot=button]]:px-3",
"[&_[data-slot=panel-footer]_[data-slot=button]]:text-[11px]",
"[&_[data-slot=panel-footer]_[data-slot=button-group]]:gap-2",
"[&_[data-slot=panel-footer]_[data-slot=button-group]_[data-slot=button]]:!rounded-lg",
"[&_[data-slot=panel-footer]_[data-slot=button-group]_[data-slot=button]:not(:first-child)]:!border-l",
)}
title={hideHeader ? undefined : title}
description={hideHeader ? undefined : description}
footer={footerNode}
leftIcon={leftIcon}
rightIcons={mergedRightIcons}
leftContent={leftContent}
rightContent={mergedRightContent}
leftDetails={leftDetails}
rightDetails={rightDetails}
onHeaderClick={onHeaderClick}
disabled={disabled}
noPadding={bodyNoPadding}
collapsible={collapsible}
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
{body}
</Panel>
</div>
</div>
</>
);
if (typeof document === "undefined") return null;
return createPortal(content, document.body);
}
Related examples
Sizessm through xl size variants.
Trigger & footer actionsA confirmation dialog with cancel/confirm actions.
Scrollable contentLong body with Accept/Decline footer actions.
Blocking dialogNo close button; overlay and Escape disabled.
Focus restorationFocus returns to the trigger after closing.
Placement overrideCentered vs. a top-anchored panel via className.