GODRICH

CSS 순차적 애니메이션 9종 — 복붙되는 스태거 데모

CSS 순차적 애니메이션은 목록이나 격자의 여러 항목이 한꺼번에 움직이지 않고, 정해진 순서에 맞춰 지연 시간을 조금씩 다르게 주어 하나씩 나타나거나 사라지게 만드는 기법입니다. 아홉 개 중 하나(08)는 반복문도 인라인 변수도 없이 CSS 함수 하나로 지연을 계산합니다.

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

아홉 개는 무작위가 아니라 지연 시간을 계산하는 방식이 점점 자동화되는 순서로 놓았습니다. 목록·격자·글자에 기본 스타일을 입히는 것(01~03), 방향과 형태를 바꾸는 응용(04~06), 지연 값을 계산하는 서로 다른 세 가지 방법(07~09)입니다. 아래 코드는 지연을 계산하는 핵심 규칙만 뽑았고, 실제로 동작하는 전체 파일은 이 편의 압축 파일에서 받을 수 있습니다.

01리스트 캐스케이드

목록 항목 세 개가 아래에서 살짝 작아진 채로 대기하다가 nth-child마다 90ms씩 늦게 시작해 확대되며 제자리로 올라옵니다. 단순한 페이드보다 입체감이 있어 알림 피드나 검색 결과처럼 새 항목이 계속 쌓이는 목록에 어울립니다.

scale+translateY90ms 간격ease-pop
.cascade li {
  opacity: 0;
  transform: translateY(14px) scale(.9);
  animation: cascade-loop $duration $easing;
}
@for $i from 1 through 3 {
  .cascade li:nth-child(#{$i}) {
    animation-delay: ($i - 1) * $stagger;
  }
}
@keyframes cascade-loop {
  to { opacity: 1; transform: translateY(0) scale(1); }
}

02격자 물결

그리드 카드 아홉 장이 3×3으로 놓이고, 각 칸의 지연 시간을 행 번호와 열 번호를 더한 값에 70ms를 곱해서 정합니다. 왼쪽 위에서 오른쪽 아래로 대각선 물결처럼 순서대로 나타나, 대시보드 카드나 갤러리 썸네일에 쓰기 좋습니다.

행+열 인덱스70ms 계수대각선 확산
$cols: 3;
@for $i from 1 through 9 {
  $row: math.floor(math.div($i - 1, $cols));
  $col: ($i - 1) % $cols;
  .grid .cell:nth-child(#{$i}) {
    animation-delay: ($row + $col) * $wave;
  }
}
@keyframes wave-loop {
  from { opacity: 0; transform: scale(.5); }
  to   { opacity: 1; transform: scale(1); }
}

03글자 단위 스태거

제목 글자 하나하나를 span으로 감싸 30ms씩 늦게 시작하도록 지연을 주면, 문장이 통째로 나타나는 대신 글자가 아래에서 튀어 오르며 하나씩 자리 잡습니다. 로고 인트로나 히어로 타이틀처럼 첫인상이 중요한 자리에 맞습니다.

글자별 span30ms 간격translateY 바운스
.letters span {
  opacity: 0;
  transform: translateY(16px);
  animation: letter-loop $duration $easing;
}
@for $i from 1 through 5 {
  .letters span:nth-child(#{$i}) {
    animation-delay: ($i - 1) * $stagger;
  }
}
@keyframes letter-loop {
  to { opacity: 1; transform: translateY(0); }
}

04카드 3D 플립 순차

카드 세 장이 Y축으로 90도 젖혀진 채 옆으로 누워 대기하다가 150ms씩 늦게 하나씩 rotateY로 펴지며 앞면을 드러냅니다. 부모 요소에 perspective를 걸어야 회전에 원근감이 생겨, 밋밋한 확대·축소보다 카드를 넘기는 느낌이 강해집니다.

