CSS 숫자 증가 애니메이션 9종 — 한 줄 복붙으로
CSS 숫자 증가 애니메이션은 0이나 시작값에서 목표 숫자까지 오르는 텍스트를 CSS 속성 하나로 세는 방식입니다. 아홉 개 중 하나는 거꾸로 세고, 또 하나는 스크롤해서 보이기 전엔 0에서 멈춰 있습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 @property 정수 카운트업
- 02 천 단위 콤마 자동 삽입
- 03 소수점 포맷 카운트업
- 04 통화 기호 붙은 금액 카운트업
- 05 퍼센트 링과 숫자 동기화
- 06 통계 카드 3개 순차 카운트
- 07 화면 진입 시 시작
- 08 오도미터 롤링 숫자
- 09 카운트다운 타이머
아홉 개는 인기순이 아니라 숫자 하나가 화면에서 겪는 순서로 놓았습니다. 그냥 세는 것(01) → 자릿수가 커져 읽기 어려워지는 것을 가르는 것(02) → 정밀함을 더하는 것(03) → 단위를 붙이는 것(04) → 다른 시각 요소와 값을 나누는 것(05) → 여러 개를 늘어놓는 것(06) → 언제 셀지 스스로 판단하는 것(07) → 완전히 다른 방식으로 세는 것(08) → 방향을 뒤집는 것(09) 순입니다. 코드 조각마다 실제로 움직이게 만드는 부분만 남겼고, 나머지 전체 파일은 zip에 들어 있습니다.
01@property 정수 카운트업
숫자가 0에서 목표값까지 오르는 동안 실제로 애니메이션되는 건 숫자 텍스트가 아니라 @property로 등록한 정수 커스텀 속성입니다. 그 값을 counter()로 그대로 그려서 진짜 자릿수가 보이고, 회원 가입자 수나 방문자 수 같은 실적 카드에 씁니다.
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.num {
--num: 0;
counter-reset: num var(--num);
animation: count-up 2s ease-out infinite;
&::before { content: counter(num); }
}
@keyframes count-up {
0% { --num: 0; }
65% { --num: 12480; }
100% { --num: 12480; }
}
02천 단위 콤마 자동 삽입
12,483처럼 천 단위 콤마가 자동으로 붙으려면 천의 자리와 나머지를 서로 다른 @property 정수로 나눠 세어야 합니다. 나머지는 @counter-style의 pad로 항상 세 자리를 채워야 "12,3" 같은 오류가 안 생깁니다.
@counter-style padded-3 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
pad: 3 '0';
}
.num {
counter-reset: k var(--k) n var(--n);
&::before { content: counter(k) "," counter(n, padded-3); }
}
03소수점 포맷 카운트업
4.98 같은 평점은 정수부와 소수부를 각각 다른 @property로 동시에 세고, 소수부만 두 자리로 패딩해서 소수점 뒤에 붙이는 방식으로 만듭니다. 평균 별점이나 온도처럼 소수 한두 자리가 의미를 갖는 값에 씁니다.
@counter-style padded-2 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
pad: 2 '0';
}
.num {
counter-reset: whole var(--whole) frac var(--frac);
&::before { content: counter(whole) "." counter(frac, padded-2); }
}
04통화 기호 붙은 금액 카운트업
통화 기호는 ::before로 고정해 두고 숫자만 ::after의 counter()로 애니메이션하면, 자릿수가 한 자리에서 네 자리로 늘어나도 기호 위치가 안 밀립니다. 장바구니 합계나 결제 총액 요약 카드에 씁니다.
.num {
counter-reset: num var(--num);
animation: count-up .6s ease-out infinite;
&::before { content: "$"; margin-right: 2px; }
&::after { content: counter(num); }
}
05퍼센트 링 동기화
정수 하나가 두 군데를 동시에 구동합니다. counter(p)로 숫자를, calc(var(--p) * 1%)로 conic-gradient 링의 채움 각도를 같이 바꾸니 숫자와 링이 어긋날 일이 없습니다. 목표 달성률이나 저장 공간 사용률에 씁니다.
.ring {
--p: 0;
background: conic-gradient($color calc(var(--p) * 1%), transparent 0);
animation: fill-up 2s ease-out infinite;
}
.ring__num {
counter-reset: p var(--p);
&::before { content: counter(p) "%"; }
}
06통계 카드 3개 순차 카운트
카드 세 장이 한꺼번에 채워지면 뭘 먼저 봐야 할지 모릅니다. animation-delay로 0.15초씩 늦게 시작시켜 순서대로 훑어보게 만들고, 대시보드 요약 카드나 랜딩 페이지 실적 3종에 씁니다.
.card:nth-child(1) .num {
animation: count-a 2s ease-out infinite;
}
.card:nth-child(2) .num {
animation: count-b 2s ease-out infinite;
animation-delay: .15s;
}
.card:nth-child(3) .num {
animation: count-c 2s ease-out infinite;
animation-delay: .3s;
}
07화면 진입 시 시작
숫자가 로드되자마자 다 세어져 있으면 스크롤해서 내려간 사람은 그 순간을 못 봅니다. 카드가 뷰포트에 60% 이상 들어오는 순간에만 IntersectionObserver가 카운트를 시작시켜, 그 전엔 0에 멈춰 있습니다.
var card = document.querySelector('.card');
new IntersectionObserver(function (es) {
card.classList.toggle('is-counting', es[0].isIntersecting);
}, { threshold: .6 }).observe(card);
// .num 은 기본값이 animation-play-state: paused
// .is-counting .num 에서만 running 으로 바뀐다
08오도미터 롤링 숫자
자릿수마다 0~9를 세로로 늘어놓은 스트립을 두고, 목표 숫자만큼 translateY로 굴리면 각 자리가 기계식 계기판처럼 따로 돕니다. 셋을 붙이면 "128"처럼 읽히고, 레트로 스타일 카운터나 키오스크 대기번호에 씁니다.
.digit {
height: 44px;
overflow: hidden; // 스트립에서 지금 칸 하나만 보인다
}
.strip {
transform: translateY(0); // 값은 아래 keyframes 가 직접 준다
animation: roll-to-8 2s cubic-bezier(.2, 1.4, .4, 1) infinite;
span { height: 44px; }
}
@keyframes roll-to-8 {
0% { transform: translateY(0); }
60%, 100% { transform: translateY(-80%); } // 8번째 칸(-80%) 에서 멈춘다
}
09카운트다운 타이머
@property 정수를 목표값이 아니라 0을 향해 거꾸로 세면 마감까지 남은 시간이나 재고를 표현할 수 있습니다. 같은 값으로 숫자 아래 진행 막대의 길이도 함께 줄이고, 애니메이션 후반부에서는 색도 경고색으로 바꿔 다급함을 더합니다.
@keyframes count-down {
0% { --t: 9; color: $color; }
60% { --t: 3; color: $color; }
85% { --t: 0; color: $error; }
100% { --t: 0; color: $error; }
}
.bar::after {
transform: scaleX(calc(var(--t) / 9)); // 같은 --t 로 막대 길이도 함께 줄어든다
}
어디서 깨지는가 — 함정 셋
@property로 만든 카운터 아홉 개 모두 같은 전제가 있습니다. @property 자체가 Chrome·Edge는 85부터, Safari는 16.4부터, Firefox는 128부터 지원됩니다(caniuse의 @property 지원표). 그보다 오래된 브라우저에서는 애니메이션 없이 최종 숫자만 바로 나타나므로 레이아웃이 깨지지는 않지만, 세는 느낌 자체가 사라집니다.
두 번째 함정은 counter()로 그린 숫자가 진짜 텍스트가 아니라는 점입니다. 마우스로 드래그해도 선택되지 않고 스크린 리더도 읽지 않는다고 MDN의 생성된 콘텐츠 문서에 나와 있습니다. 그래서 이 zip의 아홉 개 데모는 전부 시각적으로만 숨긴 실제 숫자 텍스트를 하나씩 더 심어 뒀습니다. 그 대비책까지 반영해 둔 아홉 개 파일을 열려면 압축 비밀번호 j9xejany을 아래 버튼 옆 칸에 넣으면 됩니다.
세 번째는 07처럼 뷰포트 진입에 반응하는 카운터의 재시작 문제입니다. animation-play-state: paused로 멈춰 둔 애니메이션은 클래스만 다시 붙여 running으로 돌려도 멈췄던 지점에서 이어질 뿐 0으로 되돌아가지 않습니다. 카드가 화면 밖으로 나갔다 다시 들어올 때마다 처음부터 다시 세게 하려면 리플로우를 강제로 한 번 넣어 애니메이션을 재시작해야 합니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 애니메이션 없이 최종 숫자로 즉시 멈춥니다 — 숫자 자체가 사라지는 게 아니라 세는 움직임만 빠집니다. counter() 생성 숫자는 스크린 리더가 못 읽으므로 아홉 개 모두 시각적으로 숨긴 실제 숫자 텍스트를 화면 밖에 따로 둡니다. 09처럼 색으로 다급함을 표현하는 항목은 색이 아니라 숫자 자체가 이미 정보를 담고 있는지 늘 확인하십시오. 미디어 쿼리를 실제로 어디에 거는지는 MDN 문서의 예제를 참고하십시오.
스크롤에 반응하는 다른 트리거들은 이 편에서 다 담지 못했습니다 — CSS 스크롤 애니메이션 9종에 더 있습니다. 이 사이트가 다루는 CSS 소재 전부는 CSS 애니메이션 카테고리 목록에서, 만든 사람 이야기는 소개 페이지에서 확인하십시오.
FAQ
숫자 카운터에 꼭 JS가 필요합니까?
아닙니다. 아홉 개 모두 세는 동작 자체는 @property와 CSS 애니메이션 규칙만으로 끝납니다. 07 하나만 언제 시작할지 판단하려고 IntersectionObserver 몇 줄을 곁들였을 뿐, 숫자를 증가시키는 계산은 CSS 밖으로 나가지 않습니다. 다만 caniuse의 @property 지원표는 미리 확인해 두는 게 좋습니다.
counter()로 그린 숫자를 복사할 수 없는데 괜찮습니까?
화면에 보여주는 용도라면 문제없지만, 사용자가 숫자를 직접 복사해야 하는 화면(가격표 등)이라면 이 zip에 함께 넣어 둔 시각적으로 숨긴 실제 텍스트 span을 화면에 드러내는 쪽으로 바꾸십시오.
3자리가 넘는 수(만 단위 등)에도 오도미터 방식을 쓸 수 있습니까?
네, 08의 자릿수 컬럼을 원하는 만큼 늘리면 됩니다. 컬럼을 늘려도 translateY 계산 방식은 그대로이고 컬럼 개수만 늘어나며, react/ 폴더의 컴포넌트는 digits 배열 길이를 그대로 따라가도록 만들어 뒀습니다.