Back to Code Bytes
3 min read
Modal

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

VariableDefaultNotes
--modal-open-dur250mssourced from --p7-open-dur
--modal-close-dur150mssourced from --p7-close-dur
--modal-scale0.96sourced from --p7-scale
--modal-scale-close0.96sourced from --p7-scale-close
--modal-easecubic-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.