Modal
Modal provides an unstyled basis to display content in a layer above the main interface.
import { Button } from '@/components/button';
import { Modal } from '@/components/modal';
const ModalPreview = () => {
return (
<Modal>
<Modal.Trigger asChild>
<Button variant="outline">Open</Button>
</Modal.Trigger>
<Modal.Content className="min-w-xs space-y-6">
<div className="space-y-1">
<Modal.Title className="font-semibold">Title</Modal.Title>
<Modal.Description>Description</Modal.Description>
</div>
<Modal.Close asChild>
<Button variant="outline">Close</Button>
</Modal.Close>
</Modal.Content>
</Modal>
);
};
export default ModalPreview; Dependencies
Source Code
'use client';
import * as motion from 'motion/react-client';
import {
Children,
createContext,
isValidElement,
use,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { useElementTransition } from '@/foundations/hooks/use-element-transition';
import {
MOTION_DURATION,
MOTION_EASE,
MOTION_SCALE,
} from '@/foundations/setup/motion';
import { composeRefs } from '@/lib/compose-refs';
import { SafeAnimatePresence } from '@/lib/utils/animate-presence-safe';
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);
// Emit modal-open or modal-close custom event when the dialog is opened or closed,
// so other components can react to it
useEffect(() => {
const origin = ref.current;
if (!origin) return;
const openEvent = new CustomEvent(
open ? 'ui:modal-open' : 'ui:modal-close',
{
detail: { origin: ref.current },
}
);
window.dispatchEvent(openEvent);
}, [open]);
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}
data-motion="modal"
aria-labelledby={labelId}
aria-describedby={descriptionId}
className={cn(
// Family defaults: 20px radius, 24px padding, no border, CK modal shadow.
// Examples can override any of these via className (which lands last).
'm-auto rounded-[1.25rem] bg-surface-raised p-6 shadow-[var(--shadow-modal)]',
// No default backdrop blur — CK uses transparent slate dim only.
'backdrop:bg-(--color-overlay) not-data-[status=open]:backdrop:opacity-0 backdrop:transition-opacity backdrop:duration-(--duration-box) backdrop:ease-out',
'motion-reduce:backdrop:transition-none motion-reduce:[animation:none]',
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,
className,
...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}
// CK body copy is muted by default; <strong> flips to primary foreground.
className={cn(
'text-base text-foreground-secondary leading-[21px] [&_strong]:font-medium [&_strong]:text-foreground',
className
)}
{...props}
>
{children}
</Component>
);
};
// ---------------------------------------------------------------------------
// Modal.Pages + Modal.Page — direction-aware step transitions
// ---------------------------------------------------------------------------
// Ports ConnectKit's modal page-swap (Modal/index.tsx + Modal/styles.ts).
// When the activeId changes, the leaving page exits and the entering page
// enters with one of four animations, chosen by direction:
//
// forward (depth ↑): old page FadeOutScaleUp (1 → 1.1)
// new page FadeInScaleUp (0.85 → 1)
// back (depth ↓): old page FadeOutScaleDown (1 → 0.85)
// new page FadeInScaleDown (1.1 → 1)
//
// Depth is inferred from the children's order unless an explicit `depth`
// map is provided.
interface ModalPageProps {
/** Stable ID — must match the value passed to `Modal.Pages` `activeId`. */
id: string;
children: React.ReactNode;
}
// Modal.Page is a marker component — actual rendering happens inside
// Modal.Pages so AnimatePresence can drive the transition.
const ModalPage = ({ children }: ModalPageProps) => <>{children}</>;
ModalPage.displayName = 'Modal.Page';
interface ModalPagesProps extends React.ComponentPropsWithRef<'div'> {
activeId: string;
/** Optional explicit depth map. Defaults to children-order index. */
depth?: Record<string, number>;
children: React.ReactNode;
}
// CK animation timings — `Page` uses `200ms ease both`, exit gets a tiny delay.
// (Modal/styles.ts: animation: 200ms ease both; Page exit has 16.67ms delay.)
const PAGE_TRANSITION = {
duration: MOTION_DURATION.base,
ease: MOTION_EASE.default,
};
const ModalPages = ({
activeId,
depth: explicitDepth,
className,
children,
...props
}: ModalPagesProps) => {
const pages = useMemo(() => {
return Children.toArray(children).filter(
(c): c is React.ReactElement<ModalPageProps> => {
return (
isValidElement<ModalPageProps>(c) && typeof c.props.id === 'string'
);
}
);
}, [children]);
const depthMap = useMemo(() => {
if (explicitDepth) return explicitDepth;
const map: Record<string, number> = {};
pages.forEach((p, i) => {
map[p.props.id] = i;
});
return map;
}, [pages, explicitDepth]);
const prevIdRef = useRef(activeId);
const currentDepth = depthMap[activeId] ?? 0;
const prevDepth = depthMap[prevIdRef.current] ?? 0;
// direction: 1 = forward (deeper), -1 = back (shallower)
const direction: 1 | -1 = currentDepth >= prevDepth ? 1 : -1;
useEffect(() => {
prevIdRef.current = activeId;
}, [activeId]);
const activePage = pages.find((p) => p.props.id === activeId);
return (
// `grid` lets entering + leaving pages stack in the same cell so the
// modal sizes to whichever is rendering, without absolute positioning.
<div className={cn('relative grid', className)} {...props}>
<SafeAnimatePresence mode="popLayout" initial={false} custom={direction}>
{activePage && (
<motion.div
key={activeId}
data-modal-page={activeId}
className="col-start-1 row-start-1"
custom={direction}
variants={{
enter: (d: 1 | -1) => ({
opacity: 0,
// forward: FadeInScaleUp (0.85 → 1)
// back: FadeInScaleDown (1.1 → 1)
// ConnectKit page transition — sub-modal scale entry, not family motion.
scale: d === 1 ? 0.85 : MOTION_SCALE.enterDown,
}),
center: { opacity: 1, scale: 1 },
exit: (d: 1 | -1) => ({
opacity: 0,
// forward: FadeOutScaleUp (1 → 1.1)
// back: FadeOutScaleDown (1 → 0.85)
// ConnectKit page transition — sub-modal scale entry, not family motion.
scale: d === 1 ? MOTION_SCALE.enterDown : 0.85,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={PAGE_TRANSITION}
>
{activePage.props.children}
</motion.div>
)}
</SafeAnimatePresence>
</div>
);
};
const CompoundModal = Object.assign(Modal, {
Content: ModalContent,
Trigger: ModalTrigger,
Close: ModalClose,
Title: ModalTitle,
Description: ModalDescription,
Pages: ModalPages,
Page: ModalPage,
});
export { CompoundModal as Modal }; 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>
<Modal.Trigger />
<Modal.Content>
<Modal.Title />
<Modal.Description />
<Modal.Close />
</Modal.Content>
</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 |
Modal.Content
Extends the native <dialog> element.
| Prop | Default | Type | Description |
|---|---|---|---|
catchFocus | true | boolean | Whether to catch initial focus to prevent scroll jumps |
Modal.Trigger
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as a child component instead of an h2 |
Modal.Close
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as a child component instead of an h2 |
Modal.Title
Extends the h2 element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as a child component instead of an h2 |
Modal.Description
Extends the p element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | false | boolean | Render as a child component instead of a p |
Modal.Pages
Wraps a set of Modal.Page children and animates between them with
direction-aware enter/exit transitions ported from ConnectKit.
| Prop | Default | Type | Description |
|---|---|---|---|
activeId | - | string | ID of the page currently visible. Must match the `id` of one Modal.Page child. |
depth | - | Record<string, number> | Optional explicit depth map. Defaults to children-array order. Higher depth = deeper in the flow. |
Modal.Page
A single step inside a Modal.Pages container. Renders only when its
id matches the parent’s activeId.
| Prop | Default | Type | Description |
|---|---|---|---|
id | - | string | Stable identifier — used by Modal.Pages to determine the active page. |
Examples
Basic Modal
import { Button } from '@/components/button';
import { Modal } from '@/components/modal';
const ModalPreview = () => {
return (
<Modal>
<Modal.Trigger asChild>
<Button variant="outline">Open</Button>
</Modal.Trigger>
<Modal.Content className="min-w-xs space-y-6">
<div className="space-y-1">
<Modal.Title className="font-semibold">Title</Modal.Title>
<Modal.Description>Description</Modal.Description>
</div>
<Modal.Close asChild>
<Button variant="outline">Close</Button>
</Modal.Close>
</Modal.Content>
</Modal>
);
};
export default ModalPreview; Controlled Modal
import { useState } from 'react';
import { Button } from '@/components/button';
import { Modal } 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>
<Modal.Content inert={isSubmitting}>
<Modal.Title className="my-1 font-semibold">Delete Account</Modal.Title>
<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>
</Modal.Content>
</Modal>
);
};
export default ModalControlledPreview; Multi-step (Pages)
import { useState } from 'react';
import { Button } from '@/components/button';
import { Modal } from '@/components/modal';
type Step = 'welcome' | 'details' | 'confirm';
const ModalPagesPreview = () => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<Step>('welcome');
// Reset to welcome when modal opens
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (next) setStep('welcome');
};
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<Modal.Trigger asChild>
<Button variant="muted">Start flow</Button>
</Modal.Trigger>
<Modal.Content className="min-w-xs">
<Modal.Pages activeId={step}>
<Modal.Page id="welcome">
<div className="space-y-4 text-center">
<Modal.Title className="font-semibold text-lg">
Welcome
</Modal.Title>
<Modal.Description>
Step 1 of 3 — let's get you set up. Click{' '}
<strong>Continue</strong> to go deeper. Watch the scale-up
animation.
</Modal.Description>
<Button
variant="muted"
className="w-full"
onClick={() => setStep('details')}
>
Continue
</Button>
</div>
</Modal.Page>
<Modal.Page id="details">
<div className="space-y-4 text-center">
<Modal.Title className="font-semibold text-lg">
Details
</Modal.Title>
<Modal.Description>
Step 2 of 3 — going back uses <strong>scale-down</strong>
going forward uses <strong>scale-up</strong>.
</Modal.Description>
<div className="flex gap-2">
<Button
variant="brandSoft"
className="grow"
onClick={() => setStep('welcome')}
>
Back
</Button>
<Button
variant="muted"
className="grow"
onClick={() => setStep('confirm')}
>
Continue
</Button>
</div>
</div>
</Modal.Page>
<Modal.Page id="confirm">
<div className="space-y-4 text-center">
<Modal.Title className="font-semibold text-lg">
All set!
</Modal.Title>
<Modal.Description>
Step 3 of 3 — back will scale down to the previous step.
</Modal.Description>
<div className="flex gap-2">
<Button
variant="brandSoft"
className="grow"
onClick={() => setStep('details')}
>
Back
</Button>
<Modal.Close asChild>
<Button variant="muted" className="grow">
Done
</Button>
</Modal.Close>
</div>
</div>
</Modal.Page>
</Modal.Pages>
</Modal.Content>
</Modal>
);
};
export default ModalPagesPreview; Modal.Pages swaps between Modal.Page children with direction-aware
transitions. Going forward (deeper) uses FadeInScaleUp (0.85 → 1) for
the entering page and FadeOutScaleUp (1 → 1.1) for the leaving one;
going back uses the inverted scales. Direction is inferred from the
order of Modal.Page children unless an explicit depth map is passed.
Motion
Modal.Pages uses a deliberate one-off scale literal (0.85) for the forward enter and back exit phases of its sub-modal page transition. This is a ConnectKit page-transition value, not family motion — it sits intentionally outside MOTION_SCALE because it tunes the depth feel of nested page swaps rather than the system-wide enter/exit pop. Per §10 of the motion guide, it’s documented here as a literal-without-token rather than promoted to a shared scale.
The complementary scale (MOTION_SCALE.enterDown, 1.1) is used for the back-enter and forward-exit phases so the motion still reads as inversion across direction.
Best Practices
-
Accessibility:
- Always provide
Modal.Titlefor screen readers - Use
Modal.Descriptionfor additional context - Ensure keyboard navigation works properly
- Always provide
-
User Experience:
- Keep modal content focused and concise
- Provide clear actions (confirm/cancel)
Previous
Menu
Next
Nav