rotateY 90→0150ms 간격perspective
.cards { perspective: 600px; }
.card {
  opacity: 0;
  transform: rotateY(90deg);
  transform-style: preserve-3d;
  animation: flip-loop $duration $easing;
}
@for $i from 1 through 3 {
  .card:nth-child(#{$i}) {
    animation-delay: ($i - 1) * $stagger;
  }
}
@keyframes flip-loop {
  to { opacity: 1; transform: rotateY(0deg); }
}

05역순 퇴장

들어올 때는 위에서 아래로(01→03) 순서대로 나타나지만, 사라질 때는 반대로 아래에서 위로(03→01) 역순으로 없어지도록 항목마다 다른 @keyframes를 붙였습니다. 삭제 확인 목록이나 쌓인 토스트 알림을 정리할 때는 마지막에 온 것부터 지워지는 쪽이 자연스럽습니다.

역순 delay50ms 간격enter/exit 분리
@keyframes exit-1 {
  0%, 5%    { opacity: 0; transform: translateY(-8px); }
  15%, 78%  { opacity: 1; transform: translateY(0); }
  90%, 100% { opacity: 0; transform: translateY(-8px); }
}
@keyframes exit-3 {
  0%, 25%   { opacity: 0; transform: translateY(-8px); }
  35%, 53%  { opacity: 1; transform: translateY(0); }
  65%, 100% { opacity: 0; transform: translateY(-8px); }
}

06원형 파문

3×3 격자의 점 아홉 개가 있고, 각 점의 지연 시간을 중심 칸까지의 거리에 90ms를 곱해 정합니다. 코너는 대각선 거리(√2)만큼 더 늦게, 중심은 지연 없이 먼저 커지기 시작해 물결이 가운데서 바깥으로 퍼지는 것처럼 보입니다.

중심 거리 delay동심원 확산scale+opacity
.ripple span {
  opacity: 0;
  transform: scale(.3);
  animation: ripple-loop $duration $easing;
}
// 코너 √2, 변 1, 중심 0 — 미리 계산해 넣는다
span:nth-child(1) { animation-delay: $step * 1.41; }
span:nth-child(5) { animation-delay: 0ms; }
span:nth-child(9) { animation-delay: $step * 1.41; }
@keyframes ripple-loop {
  to { opacity: 1; transform: scale(1); }
}

07--i 변수 + calc 한 줄

항목마다 인라인 style에 --i 값만 0부터 순서대로 넣어 두면, CSS 쪽은 반복문 없이 딱 한 줄 calc(var(--i) * 80ms)로 지연을 계산합니다. SCSS @fornth-child도 필요 없어서, 항목 개수가 늘거나 줄어도 스타일을 다시 컴파일할 필요가 없습니다.

--i 변수calc() 한 줄인라인 style
// html: <li style="--i:0">, <li style="--i:1">, ...
.i-list li {
  opacity: 0;
  transform: translateY(10px);
  animation: i-loop $duration $easing;
  animation-delay: calc(var(--i) * 80ms);   // 한 줄로 끝
}
@keyframes i-loop {
  to { opacity: 1; transform: translateY(0); }
}

08sibling-index()로 자동 지연

타일 여섯 개에 --i 값도 붙이지 않고 nth-child 반복문도 쓰지 않은 채, animation-delay: calc(sibling-index() * 100ms) 한 줄만 적었습니다. sibling-index()가 요소 자신의 형제 순서를 직접 읽어 오기 때문에 07의 --i 인라인 값보다도 마크업이 가벼워지지만, Chrome/Edge 138부터만 지원하고 다른 브라우저는 아직입니다(caniuse의 sibling-index() 지원표).

sibling-index()반복문 없음Chrome/Edge 138+
.tiles span {
  opacity: 0;
  transform: scale(.5);
  animation: tile-loop $duration $easing;
  animation-delay: calc(sibling-index() * 100ms);
}
@keyframes tile-loop {
  to { opacity: 1; transform: scale(1); }
}

09랜덤 지연 근사(SCSS random())

배지 다섯 개에 SCSS의 random(420) 함수로 1~420ms 사이 지연을 항목마다 다르게 정했습니다. 이 값은 브라우저에서 방문할 때마다 바뀌는 게 아니라 sass가 컴파일할 때 한 번 뽑혀 파일에 그대로 고정됩니다.

