Modal
Modal provides an unstyled basis to display content in a layer above the main interface.
'use client';
import { Button } from '@/components/button';
import {
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalTitle,
ModalTrigger,
} from '@/components/modal';
const ModalControlledPreview = () => {
return (
<Modal>
<ModalTrigger asChild>
<Button variant="outline">Open</Button>
</ModalTrigger>
<ModalContent className="min-w-xs space-y-6 rounded-xl p-4">
<div className="space-y-1">
<ModalTitle className="font-semibold">Title</ModalTitle>
<ModalDescription>Description</ModalDescription>
</div>
<ModalClose asChild>
<Button variant="outline">Close</Button>
</ModalClose>
</ModalContent>
</Modal>
);
};
export default ModalControlledPreview; Dependencies
Source Code
'use client';
import {
createContext,
use,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { useElementTransition } from '@/foundations/hooks/use-element-transition';
import { composeRefs } from '@/lib/compose-refs';
import { cn } from '@/lib/utils/classnames';
// Hook to manage native <dialog> element behavior
const useDialogElement = (
open: boolean,
setOpen: (isOpen: boolean) => void
) => {
const ref = useRef<HTMLDialogElement>(null);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
if (!element.open) {
// use native showModal() to ensure it receives focus when opened, and ESC closes it
element.showModal();
}
const abortController = new AbortController();
const { signal } = abortController;
// Prevent the default cancel event and use internal state to close the drawer instead
// This ensures drawer closing is synchronized with internal state, preventing layout shifts
element.addEventListener(
'cancel',
(event: Event) => {
event.preventDefault();
setOpen(false);
},
{ signal }
);
// Prevent ESC from closing the dialog when the cancel event is prevented.
// Unsure if this is a browser bug or intended behavior — the ESC key can push through the cancel event for some reason
element.addEventListener(
'keydown',
(event: KeyboardEvent) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
setOpen(false);
}
},
{ signal }
);
// Prevent ESC from closing the dialog when it is inert.
// If the dialog is opened while inert, the focus goes to the window, which allows ESC to close the dialog unexpectedly.
window.addEventListener(
'keydown',
(event: KeyboardEvent) => {
if (event.key === 'Escape' && element.inert && open) {
event.preventDefault();
setOpen(false);
}
},
{ signal }
);
return () => {
abortController.abort();
};
}, [open, setOpen]);
useEffect(() => {
const element = ref.current;
if (!element || !open) return;
const handleDialogClick = (event: MouseEvent) => {
// if the click is on the backdrop, close the drawer
if ((event.target as HTMLElement).nodeName === 'DIALOG') {
const dialog = event.target as HTMLDialogElement;
const { top, left, width, height } = dialog.getBoundingClientRect();
const isOutsideModal =
top > event.clientY ||
event.clientY > top + height ||
left > event.clientX ||
event.clientX > left + width;
if (isOutsideModal) {
event.stopPropagation();
setOpen(false);
}
}
};
element.addEventListener('click', handleDialogClick);
return () => {
element.removeEventListener('click', handleDialogClick);
};
}, [setOpen, open]);
return ref;
};
const ModalContext = createContext<{
open: boolean;
labelId?: string;
descriptionId?: string;
setOpen: (open: boolean) => void;
setLabelId: (id?: string) => void;
setDescriptionId: (id?: string) => void;
} | null>(null);
const useModalContext = () => {
const context = use(ModalContext);
if (!context) {
throw new Error('Modal component must be used within a Modal');
}
return context;
};
interface ModalProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
const Modal = ({ open: propsOpen, onOpenChange, children }: ModalProps) => {
const [internalOpen, setInternalOpen] = useState(false);
const [labelId, setLabelId] = useState<string | undefined>(undefined);
const [descriptionId, setDescriptionId] = useState<string | undefined>(
undefined
);
const open = propsOpen ?? internalOpen;
const setOpen = useCallback(
(isOpen: boolean) => {
setInternalOpen(isOpen);
onOpenChange?.(isOpen);
},
[onOpenChange]
);
const ctx = useMemo(
() => ({
open,
setOpen,
labelId,
setLabelId,
descriptionId,
setDescriptionId,
}),
[descriptionId, labelId, open, setOpen]
);
return <ModalContext value={ctx}>{children}</ModalContext>;
};
interface ModalContentProps extends React.ComponentPropsWithRef<'dialog'> {
catchFocus?: boolean;
}
const ModalContent = ({
className,
children,
catchFocus = true,
...props
}: ModalContentProps) => {
const { open, labelId, descriptionId, setOpen } = useModalContext();
const dialogRef = useDialogElement(open, setOpen);
const {
ref: transitionRef,
isMounted,
status,
} = useElementTransition<HTMLDialogElement>(open);
if (!isMounted) return;
return (
<dialog
ref={composeRefs(dialogRef, transitionRef)}
data-status={status}
aria-labelledby={labelId}
aria-describedby={descriptionId}
className={cn('m-auto', className)}
{...props}
>
{catchFocus && (
// By default, the HTML <dialog> element focuses the first focusable child when opened.
// If that element is scrolled out of view, the dialog may jump to it, causing a jarring and confusing scroll.
// Additionally, browsers like Safari may show focus-visible styles on that element, which can look odd.
// The following element catches initial focus to prevent these issues.
<div
className="sr-only"
autoFocus
tabIndex={-1}
data-modal-focus-catcher=""
/>
)}
{children}
</dialog>
);
};
interface ModalTriggerProps extends React.ComponentPropsWithRef<'button'> {
asChild?: boolean;
}
const ModalTrigger = ({ asChild, children, ...props }: ModalTriggerProps) => {
const { setOpen } = useModalContext();
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
props.onClick?.(event);
if (!event.defaultPrevented) {
setOpen(true);
}
};
const Component = asChild ? Slot : 'button';
return (
<Component {...props} onClick={handleClick}>
{children}
</Component>
);
};
interface ModalCloseProps extends React.ComponentPropsWithRef<'button'> {
asChild?: boolean;
}
const ModalClose = ({
asChild = false,
children,
...props
}: ModalCloseProps) => {
const { setOpen } = useModalContext();
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
props.onClick?.(event as React.MouseEvent<HTMLButtonElement>);
if (!event.defaultPrevented) {
setOpen(false);
}
};
const Component = asChild ? Slot : 'button';
return (
<Component {...props} onClick={handleClick}>
{children}
</Component>
);
};
interface ModalTitleProps extends React.ComponentPropsWithRef<'h2'> {
asChild?: boolean;
}
const ModalTitle = ({ children, asChild, ...props }: ModalTitleProps) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const { setLabelId } = useModalContext();
useLayoutEffect(() => {
setLabelId(id);
return () => setLabelId(undefined);
}, [id, setLabelId]);
const Component = asChild ? Slot : 'h2';
return (
<Component id={id} {...props}>
{children}
</Component>
);
};
interface ModalDescriptionProps extends React.ComponentPropsWithRef<'p'> {
asChild?: boolean;
}
const ModalDescription = ({
children,
asChild,
...props
}: ModalDescriptionProps) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const { setDescriptionId } = useModalContext();
useLayoutEffect(() => {
setDescriptionId(id);
return () => setDescriptionId(undefined);
}, [id, setDescriptionId]);
const Component = asChild ? Slot : 'p';
return (
<Component id={id} {...props}>
{children}
</Component>
);
};
export {
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalTitle,
ModalTrigger,
}; CSS
To prevent background scrolling when the modal is open, add the following CSS to your global styles:
body:has(dialog[open]) {
overflow: hidden;
scrollbar-gutter: stable;
}
Features
- Native Dialog: Built on top of the HTML
<dialog>element for optimal accessibility and base functionality - Backdrop Interaction: Click outside to close, ESC key support
- Focus Management: Automatic focus trapping and restoration
- Smooth Transitions: Integrated with
useElementTransitionfor animations - Controlled & Uncontrolled: Supports both controlled and uncontrolled modes
Anatomy
<Modal>
<ModalTrigger />
<ModalContent>
<ModalTitle />
<ModalDescription />
<ModalClose />
</ModalContent>
</Modal>
API Reference
Modal
| Prop | Default | Type | Description |
|---|---|---|---|
open | - | boolean | Controls the open state of the modal (controlled mode) |
onOpenChange | - | (open: boolean) => void | Callback fired when the open state changes |
children * | - | ReactNode | The modal components |
ModalContent
Extends the native <dialog> element.
| Prop | Default | Type | Description |
|---|---|---|---|
catchFocus | true | boolean | Whether to catch initial focus to prevent scroll jumps |
ModalTrigger
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as a child component instead of an h2 |
ModalClose
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as a child component instead of an h2 |
ModalTitle
Extends the h2 element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as a child component instead of an h2 |
ModalDescription
Extends the p element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as a child component instead of a p |
Examples
Basic Modal
'use client';
import { Button } from '@/components/button';
import {
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalTitle,
ModalTrigger,
} from '@/components/modal';
const ModalControlledPreview = () => {
return (
<Modal>
<ModalTrigger asChild>
<Button variant="outline">Open</Button>
</ModalTrigger>
<ModalContent className="min-w-xs space-y-6 rounded-xl p-4">
<div className="space-y-1">
<ModalTitle className="font-semibold">Title</ModalTitle>
<ModalDescription>Description</ModalDescription>
</div>
<ModalClose asChild>
<Button variant="outline">Close</Button>
</ModalClose>
</ModalContent>
</Modal>
);
};
export default ModalControlledPreview; Controlled Modal
'use client';
import { useState } from 'react';
import { Button } from '@/components/button';
import { Modal, ModalContent, ModalTitle } from '@/components/modal';
const ModalControlledPreview = () => {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = () => {
setIsSubmitting(true);
setTimeout(() => {
setIsSubmitting(false);
setOpen(false);
}, 3000);
};
const handleCancel = () => {
setOpen(false);
};
return (
<Modal
open={open}
onOpenChange={(isOpen) => setOpen(isSubmitting ? true : isOpen)}
>
<Button onClick={() => setOpen(true)} variant="destructive">
Delete
</Button>
<ModalContent inert={isSubmitting} className="rounded-xl p-4">
<ModalTitle className="my-1 font-semibold">Delete Account</ModalTitle>
<p>Are you sure you want to proceed?</p>
<div className="mt-4 flex gap-2">
<Button
isLoading={isSubmitting}
onClick={handleSubmit}
variant="destructive"
>
Delete
</Button>
<Button onClick={handleCancel}>Cancel</Button>
</div>
</ModalContent>
</Modal>
);
};
export default ModalControlledPreview; Best Practices
-
Accessibility:
- Always provide
ModalTitlefor screen readers - Use
ModalDescriptionfor additional context - Ensure keyboard navigation works properly
- Always provide
-
User Experience:
- Keep modal content focused and concise
- Provide clear actions (confirm/cancel)
Previous
Listbox
Next
Popover