Description
Modal gives a dialog surface a subtle scale-and-fade entrance that draws the eye without being distracting. The open animation uses a longer duration for a smooth reveal, while the close animation uses a shorter duration so the dialog gets out of the way quickly. The element is removed from the DOM only after the closing animation finishes, which keeps the exit smooth rather than abrupt.
Example
CSS
:root {
--modal-open-dur: 250ms;
--modal-close-dur: 150ms;
--modal-scale: 0.96;
--modal-scale-close: 0.96;
--modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
.t-modal {
transform-origin: center;
transform: scale(var(--modal-scale));
opacity: 0;
pointer-events: none;
transition:
transform var(--modal-open-dur) var(--modal-ease),
opacity var(--modal-open-dur) var(--modal-ease);
will-change: transform, opacity;
}
.t-modal.is-open {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.t-modal.is-closing {
transform: scale(var(--modal-scale-close));
opacity: 0;
pointer-events: none;
transition:
transform var(--modal-close-dur) var(--modal-ease),
opacity var(--modal-close-dur) var(--modal-ease);
}
@media (prefers-reduced-motion: reduce) {
.t-modal {
transition: none !important;
}
}
React
import { useCallback, useEffect, useRef, useState } from "react";
import "./modal.css"; // paste the CSS above
type Phase = "closed" | "open" | "closing";
function useOpenClose(closeVar: string, fallbackMs: number) {
const [phase, setPhase] = useState<Phase>("closed");
const timer = useRef<number | undefined>(undefined);
const open = useCallback(() => {
window.clearTimeout(timer.current);
setPhase("open");
}, []);
const close = useCallback(() => {
setPhase("closing");
const ms =
parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(closeVar),
) || fallbackMs;
timer.current = window.setTimeout(() => setPhase("closed"), ms);
}, [closeVar, fallbackMs]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return { phase, open, close };
}
export function ModalDemo() {
const { phase, open, close } = useOpenClose("--modal-close-dur", 150);
const stateClass =
phase === "open" ? " is-open" : phase === "closing" ? " is-closing" : "";
return (
<div>
<button
type="button"
onClick={() => (phase === "open" ? close() : open())}
>
{phase === "open" ? "Close" : "Open"} modal
</button>
{phase !== "closed" && (
<div
className={"t-modal" + stateClass}
role="dialog"
aria-modal="true"
aria-label="Delete file dialog"
>
<p>Delete file?</p>
<p>This action cannot be undone.</p>
</div>
)}
</div>
);
}
Variables
| Variable | Default | Notes |
|---|---|---|
--modal-open-dur | 250ms | sourced from --p7-open-dur |
--modal-close-dur | 150ms | sourced from --p7-close-dur |
--modal-scale | 0.96 | sourced from --p7-scale |
--modal-scale-close | 0.96 | sourced from --p7-scale-close |
--modal-ease | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p7-ease |
Credit
Adapted from Modal on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.