CSS 아이콘 애니메이션 9종 — SVG 로고도 한 줄 복붙으로
CSS 아이콘 애니메이션은 재생·알림·새로고침처럼 화면에 흔한 인터페이스 아이콘을 인라인 SVG로 그려 움직이게 만드는 것이다. 문자 글리프 대신 좌표가 있는 path를 쓰므로 서체가 바뀌어도 모양이 깨지지 않는다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
순서는 화면에서 아이콘이 맡는 역할을 따라간다. 배지·로고처럼 가만히 있다가 시선을 끄는 장식(01·02)으로 시작해, 상태에 따라 모양이 바뀌는 아이콘 버튼(03~07)을 지나, 텍스트 옆에서 조용히 거드는 장식(08·09)으로 끝난다. 아홉 개 모두 transform·opacity·stroke-dash 계열 속성만 움직인다. 선을 그리는 세 개(01·02·09)는 stroke-dasharray와 dashoffset을, 나머지는 transform이나 opacity를 쓰는데, 둘 다 top이나 width와 달리 레이아웃을 다시 계산하지 않는 페인트 단계 속성이다. MDN stroke-dasharray 문서에서 확인할 수 있다.
01스트로크 그리기
별 모양 path(pathLength="100")의 윤곽선이 stroke-dashoffset 100에서 0으로 그어지고, 잠깐 멈췄다가 반대 방향(-100)으로 지워지며 반복된다. "새 기능" 배지처럼 옆에 글자가 있는 장식 아이콘이라 svg에는 aria-hidden="true"만 붙인다.
.badge__star {
fill: none; stroke: $color; stroke-width: 1.6;
stroke-dasharray: 100; // pathLength="100" 이라 %처럼 다룬다
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
80% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
02로고 리빌
같은 다이아몬드 path를 두 장 겹친다. 위는 선만 그려지는 버전, 아래는 채운 버전으로 처음엔 opacity 0이다. 선이 다 그려지면 채운 버전이 드러나고, 다시 선만 남기고 처음으로 돌아간다.
.mark__line { stroke-dasharray: 100; animation: line-draw $duration $easing infinite; }
@keyframes line-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
55% { stroke-dashoffset: 0; opacity: 1; }
85% { opacity: 0; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes fill-in {
0%, 55% { opacity: 0; }
75%, 85% { opacity: 1; }
}
03아이콘 모핑
재생 삼각형과 일시정지 막대를 각각 <svg>로 만들어 같은 grid-area(1/1)에 겹치고 opacity와 scale(.85→1)로 서로 바꾼다. 아이콘만 있는 버튼이라 svg 안 <title>을 상태에 맞게 바꾸고 버튼은 aria-labelledby로 그 title을 가리킨다.
.ic {
grid-area: 1 / 1; fill: currentColor;
transition: opacity $duration $easing, transform $duration $easing;
transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--play { opacity: 1; }
.ic--pause { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--play { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--pause { opacity: 1; transform: scale(1); }
04알림종 흔들림
종 path를 감싼 <g>가 꼭지점을 축으로 좌우로 흔들리고, 오른쪽 위 배지 원이 같은 타이밍에 커졌다 작아진다. 처음엔 흔드는 구간을 루프의 15%로 짧게 잡았더니 측정에서 움직인 프레임이 4장뿐이라 30%로 늘렸다.
.bell { transform-box: fill-box; transform-origin: 50% 0%; }
.iconbtn.is-demo .bell { animation: shake $dur-loop $easing infinite; }
@keyframes shake {
0%, 30%, 100% { transform: rotate(0); }
4% { transform: rotate(8deg); }
8% { transform: rotate(-8deg); }
16% { transform: rotate(-6deg); }
24% { transform: rotate(-3deg); }
}
05새로고침 회전
화살표 두 개로 된 새로고침 도형은 24×24 뷰박스의 정중앙에 있지 않다. <g>에 transform-box: fill-box를 주면 뷰박스가 아니라 이 도형 자신의 경계 상자를 축으로 돌아, 클릭 한 번에 자연스럽게 한 바퀴 돈다.
.refresh {
transform-box: fill-box;
transform-origin: 50% 50%;
}
.iconbtn.is-spinning .refresh { animation: spin-once $duration $easing; }
@keyframes spin-once { to { transform: rotate(360deg); } }
06메뉴 아이콘 모핑
점 3개짜리 아이콘과 X 아이콘을 각각 <svg>로 만들어 같은 칸에 겹치고 opacity로만 넘긴다. 3줄이 X로 겹쳐 도는 햄버거 메뉴와 달리, 두 개의 완전히 다른 도형이 자리를 바꾸는 방식이라 회전이 전혀 없다.
.ic { grid-area: 1 / 1; fill: currentColor; transition: opacity $duration $easing; }
.ic--dots { opacity: 1; }
.ic--x { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--dots { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--x { opacity: 1; }
.iconbtn.is-demo .ic--x { animation: x-loop $dur-loop $easing infinite; }
@keyframes x-loop { 40%, 60% { opacity: 1; } }
07하트 펄스
윤곽선 하트와 채운 하트를 겹쳐 두고, 누르면 채운 쪽이 드러나며 두 아이콘 모두 scale이 1에서 1.3까지 튕겼다가 돌아온다. 버튼은 aria-pressed를 토글하고 aria-label="좋아요"로 이름을 밝힌다.
.ic {
grid-area: 1 / 1;
transition: opacity $duration $easing, transform $duration $easing;
transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--fill { fill: $color; opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--fill { opacity: 1; transform: scale(1); }
@keyframes fill-loop { 30% { transform: scale(1.3); } 40%, 60% { opacity: 1; } }
08화살표 이동
아래를 가리키는 화살표 하나가 제자리에서 위아래로 8px 오간다. "더 보기" 글자 옆의 장식이라 svg는 aria-hidden이고, 정보는 글자가 전부 갖고 있다.
$duration: $dur-loop; // 2s
$easing: $ease-spring;
.cue__arrow { animation: bob $duration $easing infinite; }
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY($sp-2); }
}
09체크 그리기
polyline 체크가 stroke-dashoffset 100에서 0으로 그어졌다가 반대편으로 지워지며 반복된다. 처음엔 아이콘을 26px로 두었더니 변한 픽셀 면적이 기준(0.08%)에 못 미치는 0.04%로 나와, 64px로 키우고 선도 2.4px에서 3.2px로 굵혔다.
.confirm__check {
stroke-dasharray: 100;
animation: draw $duration $easing infinite;
}
@keyframes draw {
0% { stroke-dashoffset: 100; }
55% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
어디서 깨지는가 — 함정 하나
가장 자주 걸리는 함정은 회전축이다. <g>나 <path>를 그냥 rotate()하면 축이 그 요소가 속한 SVG 뷰포트의 (0,0)~(24,24) 기준으로 잡혀, 도형이 한쪽으로 쏠려 그려진 05 새로고침·04 알림종에서 축이 엉뚱한 자리로 튀었다. transform-box: fill-box를 더해야 도형 자신의 경계 상자가 축이 된다. 이 속성은 크롬 64·엣지 79·파이어폭스 55·사파리(iOS 포함) 11부터 지원해 caniuse 표로 확인해도 막힐 일이 거의 없다. 04와 09는 측정값이 기준에 못 미쳐 한 번씩 고쳤는데, 그때 고친 값 그대로 들어간 zip의 압축 비밀번호가 datzbfgj 이고 이 카드로 받을 수 있다.
또 하나는 글리프다. 체크나 화살표를 텍스트로 ✓ →처럼 넣으면 운영체제 서체에 따라 아예 안 보이거나 다른 모양으로 나온다. 아홉 개 전부 SVG path로 그린 이유다.
접근성
아이콘 버튼 다섯 개(03·04·05·06·07)는 이름이 없으면 스크린리더에 "버튼"으로만 읽혀 무슨 동작인지 알 수 없다. 03·06은 svg 안 <title>과 버튼의 aria-labelledby로 상태가 바뀔 때마다 이름을 새로 갱신하고, 04·05·07은 aria-label로 고정된 이름을 밝힌다.
<button aria-labelledby="t3">
<svg><title id="t3">재생</title><path d="M8 5l11 7-11 7z"/></svg>
</button>
나머지 넷(01·02·08·09)은 옆에 보이는 글자가 이미 뜻을 전달하는 장식이라 svg에 aria-hidden="true"만 붙인다.
<svg aria-hidden="true"><path class="badge__star" d="M12 2l2.9 6.9L22 9.3z"/></svg> 새 기능
prefers-reduced-motion: reduce에서는 아홉 개 전부 애니메이션을 끄고 완성된 모양(체크가 다 그려진 상태, 채워진 하트)만 남긴다. 움직임을 끄고 결과까지 지우면 "저장됐는지" 같은 정보 자체가 사라지기 때문이다. 사이트의 다른 CSS 애니메이션은 CSS 카테고리에, 이 블로그를 만든 이유는 소개에 있다.
FAQ
아이콘을 SVG 대신 웹폰트로 써도 되나요?
되긴 하지만 웹폰트는 로드가 끝나기 전 순간 깨진 문자가 보이거나 아예 안 뜰 수 있다. 인라인 SVG는 파일 요청이 없어 zip을 그대로 열어도 바로 나온다.
transform-box: fill-box가 없으면 어떻게 되나요?
rotate()나 scale()의 축이 SVG 뷰포트 좌상단 기준으로 잡혀, 도형이 뷰박스 한쪽에 몰려 있을 때 제자리가 아닌 곳에서 돈다. 05 새로고침이 그 예로, fill-box를 빼고 렌더링하면 화살표 도형이 통째로 옆으로 튕기듯 돈다.
아이콘 버튼 다섯 개는 React에서 상태를 어떻게 관리하나요?
zip의 react/ 폴더는 컴포넌트마다 useState로 playing·open·liked 같은 상태를 하나씩 관리하고, 클래스 토글 대신 className 분기로 opacity와 aria 속성을 함께 바꾼다.