CSS 모달 팝업 애니메이션 9종 — JS 최소, @starting-style로
CSS 모달 팝업 애니메이션 아홉 개를 한 번에 모았습니다. 확인창부터 바텀 시트, 팝오버, 겹쳐 열리는 모달까지 실무에서 자주 쓰는 장면을 아홉 가지로 나눠 담았고, 전부 transform과 opacity만(배경 흐림은 예외) 움직입니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 페이드 + 확대
- 02 바텀 시트
- 03 사이드 드로어
- 04 배경 흐리기
- 05 네이티브 dialog
- 06 팝오버 확인창
- 07 버튼에서 전체화면
- 08 바깥 클릭 시 흔들림
- 09 겹쳐 열리는 모달
아홉 개는 흔히 보는 목록이 아니라 모달이 화면에 나타나는 방식을 기준으로 놓았습니다. 가운데서 뜨는 기본형부터(01~04), 브라우저 기능으로 여는 것(05~06), 화면을 통째로 덮는 것(07), 닫지 못하게 막는 것(08), 마지막으로 모달 위에 모달이 쌓이는 경우(09) 순입니다. 각 항목에는 동작을 보여주는 핵심 코드만 붙였고, 나머지 파일 전체는 zip 안에서 확인할 수 있습니다.
01페이드 + 확대
가운데서 살짝 커지며 나타나고, 오버레이가 함께 어두워집니다. 닫힐 때는 반대로 줄어들며 사라집니다. 확인·결제처럼 기본이 되는 모달에 씁니다.
.overlay {
opacity: 0;
transition: opacity $duration $easing;
}
.modal {
transform: translate(-50%, -50%) scale(.92);
opacity: 0;
transition: opacity $duration $easing, transform $duration $easing;
}
.stage.is-open .overlay { opacity: 1; }
.stage.is-open .modal {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
02바텀 시트
아래에서 시트가 올라오고, 위쪽 손잡이 바가 잡을 수 있다는 신호를 줍니다. 모바일에서 옵션을 고르거나 필터를 열 때 씁니다.
.sheet {
position: fixed; left: 50%; bottom: 0;
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;
border-radius: $r-pill;
background: rgba($stage-ink, .25);
}
.stage.is-open .sheet { transform: translate(-50%, 0); }
03사이드 드로어
오른쪽 끝에서 패널이 밀려 들어옵니다. 장바구니나 설정처럼 본문은 그대로 두고 옆에서 확인할 내용에 씁니다.
.drawer {
position: fixed; right: 0; top: 0; bottom: 0;
transform: translateX(100%);
width: min(78vw, 260px);
transition: transform $duration $easing;
}
.stage.is-open .drawer { transform: translateX(0); }
@keyframes drawer-loop {
0% { transform: translateX(100%); }
15% { transform: translateX(0); }
85% { transform: translateX(100%); }
100% { transform: translateX(100%); }
}
04배경 흐리기
모달이 뜨는 동안 뒤 화면이 서서히 흐려지며 시선을 모읍니다. 결제처럼 실수하면 안 되는 화면에 씁니다.
.overlay {
backdrop-filter: blur(0px);
opacity: 0;
transition: opacity $duration $easing, backdrop-filter $duration $easing;
}
.stage.is-open .overlay {
opacity: 1;
backdrop-filter: blur(8px);
}
05네이티브 dialog
브라우저 내장 <dialog>를 showModal()로 열고, @starting-style로 등장 애니메이션만 CSS로 얹습니다. 포커스 가두기와 ESC 닫기가 공짜로 따라옵니다.
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); }
}
}
06팝오버 확인창
버튼 옆에 붙어 작게 뜨는 확인창입니다. Popover API로 열려 다른 요소보다 항상 위에 그려집니다. 삭제처럼 가벼운 확인에 씁니다.
.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 {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
07버튼에서 전체화면
누른 항목이 화면 전체로 커지는 갤러리형 모달입니다. width·height 대신 transform: scale로만 커지므로 레이아웃이 다시 계산되지 않습니다.
.thumb {
position: fixed; inset: 0;
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;
}
08바깥 클릭 시 흔들림
닫을 수 없는 모달 바깥을 누르면 좌우로 두 번 흔들리고 테두리가 잠깐 강조색으로 바뀝니다. 약관 동의처럼 반드시 눌러야 넘어가는 화면에 씁니다.
.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%); }
50% { transform: translate(calc(-50% - 3px), -50%); }
}
09겹쳐 열리는 모달
두 번째 모달이 열리면 첫 번째가 뒤로 물러나 작아지고 흐려집니다. 다단계 폼이나 상세 안의 상세처럼 맥락을 잃지 않아야 할 때 씁니다.
.modal--1 { 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;
}
어디서 깨지는가 — 함정 하나
05 네이티브 dialog에서 오버레이를 눌러 닫으려다 자주 걸리는 함정이 있습니다. <dialog>의 ::backdrop은 자식 엘리먼트가 아니라서 배경을 클릭해도 이벤트의 target은 dialog 자기 자신입니다. dialog.addEventListener('click', () => dialog.close())라고만 쓰면 안쪽 padding을 눌러도 닫혀버립니다. e.target === dialog인지 확인한 뒤에만 닫아야 안쪽 클릭과 바깥 클릭이 구분됩니다. MDN의 dialog 문서에 이 동작이 나와 있습니다.
함정 말고도 실제로 만들면서 걸린 것이 둘 있습니다. 하나는 오버레이 진하기입니다. 09 겹쳐 열리는 모달을 처음 만들 때 두 모달의 오버레이를 똑같은 값으로 뒀더니 어느 쪽이 위인지 한눈에 안 들어왔습니다. 두 번째 모달이 뜰 때만 오버레이를 한 번 더 어둡게 겹치면 순서가 눈에 보입니다. 아홉 개 파일에 이 값이 전부 들어 있는 zip의 압축 비밀번호는 udnptj2b 입니다.
다른 하나는 body 스크롤입니다. 모달이 열린 동안 뒤 화면이 같이 스크롤되면 오버레이 밖으로 콘텐츠가 삐져나온 것처럼 보입니다. zip의 파일들은 .stage.is-open 상태에서 body에 overflow: hidden을 걸어 이 문제를 막습니다.
접근성
시스템에서 동작 줄이기를 켠 사용자를 위해 아홉 개 전부 prefers-reduced-motion: reduce에서 애니메이션을 끕니다. 01·04·05는 전환 없이 즉시 나타나고 사라지며, 08 흔들림은 움직임 대신 테두리 색만 바뀝니다. 07 전체화면 전환도 즉시 바뀌어 큰 스케일 이동이 만드는 멀미가 사라집니다. 포커스 이동은 reduced-motion과 무관하게 항상 살아 있어야 하므로, 05 네이티브 dialog처럼 브라우저가 기본 제공하는 포커스 가두기를 쓰는 편이 직접 구현하는 것보다 안전합니다.
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .modal { animation: none; }
.overlay, .modal { transition-duration: 1ms; }
}
다른 모달·전환 애니메이션은 CSS 애니메이션 카테고리에서 더 볼 수 있고, 사이트가 다루는 범위는 소개 페이지에 정리돼 있습니다.
FAQ
오버레이를 클릭해도 닫히지 않습니다.
05 네이티브 dialog라면 함정 섹션에서 설명한 target 확인 누락일 가능성이 큽니다. 그 밖의 항목은 .overlay 자체에 클릭 리스너를 달았는지, pointer-events: none이 실제 클릭 순간에 풀려 있는지부터 확인하십시오.
아홉 개를 한 페이지에 다 써도 됩니까?
모달은 한 번에 하나만 여는 것이 원칙이라 페이지 자체에는 문제없지만, 07 전체화면과 09 겹쳐 열리는 모달처럼 무거운 전환은 같은 화면에 두 개 이상 두지 않는 편이 좋습니다. 그 밖의 항목은 열리는 조건(확인·삭제·필터 등)이 서로 달라서 한 화면에 둘이 있어도 부딪힐 일이 드뭅니다.
React 판에서 dialog 상태는 어떻게 관리합니까?
zip의 react/05-native-dialog는 useRef로 dialog 엘리먼트를 잡고 useEffect 안에서 열림 상태에 따라 showModal()·close()를 직접 호출합니다. React state로 open 여부만 들고, DOM 메서드 호출은 effect에 맡기는 편이 값과 실제 화면이 어긋나지 않습니다.