9 CSS Modal Animation Patterns, No Library Needed
A css modal animation is the motion a dialog uses to open and close, instead of snapping straight from hidden to visible.
01 modal-fade-scale
02 bottom-sheet
03 side-drawer
04 backdrop-blur
05 native-dialog
06 popover-confirm
07 fullscreen-takeover
08 modal-shake-outside
09 stacked-modals
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Fade & scale
- 02 Bottom sheet
- 03 Side drawer
- 04 Backdrop blur
- 05 Native dialog
- 06 Popover confirm
- 07 Fullscreen takeover
- 08 Shake on outside click
- 09 Stacked modals
The nine tiles above open and close on their own, looping every two seconds inside a non-scrollable iframe that's too short to hold a real interaction — watch them rather than expecting a click to do anything, since none of the trigger buttons in that preview are wired up. 01 through 04 vary a single centered or edge-anchored panel by position and by what happens behind it. 05 and 06 hand the job to the browser itself, animating the native dialog element and a Popover API bubble instead of a hand-rolled overlay div. 07 through 09 add a second moving part: a thumbnail that becomes the modal, a shake that stands in for a close, and a second dialog that pushes the first aside. Once a takeover like 07 grows to cover a whole route rather than one panel, the CSS page transitions collect that larger size of change, and for entrances triggered by scrolling rather than by opening, the CSS scroll animations are the companion set.
01Fade & scale
What it does
A centered modal fades in while scaling up from 0.9 to 1 over roughly 200ms, then reverses on close — nothing slides, nothing changes color, just opacity and scale together.

