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.json

Live 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

PropTypeDefaultDescription
showbooleanControlled visibility.
onClose() => voidCalled when the user dismisses the dialog.
childrenReactNodeDialog body content.
title / descriptionReactNodePanel header text.
modalWidth"sm" | "md" | "lg" | "xl" | "auto" | "full"mdMax width (and height for `full`).
closeablebooleantrueWhether Escape and overlay click dismiss the modal.
showCloseIconbooleantrueShows the header close icon when `onClose` is provided.
scrollablebooleanfalseScrolls long body content inside the panel.
zIndexnumber50Stacking order for the overlay and panel.
hideHeaderbooleanfalseHides the panel header entirely.
bodyNoPaddingbooleanfalseRemoves default padding from the body region.
bodyClassNamestringMerged onto the scrollable body container.
overlayClassNamestringMerged onto the backdrop overlay.
classNamestringMerged onto the panel container.
unmountOnHidebooleantrueRemove from the DOM after the exit animation.
initialFocusRefRefObject<HTMLElement>Element to focus when the dialog opens.
onOpening / onOpened / onClosing / onClosed() => voidAnimation lifecycle callbacks.
footerReactNodeFooter action row via Panel.
leftIcon / rightIcons / leftDetails / rightDetailsPanel slotsRich header configuration (see Panel).
leftContent / rightContentReactNodeCustom 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

Browse the full examples gallery