GODRICH

타임라인 CSS 애니메이션 9종 — 스테퍼까지 복붙

타임라인 CSS 애니메이션은 이벤트나 결제 같은 여러 단계를 선과 원으로 이어 순서대로 보여주는 장치입니다. AOS·GSAP 없이, JS 0줄로 아홉 가지를 모았고, 그중 셋은 스크롤 위치를 CSS가 직접 읽는 scroll()·view()·sibling-index()까지 씁니다.

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

아홉 개는 실제 화면이 겪는 순서로 놓았습니다. 먼저 타임라인 자체가 그려지는 방식(01~02), 그다음 가로 스테퍼가 진행을 보여주는 방식(03~04), 화면에 콘텐츠로 얹히는 방식(05~06), 마지막으로 최신 CSS 하나만으로 스크롤·순서를 다루는 방식(07~09)입니다.

01세로 선 그리기

회사 연혁이나 이력서 경력처럼 세로로 늘어선 이벤트 목록에서, 선이 위에서 아래로 자라나며 그려집니다. transform: scaleY(0)에서 scaleY(1)로, transform-origin: top만 있으면 됩니다.

scaleYtransform-origin1.4s
.rail {
  width: 3px;
  height: 100%;
  background: $color;
  transform-origin: top;
  transform: scaleY(1);

  @supports (animation-timeline: view()) {
    transform: scaleY(0);
    animation: draw linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 70%;
  }
}
@keyframes draw {
  from { transform: scaleY(0); }
  to   { transform: scaleY(1); }
}

02도트 순차 팝

선 위의 점들이 위에서부터 하나씩 튀어오릅니다. 온보딩 체크리스트나 주문 단계 표시에 쓰며, 딜레이만 nth-child로 다르게 줍니다.

scaleanimation-delaynth-child
.dot {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: $color;
  transform: scale(0);
  opacity: 0;
  animation: pop 2s cubic-bezier(.2, 1.4, .4, 1) both;
}