<main class="stage is-demo">
<button class="trigger" type="button">Pay now</button>
<div class="overlay"></div>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="t1">
<h2 id="t1">Complete this payment?</h2>
<p>Once your card is approved, this can't be undone.</p>
<button class="modal__close" type="button">Confirm</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-quick; // 열리는 시간 (실제 클릭)
$easing: $ease-spring; // 처음 빠르고 끝은 부드럽게
$color: $subject-blue; // 강조색(버튼·포커스)
@include stage($stage-ink);
.stage { position: relative; }
.trigger {
border: 0; border-radius: $r-pill;
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-ink; background: $subject-cream;
cursor: pointer; -webkit-tap-highlight-color: transparent;
transition: filter $dur-instant;
&:hover, &:focus-visible { filter: brightness(.94); }
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .55);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.modal {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%) scale(.92);
opacity: 0; pointer-events: none;
width: min(78vw, 300px);
padding: clamp(12px, 4.5vmin, 22px);
border-radius: $r-card; background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
transition: opacity $duration $easing, transform $duration $easing;
h2 { margin: 0 0 6px; font-size: clamp(13px, 4.4vmin, 18px); }
p { margin: 0 0 14px; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .65); }
}
.modal__close {
border: 0; border-radius: $r-control; background: $color; color: $stage-paper;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
// 실제 조작 상태
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .modal { opacity: 1; transform: translate(-50%, -50%) scale(1); pointer-events: auto; }
// 데모: 2초 안에 열림→닫힘 반복 (실측 2026-09-09 편 001 관례: 루프 전용 keyframes 는 $dur-loop 로 고정)
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .modal { animation: modal-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0% { opacity: 0; }
15% { opacity: 1; }
70% { opacity: 1; }
85% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes modal-loop {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.92); }
15% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
70% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
85% { opacity: 0; transform: translate(-50%, -50%) scale(.92); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(.92); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .modal { animation: none; }
.overlay, .modal { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.trigger {
border: 0;
border-radius: 999px;
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: filter 140ms;
}
.trigger:hover, .trigger:focus-visible {
filter: brightness(0.94);
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.92);
opacity: 0;
pointer-events: none;
width: min(78vw, 300px);
padding: clamp(12px, 4.5vmin, 22px);
border-radius: 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.modal h2 {
margin: 0 0 6px;
font-size: clamp(13px, 4.4vmin, 18px);
}
.modal p {
margin: 0 0 14px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.65);
}
.modal__close {
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .modal {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .modal {
animation: modal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes overlay-loop {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
70% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes modal-loop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
}
15% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
70% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
85% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .modal {
animation: none;
}
.overlay, .modal {
transition-duration: 1ms;
}
}// 실제 클릭: 트리거로 열고 오버레이·Confirm·ESC 로 닫는다. 데모(.is-demo)는 자동 반복하다가 실제 조작이 오면 멈춘다.
(function () {
var stage = document.querySelector('.stage');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var close = document.querySelector('.modal__close');
function open() { stage.classList.remove('is-demo'); stage.classList.add('is-open'); }
function shut() { stage.classList.remove('is-open'); }
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
close.addEventListener('click', shut);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') shut(); });
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ModalFadeScale.module.scss";
export type ModalFadeScaleProps = {
triggerLabel: string;
title: string;
description: string;
confirmLabel: string;
/** 열리는 시간 (기본 220ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 강조색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ModalFadeScale({
triggerLabel,
title,
description,
confirmLabel,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ModalFadeScaleProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<button type="button" className={styles.trigger} onClick={() => setOpen(true)}>
{triggerLabel}
</button>
<div
className={open ? `${styles.overlay} ${styles.isOpen}` : styles.overlay}
onClick={() => setOpen(false)}
/>
<div
className={open ? `${styles.modal} ${styles.isOpen}` : styles.modal}
role="dialog"
aria-modal="true"
>
<h2>{title}</h2>
<p>{description}</p>
<button type="button" className={styles.confirm} onClick={() => setOpen(false)}>
{confirmLabel}
</button>
</div>
</div>
);
}01 · Fade & scale — Measured area 24.692% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a css modal popup animation belongs
It's the safest default for confirm and alert dialogs, the kind a checkout button opens right before it charges a card.
.overlay {
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.modal {
transform: translate(-50%, -50%) scale(.92);
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing,
transform $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .modal {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
pointer-events: auto;
}
02Bottom sheet
What you see
A sheet slides up from the bottom edge and stops flush against its own content height instead of filling the screen, with a small handle bar above the heading to signal that it's a sheet rather than a modal.

<main class="stage is-demo">
<button class="trigger" type="button">View options</button>
<div class="overlay"></div>
<div class="sheet" role="dialog" aria-modal="true" aria-labelledby="t2">
<div class="sheet__handle"></div>
<h2 id="t2">Delivery options</h2>
<p>Choose same-day, next-day, or standard delivery.</p>
<button class="sheet__close" type="button">Close</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 시트가 올라오는 시간
$easing: $ease-out; // 관성 없이 딱 멈춘다
$color: $subject-ink; // 강조색(손잡이·버튼)
@include stage($stage-yellow);
.stage { position: relative; }
.trigger {
border: 0; border-radius: $r-pill;
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-paper; background: $subject-ink;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .5);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.sheet {
position: fixed; left: 50%; bottom: 0; top: auto;
transform: translate(-50%, 100%);
width: min(94vw, 320px);
max-height: 62%;
box-sizing: border-box;
padding: clamp(10px, 4vmin, 18px) clamp(14px, 5vmin, 22px);
border-radius: $r-card $r-card 0 0;
background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing;
h2 { margin: 8px 0 4px; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0 0 10px; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .65); }
}
.sheet__handle {
width: 36px; height: 4px; margin: 0 auto;
border-radius: $r-pill; background: rgba($stage-ink, .25);
}
.sheet__close {
border: 0; border-radius: $r-control; background: $color; color: $stage-paper;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .sheet { transform: translate(-50%, 0); pointer-events: auto; }
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .sheet { animation: sheet-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0% { opacity: 0; }
15% { opacity: 1; }
70% { opacity: 1; }
85% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes sheet-loop {
0% { transform: translate(-50%, 100%); }
15% { transform: translate(-50%, 0); }
70% { transform: translate(-50%, 0); }
85% { transform: translate(-50%, 100%); }
100% { transform: translate(-50%, 100%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .sheet { animation: none; }
.overlay, .sheet { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.trigger {
border: 0;
border-radius: 999px;
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #ffffff;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out;
}
.sheet {
position: fixed;
left: 50%;
bottom: 0;
top: auto;
transform: translate(-50%, 100%);
width: min(94vw, 320px);
max-height: 62%;
box-sizing: border-box;
padding: clamp(10px, 4vmin, 18px) clamp(14px, 5vmin, 22px);
border-radius: 16px 16px 0 0;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 300ms ease-out;
}
.sheet h2 {
margin: 8px 0 4px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.sheet p {
margin: 0 0 10px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.65);
}
.sheet__handle {
width: 36px;
height: 4px;
margin: 0 auto;
border-radius: 999px;
background: rgba(23, 20, 26, 0.25);
}
.sheet__close {
border: 0;
border-radius: 12px;
background: #17141a;
color: #ffffff;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .sheet {
transform: translate(-50%, 0);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .sheet {
animation: sheet-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
70% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sheet-loop {
0% {
transform: translate(-50%, 100%);
}
15% {
transform: translate(-50%, 0);
}
70% {
transform: translate(-50%, 0);
}
85% {
transform: translate(-50%, 100%);
}
100% {
transform: translate(-50%, 100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .sheet {
animation: none;
}
.overlay, .sheet {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var close = document.querySelector('.sheet__close');
function open() { stage.classList.remove('is-demo'); stage.classList.add('is-open'); }
function shut() { stage.classList.remove('is-open'); }
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
close.addEventListener('click', shut);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') shut(); });
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BottomSheet.module.scss";
export type BottomSheetProps = {
triggerLabel: string;
title: string;
description: string;
closeLabel: string;
/** 시트가 올라오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BottomSheet({
triggerLabel,
title,
description,
closeLabel,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: BottomSheetProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<button type="button" className={styles.trigger} onClick={() => setOpen(true)}>
{triggerLabel}
</button>
<div
className={open ? `${styles.overlay} ${styles.isOpen}` : styles.overlay}
onClick={() => setOpen(false)}
/>
<div
className={open ? `${styles.sheet} ${styles.isOpen}` : styles.sheet}
role="dialog"
aria-modal="true"
>
<div className={styles.handle} />
<h2>{title}</h2>
<p>{description}</p>
<button type="button" className={styles.close} onClick={() => setOpen(false)}>
{closeLabel}
</button>
</div>
</div>
);
}02 · Bottom sheet — Measured area 98.256% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
That handle matters most on the narrow, mobile-width screens this pattern was built for, where a full modal would feel oversized.
.sheet {
position: fixed;
left: 50%; bottom: 0; top: auto;
transform: translate(-50%, 100%);
max-height: 62%;
border-radius: $r-card $r-card 0 0;
transition: transform $duration $easing;
}
.sheet__handle {
width: 36px; height: 4px;
margin: 0 auto;
border-radius: $r-pill;
background: rgba($stage-ink, .25);
}
.stage.is-open .sheet { transform: translate(-50%, 0); }
03Side drawer
The motion
A roughly 260px-wide panel slides in from the right edge on a spring easing that overshoots slightly before settling, rather than stopping dead the instant it reaches its final position.

<main class="stage is-demo">
<button class="trigger" type="button">Cart</button>
<div class="overlay"></div>
<div class="drawer" role="dialog" aria-modal="true" aria-labelledby="t3">
<h2 id="t3">Cart (2)</h2>
<p>Total ₩48,000. Shipping shown next.</p>
<button class="drawer__close" type="button">Close</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 패널이 밀려 들어오는 시간
$easing: $ease-spring; // 끝에서 살짝 튕긴다
$color: $subject-cream; // 강조색(버튼)
@include stage($stage-orange);
.stage { position: relative; }
.trigger {
border: 0; border-radius: $r-pill;
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-orange; background: $subject-cream;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .5);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
.drawer {
position: fixed; right: 0; top: 0; bottom: 0; left: auto;
transform: translateX(100%);
width: min(78vw, 260px);
box-sizing: border-box;
padding: clamp(14px, 5vmin, 24px);
background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing;
h2 { margin: 0 0 6px; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0 0 14px; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .65); }
}
.drawer__close {
border: 0; border-radius: $r-control; background: $stage-orange; color: $color;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer { transform: translateX(0); pointer-events: auto; }
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0% { opacity: 0; }
15% { opacity: 1; }
70% { opacity: 1; }
85% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes drawer-loop {
0% { transform: translateX(100%); }
15% { transform: translateX(0); }
70% { transform: translateX(0); }
85% { transform: translateX(100%); }
100% { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer { animation: none; }
.overlay, .drawer { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.trigger {
border: 0;
border-radius: 999px;
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #ff4d1f;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer {
position: fixed;
right: 0;
top: 0;
bottom: 0;
left: auto;
transform: translateX(100%);
width: min(78vw, 260px);
box-sizing: border-box;
padding: clamp(14px, 5vmin, 24px);
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer h2 {
margin: 0 0 6px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.drawer p {
margin: 0 0 14px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.65);
}
.drawer__close {
border: 0;
border-radius: 12px;
background: #ff4d1f;
color: #fff7e6;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .drawer {
transform: translateX(0);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .drawer {
animation: drawer-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes overlay-loop {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
70% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes drawer-loop {
0% {
transform: translateX(100%);
}
15% {
transform: translateX(0);
}
70% {
transform: translateX(0);
}
85% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer {
animation: none;
}
.overlay, .drawer {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var close = document.querySelector('.drawer__close');
function open() { stage.classList.remove('is-demo'); stage.classList.add('is-open'); }
function shut() { stage.classList.remove('is-open'); }
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
close.addEventListener('click', shut);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') shut(); });
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SideDrawer.module.scss";
export type SideDrawerProps = {
triggerLabel: string;
title: string;
description: string;
closeLabel: string;
/** 패널이 밀려 들어오는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SideDrawer({
triggerLabel,
title,
description,
closeLabel,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SideDrawerProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<button type="button" className={styles.trigger} onClick={() => setOpen(true)}>
{triggerLabel}
</button>
<div
className={open ? `${styles.overlay} ${styles.isOpen}` : styles.overlay}
onClick={() => setOpen(false)}
/>
<div
className={open ? `${styles.drawer} ${styles.isOpen}` : styles.drawer}
role="dialog"
aria-modal="true"
>
<h2>{title}</h2>
<p>{description}</p>
<button type="button" className={styles.close} onClick={() => setOpen(false)}>
{closeLabel}
</button>
</div>
</div>
);
}03 · Side drawer — Measured area 100.0% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It reads as a cart or settings panel more than a confirmation, since a drawer implies there's more to browse than a single yes-or-no decision.
.drawer {
position: fixed;
right: 0; top: 0; bottom: 0; left: auto;
transform: translateX(100%);
width: min(78vw, 260px);
transition: transform $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer {
transform: translateX(0);
pointer-events: auto;
}
04Backdrop blur
What it does
The page behind the modal blurs from 0 to 8px on the same timing as the modal's own fade, so the two effects read as a single motion instead of two things firing independently.

<main class="stage is-demo">
<div class="page">
<div class="page__line page__line--a"></div>
<div class="page__line page__line--b"></div>
<div class="page__line page__line--c"></div>
<button class="trigger" type="button">Confirm payment</button>
</div>
<div class="overlay"></div>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="t4">
<h2 id="t4">Pay ₩67,000?</h2>
<p>Take a moment while the background blurs to double-check.</p>
<button class="modal__close" type="button">Pay</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 배경이 흐려지는 시간
$easing: $ease-out;
$color: $subject-blue; // 강조색(버튼)
@include stage($stage-paper);
.stage { position: relative; }
// 뒤 화면 흉내 — 단색 배경만으로는 blur 가 보이지 않아 가짜 콘텐츠를 둔다
.page {
position: absolute; inset: 0; display: grid; place-items: center; gap: clamp(8px, 3vmin, 14px);
}
.page__line {
height: clamp(8px, 3vmin, 12px); border-radius: $r-control; background: rgba($stage-ink, .12);
&--a { width: 55%; } &--b { width: 40%; } &--c { width: 62%; }
}
.trigger {
border: 0; border-radius: $r-pill; margin-top: clamp(8px, 3vmin, 14px);
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-paper; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .28);
backdrop-filter: blur(0px);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing, backdrop-filter $duration $easing;
}
.modal {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%) scale(.94);
opacity: 0; pointer-events: none;
width: min(78vw, 300px);
padding: clamp(12px, 4.5vmin, 22px);
border-radius: $r-card; background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
transition: opacity $duration $easing, transform $duration $easing;
h2 { margin: 0 0 6px; font-size: clamp(13px, 4.4vmin, 18px); }
p { margin: 0 0 14px; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .65); }
}
.modal__close {
border: 0; border-radius: $r-control; background: $color; color: $stage-paper;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; backdrop-filter: blur(8px); pointer-events: auto; }
.stage.is-open .modal { opacity: 1; transform: translate(-50%, -50%) scale(1); pointer-events: auto; }
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .modal { animation: modal-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0% { opacity: 0; backdrop-filter: blur(0px); }
15% { opacity: 1; backdrop-filter: blur(8px); }
70% { opacity: 1; backdrop-filter: blur(8px); }
85% { opacity: 0; backdrop-filter: blur(0px); }
100% { opacity: 0; backdrop-filter: blur(0px); }
}
@keyframes modal-loop {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.94); }
15% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
70% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
85% { opacity: 0; transform: translate(-50%, -50%) scale(.94); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(.94); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .modal { animation: none; }
.overlay, .modal { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.page {
position: absolute;
inset: 0;
display: grid;
place-items: center;
gap: clamp(8px, 3vmin, 14px);
}
.page__line {
height: clamp(8px, 3vmin, 12px);
border-radius: 12px;
background: rgba(23, 20, 26, 0.12);
}
.page__line--a {
width: 55%;
}
.page__line--b {
width: 40%;
}
.page__line--c {
width: 62%;
}
.trigger {
border: 0;
border-radius: 999px;
margin-top: clamp(8px, 3vmin, 14px);
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #ffffff;
background: #2f6df6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.28);
backdrop-filter: blur(0px);
opacity: 0;
pointer-events: none;
transition: opacity 600ms ease-out, backdrop-filter 600ms ease-out;
}
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.94);
opacity: 0;
pointer-events: none;
width: min(78vw, 300px);
padding: clamp(12px, 4.5vmin, 22px);
border-radius: 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: opacity 600ms ease-out, transform 600ms ease-out;
}
.modal h2 {
margin: 0 0 6px;
font-size: clamp(13px, 4.4vmin, 18px);
}
.modal p {
margin: 0 0 14px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.65);
}
.modal__close {
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
backdrop-filter: blur(8px);
pointer-events: auto;
}
.stage.is-open .modal {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .modal {
animation: modal-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0% {
opacity: 0;
backdrop-filter: blur(0px);
}
15% {
opacity: 1;
backdrop-filter: blur(8px);
}
70% {
opacity: 1;
backdrop-filter: blur(8px);
}
85% {
opacity: 0;
backdrop-filter: blur(0px);
}
100% {
opacity: 0;
backdrop-filter: blur(0px);
}
}
@keyframes modal-loop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.94);
}
15% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
70% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
85% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.94);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .modal {
animation: none;
}
.overlay, .modal {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var close = document.querySelector('.modal__close');
function open() { stage.classList.remove('is-demo'); stage.classList.add('is-open'); }
function shut() { stage.classList.remove('is-open'); }
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
close.addEventListener('click', shut);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') shut(); });
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BackdropBlurModal.module.scss";
export type BackdropBlurModalProps = {
triggerLabel: string;
title: string;
description: string;
confirmLabel: string;
/** 배경이 흐려지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BackdropBlurModal({
triggerLabel,
title,
description,
confirmLabel,
duration = 600,
easing = "ease-out",
color = "#2f6df6",
}: BackdropBlurModalProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.page}>
<div className={`${styles.line} ${styles.lineA}`} />
<div className={`${styles.line} ${styles.lineB}`} />
<button type="button" className={styles.trigger} onClick={() => setOpen(true)}>
{triggerLabel}
</button>
</div>
<div
className={open ? `${styles.overlay} ${styles.isOpen}` : styles.overlay}
onClick={() => setOpen(false)}
/>
<div
className={open ? `${styles.modal} ${styles.isOpen}` : styles.modal}
role="dialog"
aria-modal="true"
>
<h2>{title}</h2>
<p>{description}</p>
<button type="button" className={styles.confirm} onClick={() => setOpen(false)}>
{confirmLabel}
</button>
</div>
</div>
);
}04 · Backdrop blur — Measured area 100.0% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
It suits checkout screens and other high-stakes confirms, where blurring the background pulls attention harder than simply darkening it.
.overlay {
backdrop-filter: blur(0px);
opacity: 0;
transition: opacity $duration $easing,
backdrop-filter $duration $easing;
}
.modal {
transform: translate(-50%, -50%) scale(.94);
opacity: 0;
transition: opacity $duration $easing,
transform $duration $easing;
}
.stage.is-open .overlay {
opacity: 1;
backdrop-filter: blur(8px);
}
.stage.is-open .modal {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
05Native dialog
What you see
The <dialog> element opens and closes with a real transition instead of jumping instantly, because @starting-style defines what the "before" frame looks like for properties a browser would otherwise flip in a single tick. transition-behavior: allow-discrete keeps display and the dialog's own overlay property inside that transition rather than switching them the moment showModal() runs.

<main class="stage">
<button class="trigger" type="button">Open again</button>
<div class="overlay is-demo"></div>
<dialog class="modal is-demo" open aria-labelledby="t5">
<h2 id="t5">Save your changes?</h2>
<p>The dialog traps focus automatically when it opens and closes.</p>
<button class="modal__close" type="button">Close</button>
</dialog>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 대화상자가 뜨는 시간
$easing: $ease-out;
$color: $subject-cream; // 강조색(버튼)
@include stage($stage-ink);
.stage { position: relative; }
.trigger {
border: 0; border-radius: $r-pill;
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-ink; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .55);
opacity: 0; pointer-events: none;
}
// 진짜 showModal() 로 열렸을 때만 브라우저가 만드는 배경 — 우리 overlay 와 겹치지 않게 투명하게 둔다
dialog.modal::backdrop { background: transparent; }
dialog.modal {
border: 0; margin: auto;
position: fixed; inset: 0;
width: min(78vw, 300px); height: fit-content;
padding: clamp(12px, 4.5vmin, 22px);
border-radius: $r-card; background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
opacity: 0; transform: scale(.92) translateY(10px);
h2 { margin: 0 0 6px; font-size: clamp(13px, 4.4vmin, 18px); }
p { margin: 0 0 14px; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .65); }
// 실제 open 전환: display·overlay 가 discrete 라 @starting-style 없이는 즉시 나타난다
transition: opacity $duration $easing, transform $duration $easing,
overlay $duration allow-discrete, display $duration allow-discrete;
&[open] { opacity: 1; transform: none; }
@starting-style { &[open] { opacity: 0; transform: scale(.92) translateY(10px); } }
}
.modal__close {
border: 0; border-radius: $r-control; background: $subject-blue; color: $stage-paper;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
// 데모: open 속성은 그대로 두고 keyframes 로만 접었다 편다
.overlay.is-demo { animation: overlay-loop $dur-loop ease-in-out infinite; }
dialog.modal.is-demo { animation: dialog-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0% { opacity: 0; }
15% { opacity: 1; }
70% { opacity: 1; }
85% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes dialog-loop {
0% { opacity: 0; transform: scale(.92) translateY(10px); }
15% { opacity: 1; transform: scale(1) translateY(0); }
70% { opacity: 1; transform: scale(1) translateY(0); }
85% { opacity: 0; transform: scale(.92) translateY(10px); }
100% { opacity: 0; transform: scale(.92) translateY(10px); }
}
@media (prefers-reduced-motion: reduce) {
.overlay.is-demo, dialog.modal.is-demo { animation: none; }
dialog.modal { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.trigger {
border: 0;
border-radius: 999px;
padding: clamp(10px, 3.4vmin, 14px) clamp(20px, 7vmin, 30px);
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.55);
opacity: 0;
pointer-events: none;
}
dialog.modal::backdrop {
background: transparent;
}
dialog.modal {
border: 0;
margin: auto;
position: fixed;
inset: 0;
width: min(78vw, 300px);
height: fit-content;
padding: clamp(12px, 4.5vmin, 22px);
border-radius: 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: scale(0.92) translateY(10px);
}
dialog.modal h2 {
margin: 0 0 6px;
font-size: clamp(13px, 4.4vmin, 18px);
}
dialog.modal p {
margin: 0 0 14px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.65);
}
dialog.modal {
transition: opacity 300ms ease-out, transform 300ms ease-out, overlay 300ms allow-discrete, display 300ms allow-discrete;
}
dialog.modal[open] {
opacity: 1;
transform: none;
}
@starting-style {
dialog.modal[open] {
opacity: 0;
transform: scale(0.92) translateY(10px);
}
}
.modal__close {
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.overlay.is-demo {
animation: overlay-loop 2s ease-in-out infinite;
}
dialog.modal.is-demo {
animation: dialog-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
70% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dialog-loop {
0% {
opacity: 0;
transform: scale(0.92) translateY(10px);
}
15% {
opacity: 1;
transform: scale(1) translateY(0);
}
70% {
opacity: 1;
transform: scale(1) translateY(0);
}
85% {
opacity: 0;
transform: scale(0.92) translateY(10px);
}
100% {
opacity: 0;
transform: scale(0.92) translateY(10px);
}
}
@media (prefers-reduced-motion: reduce) {
.overlay.is-demo, dialog.modal.is-demo {
animation: none;
}
dialog.modal {
transition-duration: 1ms;
}
}// 데모는 open 속성이 그대로 켜진 <dialog> 를 keyframes 로 접었다 편다.
// 실제 클릭은 진짜 close()/showModal() 을 불러 top layer·::backdrop·포커스 트랩까지 켠다.
(function () {
var stage = document.querySelector('.stage');
var overlay = document.querySelector('.overlay');
var dialog = document.querySelector('.modal');
var trigger = document.querySelector('.trigger');
var close = document.querySelector('.modal__close');
function stopDemo() { stage.classList.add('is-real'); overlay.classList.remove('is-demo'); dialog.classList.remove('is-demo'); }
close.addEventListener('click', function () { stopDemo(); dialog.close(); });
trigger.addEventListener('click', function () { stopDemo(); dialog.showModal(); });
dialog.addEventListener('cancel', function () { stopDemo(); });
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NativeDialog.module.scss";
export type NativeDialogProps = {
triggerLabel: string;
title: string;
description: string;
closeLabel: string;
/** 대화상자가 뜨는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function NativeDialog({
triggerLabel,
title,
description,
closeLabel,
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: NativeDialogProps) {
const ref = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
const el = ref.current;
if (!el) return;
if (open && !el.open) el.showModal();
if (!open && el.open) el.close();
}, [open]);
return (
<div className={styles.stage} style={vars}>
<button type="button" className={styles.trigger} onClick={() => setOpen(true)}>
{triggerLabel}
</button>
<dialog
ref={ref}
className={styles.modal}
onCancel={() => setOpen(false)}
onClose={() => setOpen(false)}
>
<h2>{title}</h2>
<p>{description}</p>
<button type="button" className={styles.close} onClick={() => setOpen(false)}>
{closeLabel}
</button>
</dialog>
</div>
);
}05 · Native dialog — Measured area 25.228% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
dialog.modal {
opacity: 0;
transform: scale(.92) translateY(10px);
transition: opacity $duration $easing,
transform $duration $easing,
overlay $duration allow-discrete,
display $duration allow-discrete;
&[open] { opacity: 1; transform: none; }
@starting-style {
&[open] { opacity: 0; transform: scale(.92) translateY(10px); }
}
}
06Popover confirm
The motion
A small confirm bubble appears directly above the delete button it belongs to, positioned in the top layer once the Popover API attaches it, with no arrow pointing back at the trigger.

<main class="stage">
<div class="anchor is-demo">
<button class="trigger" type="button" aria-label="Delete">Delete</button>
<div class="confirm" role="dialog" aria-label="Confirm delete">
<p>Delete this for good?</p>
<div class="confirm__row">
<button class="confirm__yes" type="button">Delete</button>
<button class="confirm__no" type="button">Cancel</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 뜨는 시간
$easing: $ease-pop; // 살짝 튀며 나타난다
$color: $subject-ink; // 강조색(버튼)
@include stage($stage-yellow);
.anchor { position: relative; display: inline-block; }
.trigger {
border: 0; border-radius: $r-control;
padding: clamp(9px, 3vmin, 12px) clamp(16px, 5.4vmin, 22px);
font-weight: 600;
font-size: clamp(12px, 3.6vmin, 15px);
line-height: 1;
font-family: inherit;
color: $stage-yellow; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
// popover 속성이 없을 때(데모 기본값)는 평범한 상자로 그린다 — 화살표 없이, 버튼 바로 위
.confirm {
position: absolute; left: 50%; bottom: calc(100% + 10px);
transform: translateX(-50%) translateY(6px) scale(.92);
opacity: 0; pointer-events: none;
width: clamp(140px, 46vmin, 190px);
padding: clamp(10px, 3.4vmin, 14px);
border-radius: $r-card; background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
transition: opacity $duration $easing, transform $duration $easing;
margin: 0; border: 0;
p { margin: 0 0 10px; font-size: clamp(10px, 3.2vmin, 12px); }
}
// 진짜 Popover API 로 켜졌을 때는 top layer 위에서 같은 자리에 뜨게 좌표만 다시 잡는다
.confirm:popover-open {
position: fixed; inset: auto;
opacity: 1; transform: translateX(-50%) translateY(0) scale(1);
}
.confirm__row { display: flex; gap: clamp(6px, 2vmin, 8px); }
.confirm__yes, .confirm__no {
flex: 1; border: 0; border-radius: $r-control;
padding: clamp(6px, 2.2vmin, 8px) 0;
font-weight: 600;
font-size: clamp(10px, 3vmin, 12px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.confirm__yes { background: $error; color: $stage-paper; }
.confirm__no { background: $error-soft; color: $error; }
.anchor.is-demo .confirm { animation: confirm-loop $dur-loop $easing infinite; }
@keyframes confirm-loop {
0% { opacity: 0; transform: translateX(-50%) translateY(6px) scale(.92); }
15% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
70% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
85% { opacity: 0; transform: translateX(-50%) translateY(6px) scale(.92); }
100% { opacity: 0; transform: translateX(-50%) translateY(6px) scale(.92); }
}
@media (prefers-reduced-motion: reduce) {
.anchor.is-demo .confirm { animation: none; }
.confirm { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.anchor {
position: relative;
display: inline-block;
}
.trigger {
border: 0;
border-radius: 12px;
padding: clamp(9px, 3vmin, 12px) clamp(16px, 5.4vmin, 22px);
font-weight: 600;
font-size: clamp(12px, 3.6vmin, 15px);
line-height: 1;
font-family: inherit;
color: #ffd23f;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.confirm {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
transform: translateX(-50%) translateY(6px) scale(0.92);
opacity: 0;
pointer-events: none;
width: clamp(140px, 46vmin, 190px);
padding: clamp(10px, 3.4vmin, 14px);
border-radius: 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
margin: 0;
border: 0;
}
.confirm p {
margin: 0 0 10px;
font-size: clamp(10px, 3.2vmin, 12px);
}
.confirm:popover-open {
position: fixed;
inset: auto;
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
.confirm__row {
display: flex;
gap: clamp(6px, 2vmin, 8px);
}
.confirm__yes, .confirm__no {
flex: 1;
border: 0;
border-radius: 12px;
padding: clamp(6px, 2.2vmin, 8px) 0;
font-weight: 600;
font-size: clamp(10px, 3vmin, 12px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.confirm__yes {
background: #c2361a;
color: #ffffff;
}
.confirm__no {
background: #ffe0d8;
color: #c2361a;
}
.anchor.is-demo .confirm {
animation: confirm-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes confirm-loop {
0% {
opacity: 0;
transform: translateX(-50%) translateY(6px) scale(0.92);
}
15% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
70% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
85% {
opacity: 0;
transform: translateX(-50%) translateY(6px) scale(0.92);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(6px) scale(0.92);
}
}
@media (prefers-reduced-motion: reduce) {
.anchor.is-demo .confirm {
animation: none;
}
.confirm {
transition-duration: 1ms;
}
}// 데모는 anchor 에 .is-demo 를 걸어 keyframes 로 반복. 실제 클릭은 진짜 Popover API 로 전환한다.
(function () {
var anchor = document.querySelector('.anchor');
var trigger = document.querySelector('.trigger');
var confirm = document.querySelector('.confirm');
var yes = document.querySelector('.confirm__yes');
var no = document.querySelector('.confirm__no');
function stopDemo() { anchor.classList.remove('is-demo'); }
function show() {
stopDemo();
if (!confirm.hasAttribute('popover')) confirm.setAttribute('popover', 'manual');
confirm.showPopover();
}
function hide() { if (confirm.hasAttribute('popover')) confirm.hidePopover(); }
trigger.addEventListener('click', show);
no.addEventListener('click', hide);
yes.addEventListener('click', hide);
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PopoverConfirm.module.scss";
export type PopoverConfirmProps = {
triggerLabel: string;
question: string;
confirmLabel: string;
cancelLabel: string;
onConfirm?: () => void;
/** 뜨는 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PopoverConfirm({
triggerLabel,
question,
confirmLabel,
cancelLabel,
onConfirm,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: PopoverConfirmProps) {
const ref = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function show() {
const el = ref.current as (HTMLDivElement & { showPopover?: () => void }) | null;
if (el) {
if (!el.hasAttribute("popover")) el.setAttribute("popover", "manual");
el.showPopover?.();
}
setOpen(true);
}
function hide() {
const el = ref.current as (HTMLDivElement & { hidePopover?: () => void }) | null;
el?.hidePopover?.();
setOpen(false);
}
return (
<div className={styles.anchor} style={vars}>
<button type="button" className={styles.trigger} onClick={show}>
{triggerLabel}
</button>
<div ref={ref} className={open ? `${styles.confirm} ${styles.isOpen}` : styles.confirm}>
<p>{question}</p>
<div className={styles.row}>
<button
type="button"
className={styles.yes}
onClick={() => {
onConfirm?.();
hide();
}}
>
{confirmLabel}
</button>
<button type="button" className={styles.no} onClick={hide}>
{cancelLabel}
</button>
</div>
</div>
</div>
);
}06 · Popover confirm — Measured area 10.85% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It stands in for a full modal on low-stakes confirms like deleting a single row, where an entire overlay would be a lot of ceremony for a yes-or-no question.
.confirm {
position: absolute;
left: 50%; bottom: calc(100% + 10px);
transform: translateX(-50%) translateY(6px) scale(.92);
opacity: 0;
transition: opacity $duration $easing,
transform $duration $easing;
}
.confirm:popover-open {
position: fixed;
inset: auto;
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
07Fullscreen takeover
What it does
The thumbnail that was pressed grows into a fullscreen modal over roughly 420ms without its width or height ever changing — the whole effect is one transform: scale running backward from a squashed corner to full size. A caption and a close button fade in only once that scale-up finishes, so the gallery tile stays a clean thumbnail until it's genuinely open.

<main class="stage is-demo">
<button class="thumb" type="button" aria-label="View photo full size">
<span class="thumb__caption">Golden Retriever · 2026</span>
<span class="thumb__close" aria-hidden="true">×</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 커지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 강조색(캡션·닫기)
@include stage($stage-orange);
// 실제로는 width/height 를 바꾸지 않는다. inset:0 인 상자를 transform: scale 로만 줄여
// "작은 썸네일" 처럼 보이게 한다 — 커질 때도 레이아웃 재계산이 없다
.thumb {
position: fixed; inset: 0; margin: 0; padding: 0; border: 0;
border-radius: $r-card; overflow: hidden; cursor: pointer;
background: linear-gradient(160deg, $subject-cream, $stage-paper);
box-shadow: $shadow-raised;
transform: translate(-30%, 20%) scale(.3);
transform-origin: 20% 75%; // 규칙8: 썸네일 자리 앵커 확대
transition: transform $duration $easing, border-radius $duration $easing;
-webkit-tap-highlight-color: transparent;
}
.thumb__caption {
position: absolute; left: clamp(10px, 3vmin, 16px); bottom: clamp(10px, 3vmin, 16px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 14px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
opacity: 0; transition: opacity $duration $easing;
}
.thumb__close {
position: absolute; right: clamp(10px, 3vmin, 16px); top: clamp(10px, 3vmin, 16px);
width: clamp(20px, 6vmin, 26px); height: clamp(20px, 6vmin, 26px);
display: grid; place-items: center; border-radius: 50%;
background: rgba($stage-ink, .55); color: $color; font-size: clamp(13px, 3.8vmin, 16px);
opacity: 0; transition: opacity $duration $easing;
}
.stage.is-open .thumb {
transform: translate(0, 0) scale(1);
border-radius: 0;
}
.stage.is-open .thumb__caption,
.stage.is-open .thumb__close { opacity: 1; }
.stage.is-demo .thumb { animation: thumb-loop $dur-loop $easing infinite; }
.stage.is-demo .thumb__caption,
.stage.is-demo .thumb__close { animation: fade-loop $dur-loop ease-in-out infinite; }
@keyframes thumb-loop {
0% { transform: translate(-30%, 20%) scale(.3); border-radius: $r-card; }
20% { transform: translate(0, 0) scale(1); border-radius: 0; }
65% { transform: translate(0, 0) scale(1); border-radius: 0; }
85% { transform: translate(-30%, 20%) scale(.3); border-radius: $r-card; }
100% { transform: translate(-30%, 20%) scale(.3); border-radius: $r-card; }
}
@keyframes fade-loop {
0% { opacity: 0; }
20% { opacity: 1; }
65% { opacity: 1; }
85% { opacity: 0; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .thumb,
.stage.is-demo .thumb__caption,
.stage.is-demo .thumb__close { animation: none; }
.thumb, .thumb__caption, .thumb__close { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.thumb {
position: fixed;
inset: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
background: linear-gradient(160deg, #fff7e6, #ffffff);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(-30%, 20%) scale(0.3);
transform-origin: 20% 75%;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), border-radius 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
-webkit-tap-highlight-color: transparent;
}
.thumb__caption {
position: absolute;
left: clamp(10px, 3vmin, 16px);
bottom: clamp(10px, 3vmin, 16px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 14px);
line-height: 1;
font-family: inherit;
color: #17141a;
opacity: 0;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.thumb__close {
position: absolute;
right: clamp(10px, 3vmin, 16px);
top: clamp(10px, 3vmin, 16px);
width: clamp(20px, 6vmin, 26px);
height: clamp(20px, 6vmin, 26px);
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(23, 20, 26, 0.55);
color: #fff7e6;
font-size: clamp(13px, 3.8vmin, 16px);
opacity: 0;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.stage.is-open .thumb {
transform: translate(0, 0) scale(1);
border-radius: 0;
}
.stage.is-open .thumb__caption,
.stage.is-open .thumb__close {
opacity: 1;
}
.stage.is-demo .thumb {
animation: thumb-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .thumb__caption,
.stage.is-demo .thumb__close {
animation: fade-loop 2s ease-in-out infinite;
}
@keyframes thumb-loop {
0% {
transform: translate(-30%, 20%) scale(0.3);
border-radius: 16px;
}
20% {
transform: translate(0, 0) scale(1);
border-radius: 0;
}
65% {
transform: translate(0, 0) scale(1);
border-radius: 0;
}
85% {
transform: translate(-30%, 20%) scale(0.3);
border-radius: 16px;
}
100% {
transform: translate(-30%, 20%) scale(0.3);
border-radius: 16px;
}
}
@keyframes fade-loop {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
65% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .thumb,
.stage.is-demo .thumb__caption,
.stage.is-demo .thumb__close {
animation: none;
}
.thumb, .thumb__caption, .thumb__close {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var thumb = document.querySelector('.thumb');
var isOpen = false;
thumb.addEventListener('click', function () {
stage.classList.remove('is-demo');
isOpen = !isOpen;
stage.classList.toggle('is-open', isOpen);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen) { isOpen = false; stage.classList.remove('is-open'); }
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FullscreenTakeover.module.scss";
export type FullscreenTakeoverProps = {
caption: string;
/** 커지는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FullscreenTakeover({
caption,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: FullscreenTakeoverProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={open ? `${styles.thumb} ${styles.isOpen}` : styles.thumb}
aria-label={caption}
onClick={() => setOpen((v) => !v)}
>
<span className={styles.caption}>{caption}</span>
<span className={styles.close} aria-hidden="true">×</span>
</button>
</div>
);
}07 · Fullscreen takeover — Measured area 96.809% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
.thumb {
position: fixed; inset: 0;
border-radius: $r-card;
transform: translate(-30%, 20%) scale(.3);
transform-origin: 20% 75%;
transition: transform $duration $easing,
border-radius $duration $easing;
}
.stage.is-open .thumb {
transform: translate(0, 0) scale(1);
border-radius: 0;
}
.thumb__caption, .thumb__close {
opacity: 0;
transition: opacity $duration $easing;
}
.stage.is-open .thumb__caption,
.stage.is-open .thumb__close { opacity: 1; }
08Shake on outside click
What you see
Pressing outside a required modal doesn't close it — the modal's border flashes red and the whole box shakes side to side twice, then settles back to its resting border color.

<main class="stage is-demo">
<div class="overlay"></div>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="t8">
<h2 id="t8">You need to accept the terms to continue</h2>
<p>Clicking outside won't close it — it just shakes a little instead.</p>
<button class="modal__accept" type="button">Agree and continue</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 흔들림 지속 시간
$easing: $ease-spring; // 실제 흔들림엔 별도 cubic-bezier 를 쓴다(아래)
$color: $subject-ink; // 강조색(버튼)
@include stage($stage-paper);
.stage { position: relative; }
// 닫을 수 없는 모달이라 열린 채로 고정. 오버레이는 어둡게만 유지한다
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .45);
}
.modal {
position: fixed; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: min(80vw, 300px);
padding: clamp(12px, 4.5vmin, 22px);
border-radius: $r-card; background: $stage-paper; color: $stage-ink;
border: 2px solid rgba($stage-ink, .12);
box-shadow: $shadow-raised;
h2 { margin: 0 0 6px; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0 0 14px; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .65); }
}
.modal__accept {
border: 0; border-radius: $r-control; background: $color; color: $stage-paper;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.modal.is-shaking {
animation: shake $duration cubic-bezier(.36, .07, .19, .97) both;
border-color: $error;
}
@keyframes shake {
10%, 90% { transform: translate(calc(-50% - 3px), -50%); }
20%, 80% { transform: translate(calc(-50% + 4px), -50%); border-color: $error; }
30%, 70% { transform: translate(calc(-50% - 4px), -50%); }
40%, 60% { transform: translate(calc(-50% + 3px), -50%); }
50% { transform: translate(calc(-50% - 3px), -50%); border-color: $error; }
}
// 데모: 2초마다 두 번씩 흔든다
.stage.is-demo .modal { animation: shake-loop $dur-loop cubic-bezier(.36, .07, .19, .97) infinite; }
@keyframes shake-loop {
0%, 40%, 100% { transform: translate(-50%, -50%); border-color: rgba($stage-ink, .12); }
44% { transform: translate(calc(-50% - 4px), -50%); border-color: $error; }
48% { transform: translate(calc(-50% + 4px), -50%); }
52% { transform: translate(calc(-50% - 4px), -50%); border-color: $error; }
56% { transform: translate(calc(-50% + 3px), -50%); }
60% { transform: translate(-50%, -50%); border-color: rgba($stage-ink, .12); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .modal, .modal.is-shaking { animation: none; }
.modal.is-shaking { border-color: $error; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.45);
}
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(80vw, 300px);
padding: clamp(12px, 4.5vmin, 22px);
border-radius: 16px;
background: #ffffff;
color: #17141a;
border: 2px solid rgba(23, 20, 26, 0.12);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.modal h2 {
margin: 0 0 6px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.modal p {
margin: 0 0 14px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.65);
}
.modal__accept {
border: 0;
border-radius: 12px;
background: #17141a;
color: #ffffff;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.modal.is-shaking {
animation: shake 300ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
border-color: #c2361a;
}
@keyframes shake {
10%, 90% {
transform: translate(calc(-50% - 3px), -50%);
}
20%, 80% {
transform: translate(calc(-50% + 4px), -50%);
border-color: #c2361a;
}
30%, 70% {
transform: translate(calc(-50% - 4px), -50%);
}
40%, 60% {
transform: translate(calc(-50% + 3px), -50%);
}
50% {
transform: translate(calc(-50% - 3px), -50%);
border-color: #c2361a;
}
}
.stage.is-demo .modal {
animation: shake-loop 2s cubic-bezier(0.36, 0.07, 0.19, 0.97) infinite;
}
@keyframes shake-loop {
0%, 40%, 100% {
transform: translate(-50%, -50%);
border-color: rgba(23, 20, 26, 0.12);
}
44% {
transform: translate(calc(-50% - 4px), -50%);
border-color: #c2361a;
}
48% {
transform: translate(calc(-50% + 4px), -50%);
}
52% {
transform: translate(calc(-50% - 4px), -50%);
border-color: #c2361a;
}
56% {
transform: translate(calc(-50% + 3px), -50%);
}
60% {
transform: translate(-50%, -50%);
border-color: rgba(23, 20, 26, 0.12);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .modal, .modal.is-shaking {
animation: none;
}
.modal.is-shaking {
border-color: #c2361a;
}
}// 실제 클릭: 바깥(overlay)을 누르면 닫히는 대신 흔들린다.
(function () {
var stage = document.querySelector('.stage');
var overlay = document.querySelector('.overlay');
var modal = document.querySelector('.modal');
overlay.addEventListener('click', function () {
stage.classList.remove('is-demo');
modal.classList.remove('is-shaking');
void modal.offsetWidth;
modal.classList.add('is-shaking');
});
modal.addEventListener('animationend', function (e) {
if (e.animationName === 'shake') modal.classList.remove('is-shaking');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShakeOutsideModal.module.scss";
export type ShakeOutsideModalProps = {
title: string;
description: string;
acceptLabel: string;
onAccept?: () => void;
/** 흔들림 지속 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ShakeOutsideModal({
title,
description,
acceptLabel,
onAccept,
duration = 300,
easing = "cubic-bezier(.36,.07,.19,.97)",
color = "#17141a",
}: ShakeOutsideModalProps) {
const [shakeKey, setShakeKey] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.overlay} onClick={() => setShakeKey((k) => k + 1)} />
<div
key={shakeKey}
className={shakeKey ? `${styles.modal} ${styles.isShaking}` : styles.modal}
role="dialog"
aria-modal="true"
>
<h2>{title}</h2>
<p>{description}</p>
<button type="button" className={styles.accept} onClick={onAccept}>
{acceptLabel}
</button>
</div>
</div>
);
}08 · Shake on outside click — Measured area 5.663% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It's built for consent screens and required fields, where the interaction has to say "you can't skip this" without any extra text popping up to say so out loud.
.modal.is-shaking {
animation: shake $duration cubic-bezier(.36, .07, .19, .97) both;
border-color: $error;
}
@keyframes shake {
10%, 90% { transform: translate(calc(-50% - 3px), -50%); }
20%, 80% { transform: translate(calc(-50% + 4px), -50%); border-color: $error; }
30%, 70% { transform: translate(calc(-50% - 4px), -50%); }
40%, 60% { transform: translate(calc(-50% + 3px), -50%); }
50% { transform: translate(calc(-50% - 3px), -50%); border-color: $error; }
}
09Stacked modals
The motion
Opening a second modal doesn't hide the first one — it pushes it back 16px, scales it down to 0.95, and drops its opacity to 0.55, so the first screen stays visible as context sitting behind the second.

<main class="stage is-demo">
<div class="overlay"></div>
<div class="modal modal--1" role="dialog" aria-modal="true" aria-labelledby="t9a">
<h2 id="t9a">Enter shipping address</h2>
<p>Enter your address, then look up the postal code.</p>
<button class="modal__next" type="button">Look up postal code</button>
</div>
<div class="modal modal--2" role="dialog" aria-modal="true" aria-labelledby="t9b">
<h2 id="t9b">Look up postal code</h2>
<p>Searching by street name is faster.</p>
<button class="modal__back" type="button">Use this address</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 두 번째 모달이 뜨는 시간
$easing: $ease-spring;
$color: $subject-blue; // 강조색(버튼)
@include stage($stage-ink);
.stage { position: relative; }
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .35);
transition: background $duration $easing;
}
.modal {
position: fixed; left: 50%; top: 50%;
width: min(78vw, 300px);
padding: clamp(12px, 4.5vmin, 22px);
border-radius: $r-card; background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
h2 { margin: 0 0 6px; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0 0 14px; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .65); }
}
.modal__next, .modal__back {
border: 0; border-radius: $r-control; background: $color; color: $stage-paper;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
// 1번 모달: 평소엔 정면, 2번이 열리면 뒤로 물러나 작아진다
.modal--1 {
transform: translate(-50%, -50%);
transition: transform $duration $easing, opacity $duration $easing;
}
// 2번 모달: 평소엔 숨어 있다가 열리면 앞으로
.modal--2 {
transform: translate(-50%, -50%) translateY(10px) scale(.9);
opacity: 0; pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.stage.is-stacked .overlay { background: rgba($stage-ink, .6); }
.stage.is-stacked .modal--1 { transform: translate(-50%, calc(-50% - 16px)) scale(.95); opacity: .55; }
.stage.is-stacked .modal--2 { transform: translate(-50%, -50%) translateY(0) scale(1); opacity: 1; pointer-events: auto; }
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .modal--1 { animation: back-loop $dur-loop $easing infinite; }
.stage.is-demo .modal--2 { animation: front-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0%, 40% { background: rgba($stage-ink, .35); }
55%, 80% { background: rgba($stage-ink, .6); }
95%, 100% { background: rgba($stage-ink, .35); }
}
@keyframes back-loop {
0%, 40% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
55%, 80% { transform: translate(-50%, calc(-50% - 16px)) scale(.95); opacity: .55; }
95%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
@keyframes front-loop {
0%, 40% { transform: translate(-50%, -50%) translateY(10px) scale(.9); opacity: 0; }
55%, 80% { transform: translate(-50%, -50%) translateY(0) scale(1); opacity: 1; }
95%, 100% { transform: translate(-50%, -50%) translateY(10px) scale(.9); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .modal--1, .stage.is-demo .modal--2 { animation: none; }
.overlay, .modal--1, .modal--2 { transition-duration: 1ms; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.35);
transition: background 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.modal {
position: fixed;
left: 50%;
top: 50%;
width: min(78vw, 300px);
padding: clamp(12px, 4.5vmin, 22px);
border-radius: 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.modal h2 {
margin: 0 0 6px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.modal p {
margin: 0 0 14px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.65);
}
.modal__next, .modal__back {
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
padding: clamp(7px, 2.6vmin, 9px) clamp(14px, 4.6vmin, 18px);
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.modal--1 {
transform: translate(-50%, -50%);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.modal--2 {
transform: translate(-50%, -50%) translateY(10px) scale(0.9);
opacity: 0;
pointer-events: none;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.stage.is-stacked .overlay {
background: rgba(23, 20, 26, 0.6);
}
.stage.is-stacked .modal--1 {
transform: translate(-50%, calc(-50% - 16px)) scale(0.95);
opacity: 0.55;
}
.stage.is-stacked .modal--2 {
transform: translate(-50%, -50%) translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .modal--1 {
animation: back-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .modal--2 {
animation: front-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes overlay-loop {
0%, 40% {
background: rgba(23, 20, 26, 0.35);
}
55%, 80% {
background: rgba(23, 20, 26, 0.6);
}
95%, 100% {
background: rgba(23, 20, 26, 0.35);
}
}
@keyframes back-loop {
0%, 40% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
55%, 80% {
transform: translate(-50%, calc(-50% - 16px)) scale(0.95);
opacity: 0.55;
}
95%, 100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
@keyframes front-loop {
0%, 40% {
transform: translate(-50%, -50%) translateY(10px) scale(0.9);
opacity: 0;
}
55%, 80% {
transform: translate(-50%, -50%) translateY(0) scale(1);
opacity: 1;
}
95%, 100% {
transform: translate(-50%, -50%) translateY(10px) scale(0.9);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .modal--1, .stage.is-demo .modal--2 {
animation: none;
}
.overlay, .modal--1, .modal--2 {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var next = document.querySelector('.modal__next');
var back = document.querySelector('.modal__back');
next.addEventListener('click', function () { stage.classList.remove('is-demo'); stage.classList.add('is-stacked'); });
back.addEventListener('click', function () { stage.classList.remove('is-stacked'); });
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StackedModals.module.scss";
export type StackedModalsProps = {
firstTitle: string;
firstDescription: string;
nextLabel: string;
secondTitle: string;
secondDescription: string;
backLabel: string;
/** 두 번째 모달이 뜨는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StackedModals({
firstTitle,
firstDescription,
nextLabel,
secondTitle,
secondDescription,
backLabel,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: StackedModalsProps) {
const [stacked, setStacked] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={stacked ? `${styles.overlay} ${styles.isStacked}` : styles.overlay} />
<div className={stacked ? `${styles.modal} ${styles.back}` : styles.modal}>
<h2>{firstTitle}</h2>
<p>{firstDescription}</p>
<button type="button" className={styles.next} onClick={() => setStacked(true)}>
{nextLabel}
</button>
</div>
<div className={stacked ? `${styles.modal2} ${styles.isOpen}` : styles.modal2}>
<h2>{secondTitle}</h2>
<p>{secondDescription}</p>
<button type="button" className={styles.back2} onClick={() => setStacked(false)}>
{backLabel}
</button>
</div>
</div>
);
}09 · Stacked modals — Measured area 28.221% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It's built for multi-step flows like an address form followed by a postal-code lookup, where losing the first screen entirely would make someone forget what they were filling in.
.modal--1 {
transform: translate(-50%, -50%);
transition: transform $duration $easing,
opacity $duration $easing;
}
.modal--2 {
transform: translate(-50%, -50%) translateY(10px) scale(.9);
opacity: 0;
transition: transform $duration $easing,
opacity $duration $easing;
}
.stage.is-stacked .modal--1 {
transform: translate(-50%, calc(-50% - 16px)) scale(.95);
opacity: .55;
}
.stage.is-stacked .modal--2 {
transform: translate(-50%, -50%) translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
Where it breaks — two traps
Why 05 needs @starting-style
Item 05's css modal open animation only works because two things come together: @starting-style, which defines the "before" frame for properties a browser would otherwise flip in an instant, and allow-discrete on the display and overlay properties, which keeps them inside the transition instead of switching the moment showModal() fires. Drop either one and the dialog still opens — it just snaps into place with no animation at all, since display and the top-layer overlay property are discrete by default and skip any transition that doesn't explicitly opt them in. Support is recent enough to matter here: native <dialog> itself works from Chrome 37, Safari 15.4, and Firefox 98, per MDN's dialog element reference, but @starting-style needs Chrome 117, Safari 17.5, or Firefox 129, per MDN's starting-style page — an older Firefox still opens the dialog, it just skips the fade.
Trap two — the preview loop
The second trap sits in the demo markup, not the CSS itself: every tile in the grid runs on an .is-demo class and a set of loop keyframes that exist only to animate the preview automatically, entirely separate from the real .is-open, [open], and :popover-open styles a genuine click triggers. Copy the .is-demo rules straight into a production build and the modal keeps looping every two seconds no matter what a visitor does, since none of those keyframes listen for a real interaction — the archive's password is udnptj2b if you'd rather see where the demo loop ends and the real behavior begins than guess from the SCSS alone.
Popover support
Item 06's Popover API carries its own version gap: Chrome shipped it at 114, Safari at 17, and Firefox trailed to 125, per MDN's Popover API guide, and the confirm bubble only gets top-layer positioning and the :popover-open styling once the popover attribute actually lands on the element — this demo sets it with JavaScript on the first click rather than baking it into the markup up front. When a stack like 09 should also read in time rather than only in depth, the delay math in the CSS stagger animation demos drops in unchanged to space the two dialogs apart.
Accessibility
Reduced motion
None of the nine reduce motion the same way, because "reduced" means something different for a shake than it does for a fade. 01 through 04 drop their entrance transition entirely and are simply open or closed, without the scale, slide, or blur ever running. 07's thumbnail-to-fullscreen transform collapses to an instant swap too, and its caption and close button appear right away instead of fading in a beat later. 08 changes behavior rather than just speed: the shake keyframes disappear and the border switches straight to red, so the modal still visibly refuses to close without any motion doing the telling. 09 still changes scale and opacity when a second modal opens, only in 1ms instead of across the spring easing, so the stacking is still legible without the animation. MDN's guide to the prefers-reduced-motion media feature has the current browser support numbers.
More css popup animation examples and overlay patterns sit under the CSS section, and the about page covers why every post here ships a working demo instead of a screenshot.
FAQ
Does the native dialog element make every css dialog animation here redundant?
Mostly, for anything that already behaves like a real dialog. <dialog> with showModal() gives a focus trap, Escape-to-close, and a ::backdrop for free, all of which 01 through 04 build by hand with a plain div and a keydown listener. It doesn't help with 02's handle bar, 07's thumbnail transform, or 09's second dialog stacking behind the first, since those are layout choices <dialog> has no opinion on either way. The one control most likely to sit beside 03's drawer is the menu button that opens it, and the CSS hamburger menu animations cover matching that icon's open-and-close to the panel's.
Why does 05 combine a transition with @starting-style instead of plain keyframes?
A looping @keyframes animation, like the one driving the grid preview, can't respond to a real showModal() or close() call because it has no idea when either one happens. A transition on [open] does know, but display and the browser's overlay property are discrete, so without @starting-style and allow-discrete the browser just swaps them at the edge of the transition instead of animating across it. Keyframes and transitions are solving different problems here — looping on a timer versus responding to a real state change.
Will these work with Tailwind or inside a React build?
Yes — the motion lives entirely in transitions, custom properties, and one @starting-style block, none of which conflict with Tailwind's utility classes or a component's own class names. The zip's react/ folder wraps each pattern in a component that takes the same three variables as props, so switching frameworks doesn't mean rewriting any of the animation.









