GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

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

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

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

아홉 개는 무작위가 아니라 지연 시간을 계산하는 방식이 점점 자동화되는 순서로 놓았습니다. 목록·격자·글자에 기본 스타일을 입히는 것(01~03), 방향과 형태를 바꾸는 응용(04~06), 지연 값을 계산하는 서로 다른 세 가지 방법(07~09)입니다. 아래 css 애니메이션 예제는 지연을 계산하는 핵심 규칙만 뽑았고, 실제로 동작하는 전체 파일은 이 편의 압축 파일에서 받을 수 있습니다. 항목이 멈추지 않고 계속 흘러야 하면 지연 대신 무한 루프가 답이라 CSS 무한 롤링 배너 쪽이고, 순서 대신 진행률을 보여야 하면 CSS 프로그레스바 애니메이션입니다.

01리스트 캐스케이드

css 애니메이션 시간차를 어떻게 매기나

목록 항목 세 개가 아래에서 살짝 작아진 채로 대기하다가 nth-child마다 90ms씩 늦게 시작해 확대되며 제자리로 올라옵니다.

어디에 쓰나

단순한 페이드보다 입체감이 있어 알림 피드나 검색 결과처럼 새 항목이 계속 쌓이는 목록에 어울립니다.

리스트 캐스케이드
새 창에서 열기

01 · 리스트 캐스케이드 — 측정 누적면적 9.301% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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를 곱해서 정합니다.

어디에 쓰나

왼쪽 위에서 오른쪽 아래로 대각선 물결처럼 순서대로 나타나, 대시보드 카드나 갤러리 썸네일에 쓰기 좋습니다.

격자 물결
새 창에서 열기

02 · 격자 물결 — 측정 누적면적 11.562% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

행+열 인덱스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씩 늦게 시작하도록 지연을 주면, 문장이 통째로 나타나는 대신 글자가 아래에서 튀어 오르며 하나씩 자리 잡습니다.

어디에 쓰나

로고 인트로나 히어로 타이틀처럼 첫인상이 중요한 자리에 맞습니다.

글자 단위 스태거
새 창에서 열기

03 · 글자 단위 스태거 — 측정 누적면적 4.64% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

글자별 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를 걸어야 회전에 원근감이 생겨, 밋밋한 확대·축소보다 카드를 넘기는 느낌이 강해집니다.

카드 3D 플립 순차
새 창에서 열기

04 · 카드 3D 플립 순차 — 측정 누적면적 13.364% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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를 붙였습니다.

어디에 쓰나

삭제 확인 목록이나 쌓인 토스트 알림을 정리할 때는 마지막에 온 것부터 지워지는 쪽이 자연스럽습니다.

역순 퇴장
새 창에서 열기

05 · 역순 퇴장 — 측정 누적면적 6.794% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

역순 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)만큼 더 늦게, 중심은 지연 없이 먼저 커지기 시작해 물결이 가운데서 바깥으로 퍼지는 것처럼 보입니다.

원형 파문
새 창에서 열기

06 · 원형 파문 — 측정 누적면적 9.975% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

중심 거리 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 @for도 nth-child도 필요 없어서, 항목 개수가 늘거나 줄어도 스타일을 다시 컴파일할 필요가 없습니다.

--i 변수 + calc 한 줄
새 창에서 열기

07 · --i 변수 + calc 한 줄 — 측정 누적면적 13.745% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

--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() 로 자동 지연
새 창에서 열기

08 · sibling-index() 로 자동 지연 — 측정 누적면적 5.736% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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())
새 창에서 열기

09 · 랜덤 지연 근사(SCSS random()) — 측정 누적면적 6.827% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 구조를 기준으로 계산된다는 점을 설명합니다.

random() 은 한 번만 돈다

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

진짜 무작위가 필요하면

방문할 때마다 배지 순서가 진짜로 바뀌게 하려면 CSS만으로는 안 되고, 페이지가 로드될 때 JS로 --delay 커스텀 속성을 다시 계산해 넣어야 합니다. 아홉 개 중 CSS만으로 끝나는 건 07·08 뿐이고 나머지도 전부 JS 없이 동작하지만, "진짜 무작위"만은 CSS의 몫이 아닙니다. 순서대로 켜지는 이 리듬에 빛까지 얹고 싶다면 CSS 글로우 효과 9종의 네온이 지연 값과 잘 맞물립니다.

접근성

동작 줄이기에서

아홉 개 전부 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 등장 애니메이션에 자바스크립트가 꼭 필요합니까?

아닙니다. 아홉 개 전부 CSS keyframes와 animation-delay만으로 자동 반복됩니다. 07의 --i 값도 마크업에 미리 넣어 둔 인라인 style이고, 08의 sibling-index()는 CSS 함수라 JS가 한 줄도 없습니다. 같은 지연 계산을 세로로 길게 이어 단계를 보여주는 형태는 타임라인 CSS 애니메이션 9종에 정리돼 있습니다.

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

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

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

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

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

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