@for $i from 1 through 5 {
  .dot:nth-child(#{$i}) { animation-delay: #{($i - 1) * 140}ms; }
}

@keyframes pop {
  0%   { transform: scale(0);    opacity: 0; }
  60%  { transform: scale(1.25); opacity: 1; }
  100% { transform: scale(1);    opacity: 1; }
}

03가로 단계 채움

회원가입이나 설문 단계처럼 가로로 늘어선 단계 사이 연결선이 지나온 만큼 색으로 채워집니다. scaleX가 자기 자신의 폭 기준으로 커지기 때문에, 옆 칸 크기를 몰라도 됩니다.

scaleXflex: 1 1 0600ms
.bar {
  flex-grow: 1;
  flex-shrink: 1;
  flex-basis: 0;
  height: 3px;
  overflow: hidden;
}
.fill {
  width: 100%;
  height: 100%;
  background: $color;
  transform: scaleX(0);
  transform-origin: left;
}
.step.is-done + .bar .fill { transform: scaleX(1); }

04활성 단계 펄스

지금 처리 중인 주문이나 실시간 알림 배지처럼, 진행 중인 단계의 원 뒤에서 링이 부풀며 옅어지는 걸 반복합니다. 레이더 신호처럼 "지금 여기가 움직이고 있다"는 신호만 따로 줍니다.

scaleopacity1.6s
.ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid $color;
  animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse {
  0%   { transform: scale(.7); opacity: .8; }
  70%  { transform: scale(1.8); opacity: 0; }
  100% { transform: scale(1.8); opacity: 0; }
}

05좌우 교차 카드 슬라이드

프로젝트 마일스톤이나 회사 연혁 페이지에서, 가운데 세로선을 기준으로 카드가 홀수는 왼쪽에서, 짝수는 오른쪽에서 모여듭니다. 뷰포트에 들어오는 카드마다 view()로 자동으로 시작됩니다.

translateXopacity500ms
.row--l .card { transform: translateX(-24px); }
.row--r .card { transform: translateX(24px); }

@supports (animation-timeline: view()) {
  .card {
    opacity: 0;
    animation: in ease-out both;
    animation-timeline: view();
    animation-range: entry 0% cover 60%;
  }
}
@keyframes in { to { opacity: 1; transform: translateX(0); } }

06결제 스테퍼

이커머스 결제나 예약 폼의 네 단계 중 끝난 단계는 원 안의 숫자가 체크 아이콘으로 바뀝니다. 막대 두 개로 사선을 만드는 대신 SVG polylinestroke-dashoffset을 줄여 그리는 방식이라, 시작점이 어긋나 깨질 일이 없습니다.

stroke-dashoffsetscaleXSVG
.check polyline {
  stroke-dasharray: 16;
  stroke-dashoffset: 16;
}
.step.is-checked .check polyline {
  animation: draw .5s ease-out forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }

.step.is-checked + .bar .fill { transform: scaleX(1); }

07스크롤 진행 선 그리기(scroll())

긴 글의 읽기 진행선이나 문서 사이드 타임라인에서, 상자 안에서 스크롤하면 옆의 세로선이 딱 그만큼만 그려집니다. animation-timeline: scroll(nearest block) 하나가 스크롤 이벤트 리스너와 requestAnimationFrame 루프를 대신합니다.

animation-timeline: scroll()scaleYJS 0줄
.rail {
  width: 3px;
  height: 100%;
  background: $color;
  transform-origin: top;
  transform: scaleY(1);

  @supports (animation-timeline: scroll()) {
    transform: scaleY(0);
    animation: draw linear both;
    animation-timeline: scroll(nearest block);
  }
}
@keyframes draw { to { transform: scaleY(1); } }

08view()로 이벤트 리빌

스크롤로 하나씩 드러나는 이벤트 타임라인이나 롱폼 기사 섹션에서, IntersectionObserver 없이 카드마다 자기가 화면에 들어오는 구간만큼 opacitytranslateY가 움직입니다.

animation-timeline: view()animation-rangeJS 0줄
.card {
  opacity: 1;

  @supports (animation-timeline: view()) {
    opacity: 0;
    transform: translateY(16px);
    animation: reveal ease-out both;
    animation-timeline: view();
    animation-range: entry 0% cover 40%;
  }
}
@keyframes reveal { to { opacity: 1; transform: translateY(0); } }

09sibling-index() 자동 번호

드래그로 순서를 바꾸는 체크리스트처럼 항목이 자주 추가·삭제되는 목록에서, nth-child 규칙 없이 형제 순서를 그대로 번호로 씁니다. counter-set에 계산식을 넣을 수 있다는 게 핵심입니다.

sibling-index()counter-setcalc()
.step {
  counter-increment: n;
  &::before { content: counter(n); }
}
@supports (z-index: calc(sibling-index())) {
  .step {
    counter-increment: none;
    counter-set: n calc(sibling-index());
    animation-delay: calc((sibling-index() - 1) * 120ms);
  }
}

어디서 깨지는가 — 스크롤 타임라인의 함정

animation-timeline: scroll()·view()·sibling-index() 세 개 모두 아직 브라우저 지원이 갈립니다. 실측(2026-09-10, 설치된 Chrome 152 기준)은 이렇습니다.

기능 Chrome·Edge Safari Firefox
animation-timeline scroll()·view() 115+ 아직 없음 아직 없음(플래그 뒤)
sibling-index()·sibling-count() 129+ 아직 없음 아직 없음

지원 범위는 MDN animation-timeline 문서sibling-index() 초안 명세에서 다시 확인할 수 있습니다. 데모를 렌더해 보면서 진짜로 겪은 함정도 하나 있습니다. 이 데모들을 gif로 뽑는 렌더 스크립트는 애니메이션 시계를 currentTime에 직접 밀어 넣어 프레임을 뽑는데, 07·08처럼 scroll()·view()를 쓰는 애니메이션에 절대 시간값(ms)을 그대로 넣었더니 "진행률 기반 애니메이션엔 절대 시간을 못 쓴다"는 에러로 렌더가 통째로 죽었습니다. 시계를 놓는 값이 CSS.percent(진행률)이어야 한다는 걸 알고 나서야 07·08이 정상적으로 캡처됐습니다. 05번 좌우 교차 카드도 렌더는 멀쩡히 통과했지만 실제로 붙여 보니 카드가 영원히 안 보이는 버그가 있었는데, .is-demo 캡처용 키프레임 이름과 실사용 코드가 가리키는 키프레임 이름이 서로 달라서였습니다 — 캡처가 통과했다고 실사용까지 확인된 건 아니라는 걸 다시 배웠습니다. .is-demo 클래스를 떼고 실사용 조건 그대로 다시 렌더해, 카드의 opacity가 0이 아니라 1에 가깝게 나오는 것까지 확인한 뒤에야 고쳤다고 판단했습니다. 이 값들이 전부 반영된 아홉 개 파일의 압축 비밀번호는 q3b8jjma이고, 아래에서 zip 하나로 받을 수 있습니다. Firefox·Safari에서는 07·08의 animation-timeline이 그냥 무시되므로, @supports not (animation-timeline: scroll())(또는 view())로 선·카드를 처음부터 다 그려진 최종 상태로 고정해 뒀습니다 — 이 zip의 vanilla/·react/ 둘 다에 포함된 코드입니다.

접근성

운영체제의 동작 줄이기 옵션(prefers-reduced-motion: reduce)을 켜면 아홉 개 전부 움직임을 멈춥니다. 01·03·06·07·08처럼 채워지거나 그려지는 항목은 진행률 0이 아니라 다 채워진 최종 상태로 고정되고, 02·05·09처럼 하나씩 나타나는 항목은 전부 이미 나타난 상태로 고정됩니다. 04의 펄스 링은 opacity: .6 고정값으로만 남아 깜빡임 없이 "지금 여기"라는 신호는 유지합니다. 06의 체크 아이콘은 장식이 아니라 상태 정보라서 스크린 리더용 aria-label="완료"를 원 요소에 따로 붙였고, 09처럼 counter()로 그린 숫자는 스크린 리더가 못 읽으므로 감싸는 <li>aria-label="{n}번째 단계"를 넣었습니다. prefers-reduced-motion의 지원 범위는 MDN 문서에서 확인할 수 있습니다.

같은 스크롤 트리거를 쓰는 다른 모음은 CSS 프로그레스바 애니메이션 9종에 더 있고, CSS 애니메이션 소재 전부는 CSS 애니메이션 카테고리에 모여 있습니다.

FAQ

scroll()·view()를 못 쓰는 브라우저에서는 타임라인이 안 움직이나요?

vanilla/·react/ 둘 다 @supports not (animation-timeline: scroll())(08은 view()) 분기를 넣어 뒀기 때문에, 진행 상태 없이 다 그려진 모습으로 멈춰 있습니다 — 두 판 모두 같은 동작입니다. Firefox·Safari에서 07·08을 스크롤에 실시간으로 반응하게 하려면 scroll 이벤트나 IntersectionObserver로 진행률을 계산해 CSS 변수로 넘기는 JS를 직접 더해야 하고, prompt.md의 "React 판을 시킬 때 덧붙이는 줄"에 그 조건을 적어 뒀습니다.

09번 sibling-index() 대신 그냥 nth-child로 번호를 매기면 안 되나요?

정적인 목록이면 nth-child로도 충분합니다. sibling-index()가 필요한 건 드래그로 순서를 바꾸거나 항목이 추가·삭제되는 목록입니다 — nth-child 규칙은 항목 수만큼 미리 다 적어 둬야 하지만, sibling-index()는 지금 DOM에 있는 형제 순서를 그대로 읽으므로 규칙을 다시 안 써도 됩니다.

아홉 개 중 세로형과 가로형을 한 페이지에 같이 써도 되나요?

01·02·05·07·08처럼 세로로 흐르는 것과 03·04·06처럼 가로로 흐르는 것은 보통 페이지 안에서 역할이 다릅니다 — 세로형은 본문 콘텐츠(연혁·이벤트)에, 가로형은 폼 상단의 진행 상태 표시에 씁니다. 같은 화면 한 구역 안에서 방향을 섞으면 어느 쪽이 "지금 진행"인지 헷갈리므로 구역을 나눠 쓰는 걸 권합니다.

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

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