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

CSS 스크롤 애니메이션 9종 — AOS·GSAP 없이 JS 0줄로

CSS 스크롤 애니메이션 아홉 개를 모았습니다. AOS·GSAP 없이 브라우저 내장 animation-timeline 하나로 스크롤 위치 자체를 애니메이션 시계로 씁니다.

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

아홉 개는 발견되는 순서가 아니라 배워야 하는 순서로 놓았습니다. css 스크롤 내리면 나타나는 애니메이션인 01~04는 요소 하나가 뷰포트에 들어오는 순간을 다루는 view() 타임라인이고, 05~07은 문서 전체 스크롤 진행률을 다루는 scroll() 타임라인입니다. 이 둘을 섞어 쓰면 뭐가 뭘 움직이는지 헷갈리므로 항목마다 둘 중 하나만 씁니다. 08~09는 앞의 두 타임라인 위에 카운터·다중 요소 같은 응용을 얹은 것이라 맨 뒤에 뒀습니다. 07 패럴랙스 한 항목만으로 모자라 층을 더 깊게 쌓고 싶으면 CSS 패럴랙스 스크롤 9종이 그 항목만 아홉 배로 늘린 편이고, 03 글자 닦아내기에서 출발해 문장 자체를 다루고 싶다면 CSS 텍스트 애니메이션 효과로 건너가면 됩니다.

01떠오르며 등장

화면에서 일어나는 일

카드가 화면에 들어오면 아래에서 24px 올라오며 페이드인합니다.

떠오르며 등장
새 창에서 열기

01 · 떠오르며 등장 — 측정 누적면적 38.606% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

24px멀리 48px페이드만

css 등장 애니메이션은 어디에 쓰나

섹션 제목이나 카드처럼 거의 모든 등장에 씁니다.

