Modal
Centered dialog on the native dialog element that fades and scales in, leaves faster than it arrives, and keeps focus inside while open.
"use client";
import { useState } from "react";
import { Button } from "@/components/motion/button";
import { Modal } from "@/components/motion/modal";
export function ModalPreview() {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
return (
<>
<Button onClick={() => setOpen(true)}>Invite teammate</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Invite a teammate"
description="They will get an email with a link to join this workspace."
footer={
<>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setOpen(false);
setEmail("");
}}
>
Send invite
</Button>
</>
}
>
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">Email</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="name@company.com"
// 16px on phones so iOS does not zoom into the field.
className="h-10 rounded-xl bg-card px-3 text-base text-foreground shadow-[0_0_0_1px_var(--border-strong)] outline-none transition-shadow duration-150 placeholder:text-muted-foreground focus:shadow-[0_0_0_2px_var(--accent)] sm:text-sm"
/>
</label>
</Modal>
</>
);
}
"use client";
// easeui.dev/components/motion/modal
import { X } from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
/** Matches the exit transition below. Closing is quicker than opening. */
const EXIT_MS = 150;
export interface ModalProps {
/** Whether the modal is open. */
open: boolean;
/** Called when the modal asks to close, from Escape, the backdrop, or the close button. */
onOpenChange: (open: boolean) => void;
/** Heading that also names the dialog for screen readers. */
title: ReactNode;
/** Optional line under the title. */
description?: ReactNode;
/** Buttons along the bottom edge. */
footer?: ReactNode;
children?: ReactNode;
className?: string;
}
/**
* A centered dialog built on the native dialog element, so focus stays inside,
* the page behind is inert, and focus returns to the trigger on close. It fades
* and scales in from 0.97, and leaves a little faster than it arrived.
*/
export function Modal({
open,
onOpenChange,
title,
description,
footer,
children,
className,
}: ModalProps) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
const descriptionId = useId();
// Drives the CSS transition. It lags one frame behind opening so the entrance can animate.
const [shown, setShown] = useState(false);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open) {
if (!dialog.open) dialog.showModal();
const frame = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(frame);
}
setShown(false);
if (!dialog.open) return;
// Let the exit transition finish before the dialog leaves the top layer.
const timeout = setTimeout(() => dialog.close(), EXIT_MS);
return () => clearTimeout(timeout);
}, [open]);
// The native dialog does not stop the page behind it from scrolling.
useEffect(() => {
if (!open) return;
const root = document.documentElement;
const previous = root.style.overflow;
root.style.overflow = "hidden";
return () => {
root.style.overflow = previous;
};
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
data-shown={shown}
// Escape fires cancel. Close through state instead so the exit animates.
onCancel={(event) => {
event.preventDefault();
onOpenChange(false);
}}
onClose={() => {
if (open) onOpenChange(false);
}}
className="group fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none items-center justify-center overflow-hidden bg-transparent p-4 text-foreground backdrop:bg-transparent open:flex"
>
<button
type="button"
aria-label="Close"
tabIndex={-1}
onClick={() => onOpenChange(false)}
className="absolute inset-0 cursor-default bg-black/40 opacity-0 transition-opacity duration-150 ease-out group-data-[shown=true]:opacity-100 group-data-[shown=true]:duration-200 motion-reduce:transition-none"
/>
<div
className={cn(
"relative flex max-h-full w-full max-w-md flex-col overflow-y-auto rounded-3xl bg-background p-6 shadow-[0_0_0_1px_var(--border-strong),0_24px_60px_-20px_rgb(0_0_0/0.45)]",
"scale-[0.97] opacity-0 transition-[opacity,scale] duration-150 ease-out",
"group-data-[shown=true]:scale-100 group-data-[shown=true]:opacity-100 group-data-[shown=true]:duration-200",
"motion-reduce:transition-none",
className,
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1.5">
<h2 id={titleId} className="text-lg font-semibold tracking-tight">
{title}
</h2>
{description ? (
<p id={descriptionId} className="text-pretty text-sm text-muted-foreground">
{description}
</p>
) : null}
</div>
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className="relative -mr-2 -mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 after:absolute after:-inset-1.5 hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{children ? <div className="mt-5">{children}</div> : null}
{footer ? <div className="mt-6 flex flex-wrap justify-end gap-2">{footer}</div> : null}
</div>
</dialog>
);
}
Installation
$ bunx --bun shadcn add @easeui/modal
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/modal.tsx "use client"; // easeui.dev/components/motion/modal import { X } from "lucide-react"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; /** Matches the exit transition below. Closing is quicker than opening. */ const EXIT_MS = 150; export interface ModalProps { /** Whether the modal is open. */ open: boolean; /** Called when the modal asks to close, from Escape, the backdrop, or the close button. */ onOpenChange: (open: boolean) => void; /** Heading that also names the dialog for screen readers. */ title: ReactNode; /** Optional line under the title. */ description?: ReactNode; /** Buttons along the bottom edge. */ footer?: ReactNode; children?: ReactNode; className?: string; } /** * A centered dialog built on the native dialog element, so focus stays inside, * the page behind is inert, and focus returns to the trigger on close. It fades * and scales in from 0.97, and leaves a little faster than it arrived. */ export function Modal({ open, onOpenChange, title, description, footer, children, className, }: ModalProps) { const ref = useRef<HTMLDialogElement>(null); const titleId = useId(); const descriptionId = useId(); // Drives the CSS transition. It lags one frame behind opening so the entrance can animate. const [shown, setShown] = useState(false); useEffect(() => { const dialog = ref.current; if (!dialog) return; if (open) { if (!dialog.open) dialog.showModal(); const frame = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(frame); } setShown(false); if (!dialog.open) return; // Let the exit transition finish before the dialog leaves the top layer. const timeout = setTimeout(() => dialog.close(), EXIT_MS); return () => clearTimeout(timeout); }, [open]); // The native dialog does not stop the page behind it from scrolling. useEffect(() => { if (!open) return; const root = document.documentElement; const previous = root.style.overflow; root.style.overflow = "hidden"; return () => { root.style.overflow = previous; }; }, [open]); return ( <dialog ref={ref} aria-labelledby={titleId} aria-describedby={description ? descriptionId : undefined} data-shown={shown} // Escape fires cancel. Close through state instead so the exit animates. onCancel={(event) => { event.preventDefault(); onOpenChange(false); }} onClose={() => { if (open) onOpenChange(false); }} className="group fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none items-center justify-center overflow-hidden bg-transparent p-4 text-foreground backdrop:bg-transparent open:flex" > <button type="button" aria-label="Close" tabIndex={-1} onClick={() => onOpenChange(false)} className="absolute inset-0 cursor-default bg-black/40 opacity-0 transition-opacity duration-150 ease-out group-data-[shown=true]:opacity-100 group-data-[shown=true]:duration-200 motion-reduce:transition-none" /> <div className={cn( "relative flex max-h-full w-full max-w-md flex-col overflow-y-auto rounded-3xl bg-background p-6 shadow-[0_0_0_1px_var(--border-strong),0_24px_60px_-20px_rgb(0_0_0/0.45)]", "scale-[0.97] opacity-0 transition-[opacity,scale] duration-150 ease-out", "group-data-[shown=true]:scale-100 group-data-[shown=true]:opacity-100 group-data-[shown=true]:duration-200", "motion-reduce:transition-none", className, )} > <div className="flex items-start justify-between gap-4"> <div className="flex flex-col gap-1.5"> <h2 id={titleId} className="text-lg font-semibold tracking-tight"> {title} </h2> {description ? ( <p id={descriptionId} className="text-pretty text-sm text-muted-foreground"> {description} </p> ) : null} </div> <button type="button" aria-label="Close" onClick={() => onOpenChange(false)} className="relative -mr-2 -mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 after:absolute after:-inset-1.5 hover:bg-muted hover:text-foreground" > <X className="h-4 w-4" /> </button> </div> {children ? <div className="mt-5">{children}</div> : null} {footer ? <div className="mt-6 flex flex-wrap justify-end gap-2">{footer}</div> : null} </div> </dialog> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/button.tsx "use client"; import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react"; import { forwardRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> { /** Visual style. Default "primary". */ variant?: ButtonVariant; /** Height and padding. "icon" is a square button for a single icon. Default "md". */ size?: ButtonSize; children?: ReactNode; } // A quick press confirms the tap before the action finishes. const PRESS = { scale: 0.97 }; const NO_PRESS = { scale: 1 }; const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const; // Hairline rings are drawn with box-shadow so they blend with any background. const VARIANT_CLASS: Record<ButtonVariant, string> = { primary: "bg-foreground text-background hover:bg-foreground/90", secondary: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted", outline: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5", ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", }; // Small sizes grow an invisible hit area so the tap target stays around 44px. const SIZE_CLASS: Record<ButtonSize, string> = { sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-5 text-base", icon: "h-9 w-9 after:absolute after:-inset-1", }; export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", type = "button", className, children, ...props }, ref, ) { const reduce = useReducedMotion(); return ( <motion.button ref={ref} type={type} // Always pass a gesture so the server and client render the same attributes. whileTap={reduce ? NO_PRESS : PRESS} transition={PRESS_TRANSITION} className={cn( "relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none", "transition-[background-color,color,box-shadow] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50", VARIANT_CLASS[variant], SIZE_CLASS[size], className, )} {...props} > {children} </motion.button> ); });
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | - | Whether the modal is open. |
| onOpenChange | (open: boolean) => void | - | Called when the modal asks to close, from Escape, the backdrop, or the close button. |
| title | ReactNode | - | Heading that also names the dialog for screen readers. |
| description? | ReactNode | - | Optional line under the title. |
| footer? | ReactNode | - | Buttons along the bottom edge. |
| className? | string | - | - |
Updated