SCSS random()비선형 지연컴파일타임 고정
@for $i from 1 through 5 {
  .badge:nth-child(#{$i}) {
    animation-delay: random(420) * 1ms;
  }
}
@keyframes badge-loop {
  from { opacity: 0; transform: translateY(10px) scale(.9); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

어디서 깨지는가 — 함정 하나

01~06, 09처럼 nth-child나 미리 계산한 값으로 지연을 매기는 방식은 항목 개수가 정확히 고정돼 있을 때만 맞아떨어집니다. 목록에 항목이 동적으로 추가되거나 빠지면 스타일을 다시 컴파일하거나 마크업을 손으로 맞춰야 하는데, 이 문제를 정면으로 피하는 게 07의 --i 인라인 값과 08의 sibling-index()입니다. MDN의 :nth-child() 문서는 이 선택자가 컴파일 시점이 아니라 실제 DOM 구조를 기준으로 계산된다는 점을 설명합니다.

09의 random()도 오해하기 쉬운 지점이 있습니다. 이 함수는 sass 컴파일러가 style.css를 만들 때 딱 한 번 실행되고, 8dxrtqpt이 걸린 zip 안 파일에도 그 고정된 값이 그대로 들어 있어 브라우저를 새로고침해도 배지 순서는 똑같습니다.

방문할 때마다 배지 순서가 진짜로 바뀌게 하려면 CSS만으로는 안 되고, 페이지가 로드될 때 JS로 --delay 커스텀 속성을 다시 계산해 넣어야 합니다. 아홉 개 중 CSS만으로 끝나는 건 07·08 뿐이고 나머지도 전부 JS 없이 동작하지만, "진짜 무작위"만은 CSS의 몫이 아닙니다.

접근성

아홉 개 전부 prefers-reduced-motion: reduce를 만나면 애니메이션을 끄고 최종 상태(항목이 이미 다 보이는 상태)를 즉시 보여줍니다. 등장 애니메이션이라 잠깐 안 보이다 나타나는 것과 처음부터 보이는 것의 차이만 있고, 목록 내용 자체는 항상 그대로 남습니다. 08의 sibling-index()처럼 아직 모든 브라우저가 지원하지 않는 기능은, 지원하지 않는 브라우저에서 지연 없이 한꺼번에 나타나는 것으로 자연스럽게 무너지는 것도 확인했습니다. 미디어 쿼리가 받는 값의 전체 목록은 MDN @media prefers-reduced-motion 문서를 참고하세요.

@media (prefers-reduced-motion: reduce) {
  li, span, .card, .badge { animation: none !important; opacity: 1; transform: none; }
}

이 편 말고 다른 전환·등장 애니메이션이 궁금하면 CSS 애니메이션 카테고리를 둘러보시고, 사이트가 다루는 범위는 소개 페이지에 있습니다.

FAQ

스태거 애니메이션에 자바스크립트가 꼭 필요합니까?

아닙니다. 아홉 개 전부 CSS keyframesanimation-delay만으로 자동 반복됩니다. 07의 --i 값도 마크업에 미리 넣어 둔 인라인 style이고, 08의 sibling-index()는 CSS 함수라 JS가 한 줄도 없습니다.

--i 변수와 sibling-index() 중 뭘 써야 합니까?

지금 당장 모든 브라우저를 지원해야 한다면 07의 --i + calc()가 안전합니다. Chrome/Edge 138 이상만 지원하면 되는 내부 도구나 사내 어드민이라면 08의 sibling-index()가 마크업을 더 가볍게 만듭니다(caniuse 지원표 확인).

09의 랜덤 지연은 새로고침마다 값이 바뀝니까?

아니요. SCSS의 random()은 sass가 style.css를 컴파일할 때 한 번만 실행되고 그 값이 파일에 고정됩니다. 방문할 때마다 바뀌게 하려면 CSS만으로는 안 되고, 로드 시점에 JS로 --delay 커스텀 속성을 다시 계산해 넣어야 합니다.

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

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