CSS 스크롤 애니메이션 9종 — AOS·GSAP 없이 JS 0줄로
CSS 스크롤 애니메이션 아홉 개를 모았습니다. AOS·GSAP 없이 브라우저 내장 animation-timeline 하나로 스크롤 위치 자체를 애니메이션 시계로 씁니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 발견되는 순서가 아니라 배워야 하는 순서로 놓았습니다. 01~04는 요소 하나가 뷰포트에 들어오는 순간을 다루는 view() 타임라인이고, 05~07은 문서 전체 스크롤 진행률을 다루는 scroll() 타임라인입니다. 이 둘을 섞어 쓰면 뭐가 뭘 움직이는지 헷갈리므로 항목마다 둘 중 하나만 씁니다. 08~09는 앞의 두 타임라인 위에 카운터·다중 요소 같은 응용을 얹은 것이라 맨 뒤에 뒀습니다.
01떠오르며 등장
카드가 화면에 들어오면 아래에서 24px 올라오며 페이드인합니다. 섹션 제목이나 카드처럼 거의 모든 등장에 씁니다.
.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순서대로 등장
목록 항목이 하나씩 나타납니다. 항목마다 같은 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글자 닦아내기
제목이 왼쪽에서 오른쪽으로 닦여 나옵니다. 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이미지 커튼
이미지를 덮은 색 판이 오른쪽으로 걷히며 이미지가 드러납니다. 포트폴리오나 상품 사진 앞에 씁니다.
.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읽기 진행 바
문서를 얼마나 읽었는지 상단 바가 채워집니다. 여기서만 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헤더 줄어들기
스크롤하면 고정 헤더가 얇아집니다. 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패럴랙스
뒤 배경이 앞 내용보다 느리게 움직여 깊이가 생깁니다. 두 레이어를 각각 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에서 목표 숫자까지 올라갑니다. @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선이 그어지는 타임라인
스크롤한 만큼 세로 선이 그어지고, 지나간 점이 밝아집니다. 점은 배경색을 바꾸지 않고 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입니다. view() 타임라인은 요소가 스크롤 가능한 조상 안에서 지나가는 위치를 재는데, 애니메이션이 걸린 요소와 뷰포트 사이 어딘가에 overflow: hidden이 끼면 컨테이너가 끊겨 타임라인이 시작하지 않거나 멈춘 것처럼 보입니다. 이 zip의 데모 무대에 있는 overflow: hidden은 화면 밖 html·body에만 걸었고, 애니메이션이 걸린 요소와 그 부모 사이에는 걸지 않았습니다.
세 번째는 모바일입니다. iOS 26 이전의 Safari는 animation-timeline을 지원하지 않아 대체 규칙이 그대로 최종 화면으로 남고, 안드로이드 크롬은 지원하지만 관성 스크롤 중에는 값이 듬성듬성 갱신돼 데스크톱만큼 매끄럽지 않습니다. 아홉 개 파일 모두 이 세 가지를 실제로 렌더해 겪고 다듬었고, 압축 비밀번호는 x5xz5uh4입니다.
접근성
시스템에서 "동작 줄이기"를 켠 사용자를 위해 아홉 개 전부 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
스크롤 애니메이션에 왜 IntersectionObserver 대신 CSS를 쓰나요?
IntersectionObserver는 JS로 요소의 등장을 감지해 클래스를 토글하는 방식이라 스크립트가 로드되기 전에는 아무 일도 없거나, 반대로 처음부터 숨겨 둔 요소가 스크립트 실패 시 영영 안 보일 위험이 있습니다. animation-timeline은 브라우저가 스크롤 위치를 직접 애니메이션 값으로 쓰므로 JS가 아예 없어도 항상 같은 결과를 냅니다. 다만 지원 브라우저가 한정적이라 @supports 대체 규칙은 꼭 같이 둬야 합니다.
Safari에서도 그대로 움직이나요?
Safari 26부터는 그대로 움직입니다. 그 이전 버전은 animation-timeline을 모르기 때문에, 이 zip의 아홉 개는 모두 미지원 시 "움직이지 않고 그냥 보여주기" 대체 규칙을 갖고 있습니다. 지원 폭은 계속 바뀌므로 배포 전 caniuse에서 최신 상태를 다시 확인하는 걸 권합니다.
React 컴포넌트에서도 같은 스크롤 효과가 되나요?
됩니다. 스크롤 타임라인은 JS가 아니라 CSS 엔진이 계산하므로 react/ 폴더의 컴포넌트도 SCSS 모듈 안에 같은 animation-timeline·animation-range 규칙을 그대로 두면 됩니다. 컴포넌트는 $duration·$color처럼 변수 3개만 props로 받아 CSS 변수로 넘기고, 스크롤 감지 로직은 하나도 새로 만들 필요가 없습니다.