GODRICH

CSS 모달 팝업 애니메이션 9종 — JS 최소, @starting-style로

CSS 모달 팝업 애니메이션 아홉 개를 한 번에 모았습니다. 확인창부터 바텀 시트, 팝오버, 겹쳐 열리는 모달까지 실무에서 자주 쓰는 장면을 아홉 가지로 나눠 담았고, 전부 transform과 opacity만(배경 흐림은 예외) 움직입니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 흔히 보는 목록이 아니라 모달이 화면에 나타나는 방식을 기준으로 놓았습니다. 가운데서 뜨는 기본형부터(01~04), 브라우저 기능으로 여는 것(05~06), 화면을 통째로 덮는 것(07), 닫지 못하게 막는 것(08), 마지막으로 모달 위에 모달이 쌓이는 경우(09) 순입니다. 각 항목에는 동작을 보여주는 핵심 코드만 붙였고, 나머지 파일 전체는 zip 안에서 확인할 수 있습니다.

01페이드 + 확대

가운데서 살짝 커지며 나타나고, 오버레이가 함께 어두워집니다. 닫힐 때는 반대로 줄어들며 사라집니다. 확인·결제처럼 기본이 되는 모달에 씁니다.

200ms0.9→1페이드만
.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바텀 시트

아래에서 시트가 올라오고, 위쪽 손잡이 바가 잡을 수 있다는 신호를 줍니다. 모바일에서 옵션을 고르거나 필터를 열 때 씁니다.

280ms반만 열기손잡이
.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사이드 드로어

오른쪽 끝에서 패널이 밀려 들어옵니다. 장바구니나 설정처럼 본문은 그대로 두고 옆에서 확인할 내용에 씁니다.

320px왼쪽에서전체 폭
.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배경 흐리기

모달이 뜨는 동안 뒤 화면이 서서히 흐려지며 시선을 모읍니다. 결제처럼 실수하면 안 되는 화면에 씁니다.

8px어둡게만12px
.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 닫기가 공짜로 따라옵니다.

@starting-styleallow-discrete::backdrop
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로 열려 다른 요소보다 항상 위에 그려집니다. 삭제처럼 가벼운 확인에 씁니다.

anchor위쪽화살표 없이
.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로만 커지므로 레이아웃이 다시 계산되지 않습니다.

420ms둥글게 유지페이드 섞기
.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바깥 클릭 시 흔들림

닫을 수 없는 모달 바깥을 누르면 좌우로 두 번 흔들리고 테두리가 잠깐 강조색으로 바뀝니다. 약관 동의처럼 반드시 눌러야 넘어가는 화면에 씁니다.

4px두 번테두리 강조
.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겹쳐 열리는 모달

두 번째 모달이 열리면 첫 번째가 뒤로 물러나 작아지고 흐려집니다. 다단계 폼이나 상세 안의 상세처럼 맥락을 잃지 않아야 할 때 씁니다.

0.95어둡게3단
.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-dialoguseRef로 dialog 엘리먼트를 잡고 useEffect 안에서 열림 상태에 따라 showModal()·close()를 직접 호출합니다. React state로 open 여부만 들고, DOM 메서드 호출은 effect에 맡기는 편이 값과 실제 화면이 어긋나지 않습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.