.card {
  @supports (animation-timeline: view()) {
    animation: fade-up-reveal ease-out both;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
  @supports not (animation-timeline: view()) {
    opacity: 1;
    transform: none;
  }
}
@keyframes fade-up-reveal {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}

02순서대로 등장

어떤 움직임인가

목록 항목이 하나씩 나타납니다.

순서대로 등장
새 창에서 열기

02 · 순서대로 등장 — 측정 누적면적 34.529% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

80ms빠르게 40ms역순

언제 쓰나

항목마다 같은 view() 규칙 하나를 걸면, 세로로 쌓인 배치 자체가 시차를 만들어 별도의 지연 값 없이도 순서대로 보입니다.

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

03글자 닦아내기

무엇이 보이나

제목이 왼쪽에서 오른쪽으로 닦여 나옵니다.

글자 닦아내기
새 창에서 열기

03 · 글자 닦아내기 — 측정 누적면적 2.99% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

600ms위에서가운데서

어디에 쓰나

clip-path 하나만 바뀌므로 레이아웃을 다시 계산하지 않습니다.

.headline {
  @supports (animation-timeline: view()) {
    animation: wipe-in cubic-bezier(.2, .8, .2, 1) both;
    animation-timeline: view();
    animation-range: entry 20% cover 60%;
  }
  @supports not (animation-timeline: view()) {
    clip-path: inset(0 0 0 0);
  }
}
@keyframes wipe-in {
  from { clip-path: inset(0 100% 0 0); }
  to   { clip-path: inset(0 0 0 0); }
}

04이미지 커튼

화면에서 일어나는 일

이미지를 덮은 색 판이 오른쪽으로 걷히며 이미지가 드러납니다.

이미지 커튼
새 창에서 열기

04 · 이미지 커튼 — 측정 누적면적 40.756% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

주황검정위로

쓰는 자리

포트폴리오나 상품 사진 앞에 씁니다.

.frame__curtain {
  transform: scaleX(1);
  transform-origin: right;
  @supports (animation-timeline: view()) {
    animation: curtain-open cubic-bezier(.2, .8, .2, 1) both;
    animation-timeline: view();
    animation-range: entry 10% cover 50%;
  }
}
@keyframes curtain-open {
  from { transform: scaleX(1); }
  to   { transform: scaleX(0); }
}

05읽기 진행 바

어떤 움직임인가

문서를 얼마나 읽었는지 상단 바가 채워집니다.

읽기 진행 바
새 창에서 열기

05 · 읽기 진행 바 — 측정 누적면적 1.0% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

3px굵게 6px하단

언제 쓰나

여기서만 view()가 아니라 scroll()을 씁니다. "들어왔나"가 아니라 "얼마나 지나왔나"를 재기 때문입니다.

.bar {
  transform: scaleX(0);
  transform-origin: left;
  @supports (animation-timeline: scroll()) {
    animation: fill-bar linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes fill-bar {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

06헤더 줄어들기

무엇이 보이나

스크롤하면 고정 헤더가 얇아집니다.

헤더 줄어들기
새 창에서 열기

06 · 헤더 줄어들기 — 측정 누적면적 7.342% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

64→48px로고만그림자 추가

어디에 쓰나

height가 아니라 transform: scaleY로 줄이므로 옆 요소가 밀리지 않습니다.

.head {
  transform-origin: top;
  @supports (animation-timeline: scroll()) {
    animation: shrink-header linear both;
    animation-timeline: scroll(root);
    animation-range: 0 200px;
  }
}
@keyframes shrink-header {
  from { transform: scaleY(1); }
  to   { transform: scaleY(.75); }
}

07패럴랙스

화면에서 일어나는 일

뒤 배경이 앞 내용보다 느리게 움직여 깊이가 생깁니다.

패럴랙스
새 창에서 열기

07 · 패럴랙스 — 측정 누적면적 13.925% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

0.5배0.3배두 겹

쓰는 자리

두 레이어를 각각 scroll(root)에 걸고 이동량만 다르게 줍니다.

.parallax__back {
  @supports (animation-timeline: scroll()) {
    animation: drift-back linear both;
    animation-timeline: scroll(root);
  }
}
.parallax__front {
  @supports (animation-timeline: scroll()) {
    animation: drift-front linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes drift-back  { from { transform: translateY(0); } to { transform: translateY(-20px); } }
@keyframes drift-front { from { transform: translateY(0); } to { transform: translateY(-40px); } }

08숫자 카운트업

어떤 움직임인가

보이는 순간 0에서 목표 숫자까지 올라갑니다.

숫자 카운트업
새 창에서 열기

08 · 숫자 카운트업 — 측정 누적면적 3.302% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

1.2s천 단위 콤마% 붙이기

언제 쓰나

@property로 정수 타입을 등록해 JS 없이도 소수점 없이 뚝뚝 올라갑니다.

@property --num {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}
.stat__num {
  --num: 0;
  counter-reset: num var(--num);
  animation: count-up 1.2s linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
  &::after { content: counter(num); }
}
@keyframes count-up {
  from { --num: 0; }
  to   { --num: 1280; }
}

09선이 그어지는 타임라인

무엇이 보이나

스크롤한 만큼 세로 선이 그어지고, 지나간 점이 밝아집니다.

선이 그어지는 타임라인
새 창에서 열기

09 · 선이 그어지는 타임라인 — 측정 누적면적 0.553% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

2px가로형점 없이

어디에 쓰나

점은 배경색을 바꾸지 않고 opacity와 transform: scale만 씁니다.

.timeline__dot {
  opacity: .28;
  @supports (animation-timeline: view()) {
    animation: dot-light 600ms ease-out both;
    animation-timeline: view();
    animation-range: entry 30% cover 60%;
  }
}
@keyframes dot-light {
  from { opacity: .28; transform: scale(1); }
  to   { opacity: 1; transform: scale(1.3); }
}

어디서 깨지는가 — 함정 셋

브라우저 지원

첫 번째는 브라우저 지원입니다. 스크롤 타임라인은 아직 모든 브라우저가 같지 않습니다. Chrome·Edge는 115부터, Safari는 26부터 animation-timeline: view()와 scroll()을 정식 지원하고, Firefox는 2026년 9월 현재 정식판에서 플래그 뒤에 있습니다. caniuse의 지원 현황에서 최신 상태를 확인할 수 있습니다. 그래서 아홉 개 전부 @supports not (animation-timeline: view()) 블록으로 미지원 브라우저에서는 움직이지 않고 그냥 보여주는 규칙을 넣었습니다. 감추는 게 아니라 지금은 그게 진짜 전부입니다.

조상의 overflow hidden

두 번째는 조상 요소의 overflow: hidden입니다. view() 타임라인은 요소가 스크롤 가능한 조상 안에서 지나가는 위치를 재는데, 애니메이션이 걸린 요소와 뷰포트 사이 어딘가에 overflow: hidden이 끼면 컨테이너가 끊겨 타임라인이 시작하지 않거나 멈춘 것처럼 보입니다. 이 zip의 데모 무대에 있는 overflow: hidden은 화면 밖 html·body에만 걸었고, 애니메이션이 걸린 요소와 그 부모 사이에는 걸지 않았습니다.

모바일

css 마우스 스크롤 애니메이션은 마우스 휠이 있는 데스크톱 이야기고, 세 번째는 모바일입니다. iOS 26 이전의 Safari는 animation-timeline을 지원하지 않아 대체 규칙이 그대로 최종 화면으로 남고, 안드로이드 크롬은 지원하지만 관성 스크롤 중에는 값이 듬성듬성 갱신돼 데스크톱만큼 매끄럽지 않습니다. 아홉 개 파일 모두 이 세 가지를 실제로 렌더해 겪고 다듬었고, 압축 비밀번호는 x5xz5uh4입니다.

08 카운트업처럼 숫자를 스크롤에 묶으면 이 세 함정이 한꺼번에 걸리는데, 스크롤과 떼어 놓고 숫자만 올리는 방법은 CSS 숫자 증가 애니메이션에 따로 모아 뒀습니다.

접근성

동작 줄이기를 켰을 때

시스템에서 "동작 줄이기"를 켠 사용자를 위해 아홉 개 전부 prefers-reduced-motion: reduce에서 애니메이션을 끄고, 움직이지 않아도 뜻이 통하는 최종 상태만 남깁니다. 01·03·04는 결과(등장·닦임·이미지)를 즉시 보여주고, 05·06은 진행 바와 헤더를 이미 채워진 모습으로 고정하며, 08은 숫자를 목표값으로 고정하고, 09는 선과 점을 이미 켜진 모습으로 둡니다. 이 미디어 쿼리의 세부는 MDN prefers-reduced-motion에서 볼 수 있습니다.

@media (prefers-reduced-motion: reduce) {
  .card, .headline, .frame__curtain, .bar, .head,
  .stat__num, .timeline__line, .timeline__dot {
    animation: none !important;
  }
}

다른 CSS 스크롤 애니메이션·SCSS 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 뭘 하는 곳인지는 소개에 있습니다.

FAQ

css 스크롤 애니메이션 효과에 왜 IntersectionObserver 대신 CSS를 쓰나요?

IntersectionObserver는 JS로 요소의 등장을 감지해 클래스를 토글하는 방식이라 스크립트가 로드되기 전에는 아무 일도 없거나, 반대로 처음부터 숨겨 둔 요소가 스크립트 실패 시 영영 안 보일 위험이 있습니다. animation-timeline은 브라우저가 스크롤 위치를 직접 애니메이션 값으로 쓰므로 JS가 아예 없어도 항상 같은 결과를 냅니다. 다만 지원 브라우저가 한정적이라 @supports 대체 규칙은 꼭 같이 둬야 합니다. 스크롤이 아니라 열림·닫힘이 방아쇠라면 JS를 최소로 줄이는 길이 또 다른데, 그 경우는 @starting-style을 쓰는 CSS 모달 팝업 애니메이션이 같은 문제를 다른 문법으로 풉니다.

Safari에서도 그대로 움직이나요?

Safari 26부터는 그대로 움직입니다. 그 이전 버전은 animation-timeline을 모르기 때문에, 이 zip의 아홉 개는 모두 미지원 시 "움직이지 않고 그냥 보여주기" 대체 규칙을 갖고 있습니다. 지원 폭은 계속 바뀌므로 배포 전 caniuse에서 최신 상태를 다시 확인하는 걸 권합니다.

React 컴포넌트에서도 같은 스크롤 효과가 되나요?

됩니다. 스크롤 타임라인은 JS가 아니라 CSS 엔진이 계산하므로 react/ 폴더의 컴포넌트도 SCSS 모듈 안에 같은 animation-timeline·animation-range 규칙을 그대로 두면 됩니다. 컴포넌트는 $duration·$color처럼 변수 3개만 props로 받아 CSS 변수로 넘기고, 스크롤 감지 로직은 하나도 새로 만들 필요가 없습니다.

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

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