들어가며: SVG 선은 어떻게 조금씩 나타날까?
로고, 서명, 아이콘, 지도 경로가 마치 펜으로 그려지는 것처럼 나타나는 애니메이션을 본 적이 있을 거예요.
처음 보면 SVG 경로를 프레임마다 새로 계산하는 것처럼 느껴집니다.
하지만 기본 원리는 의외로 단순합니다.
선을 실제로 조금씩 만드는 것이 아니라
완성된 선 위에 긴 대시 패턴을 올리고
그 대시의 위치를 움직여 선이 드러나게 만든다
이때 사용하는 핵심 속성이 두 개입니다.
stroke-dasharray
stroke-dashoffset
이번 글에서는 속성을 복사해서 쓰는 데 그치지 않고, 왜 이 두 값으로 그리는 효과가 만들어지는지부터 CSS, JavaScript, React, 스크롤 연동 활용까지 차근차근 알아보겠습니다.
먼저 SVG의 fill과 stroke 이해하기
SVG 도형은 크게 fill과 stroke로 표현됩니다.
1
2
3
4
5
6
7
8
<svg viewBox="0 0 200 100">
<path
d="M20 70 Q100 10 180 70"
fill="none"
stroke="black"
stroke-width="4"
/>
</svg>
fill: 도형 내부를 채우는 색stroke: 도형의 외곽선 또는 경로를 따라 그려지는 선
선이 그려지는 애니메이션은 기본적으로 stroke를 움직이는 효과입니다.
그래서 path에 fill="none"을 주고 stroke가 보이게 해야 원리를 관찰하기 쉽습니다.
1
2
3
4
5
6
<path
d="M20 70 Q100 10 180 70"
fill="none"
stroke="#2563eb"
stroke-width="4"
/>
로고처럼 내부 면도 필요한 도형이라면 먼저 stroke를 그리고, 마지막에 fill의 opacity를 올리는 식으로 조합할 수 있습니다.
stroke-dasharray란?
stroke-dasharray는 선을 대시와 빈칸의 반복 패턴으로 바꿉니다.
1
2
3
.path {
stroke-dasharray: 10 5;
}
뜻은 다음과 같습니다.
10만큼 선을 그림
5만큼 비움
10만큼 선을 그림
5만큼 비움
반복
예를 들어 아래처럼 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
<svg viewBox="0 0 240 40">
<line
x1="10"
y1="20"
x2="230"
y2="20"
stroke="black"
stroke-width="4"
stroke-dasharray="12 8"
/>
</svg>
값이 하나라면 대시와 빈칸에 같은 패턴이 반복됩니다.
1
2
3
.path {
stroke-dasharray: 10;
}
두 값을 쓰면 각각 대시와 빈칸의 길이가 됩니다.
1
2
3
.path {
stroke-dasharray: 10 5;
}
MDN에서 설명하듯 stroke-dasharray는 SVG 도형의 윤곽선을 그릴 때 사용할 대시와 간격의 패턴을 정의합니다.
stroke-dashoffset이란?
stroke-dashoffset은 대시 패턴이 시작되는 위치를 옮깁니다.
1
2
3
4
.path {
stroke-dasharray: 10 5;
stroke-dashoffset: 8;
}
대시의 모양은 그대로지만 시작점이 밀립니다.
이를 계속 바꾸면 대시가 선을 따라 이동하는 것처럼 보입니다.
1
2
3
4
5
6
7
8
9
10
@keyframes move-dash {
to {
stroke-dashoffset: -30;
}
}
.path {
stroke-dasharray: 10 5;
animation: move-dash 1s linear infinite;
}
이것만으로도 움직이는 점선이나 선택 영역의 marching ants 효과를 만들 수 있습니다.
하지만 우리가 원하는 것은 점선 이동이 아니라, 처음에는 보이지 않던 하나의 긴 선이 점점 드러나는 효과입니다.
그려지는 효과의 핵심 원리
경로 전체 길이가 300이라고 가정해봅시다.
stroke-dasharray에 300을 주면 선 전체를 덮는 하나의 긴 대시와 그 뒤의 긴 빈칸이 만들어집니다.
1
2
3
.path {
stroke-dasharray: 300;
}
그리고 stroke-dashoffset도 300으로 설정합니다.
1
2
3
4
.path {
stroke-dasharray: 300;
stroke-dashoffset: 300;
}
그러면 긴 대시가 경로 바깥으로 밀려 선이 보이지 않게 됩니다.
이제 offset을 0으로 애니메이션합니다.
1
2
3
4
5
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
흐름은 다음과 같습니다.
경로 길이 = 300
dasharray = 300
→ 경로 전체를 덮을 만큼 긴 대시 생성
dashoffset = 300
→ 긴 대시를 밀어서 경로를 숨김
dashoffset: 300 → 0
→ 긴 대시가 원래 자리로 돌아옴
→ 선이 시작점부터 나타남
실제로 경로 자체가 생성되는 것은 아닙니다.
완성된 stroke의 노출 위치를 움직여 그리는 것처럼 보이게 만드는 착시입니다.
가장 간단한 CSS 예제
경로 길이를 알고 있다고 가정하면 CSS만으로 만들 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
<svg class="signature" viewBox="0 0 240 100" aria-label="서명 애니메이션">
<path
class="signature__path"
d="M20 70 C50 15 80 15 95 70 S145 120 170 55 S205 20 220 65"
fill="none"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.signature {
width: min(100%, 480px);
color: #2563eb;
}
.signature__path {
stroke-dasharray: 400;
stroke-dashoffset: 400;
animation: draw-signature 2s ease-out forwards;
}
@keyframes draw-signature {
to {
stroke-dashoffset: 0;
}
}
forwards는 애니메이션이 끝난 뒤 마지막 상태를 유지합니다.
이 값이 없으면 애니메이션 종료 후 다시 숨겨진 초기 상태로 돌아갈 수 있습니다.
1
animation: draw-signature 2s ease-out forwards;
또 stroke-linecap="round"를 사용하면 펜 끝처럼 둥글게 보여 손으로 그리는 느낌이 조금 더 자연스러워집니다.
경로 길이는 어떻게 알 수 있을까?
위 예제에서는 경로 길이를 대략 400으로 가정했습니다.
간단한 아이콘이라면 충분히 크게 잡아도 효과가 보일 수 있지만, 정확한 결과를 원한다면 실제 길이를 구하는 편이 좋습니다.
SVG geometry 요소에는 getTotalLength()가 있습니다.
1
2
3
4
const path = document.querySelector(".signature__path");
const length = path.getTotalLength();
console.log(length);
MDN에 따르면 getTotalLength()는 브라우저가 계산한 경로의 전체 길이를 SVG 사용자 단위로 반환합니다.
이 값을 CSS 변수로 전달할 수 있습니다.
1
2
3
4
const path = document.querySelector(".signature__path");
const length = path.getTotalLength();
path.style.setProperty("--path-length", String(length));
1
2
3
4
5
.signature__path {
stroke-dasharray: var(--path-length);
stroke-dashoffset: var(--path-length);
animation: draw-signature 2s ease-out forwards;
}
이제 경로가 바뀌어도 JavaScript가 실제 길이를 측정하므로 수동으로 값을 다시 맞출 필요가 줄어듭니다.
JavaScript로 애니메이션 다시 실행하기
버튼을 눌렀을 때 다시 그리게 만들 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
<button type="button" id="replay-button">다시 그리기</button>
<svg viewBox="0 0 240 100">
<path
id="drawing-path"
d="M20 70 C50 15 80 15 95 70 S145 120 170 55 S205 20 220 65"
fill="none"
stroke="#2563eb"
stroke-width="4"
stroke-linecap="round"
/>
</svg>
1
2
3
#drawing-path {
transition: stroke-dashoffset 2s ease-out;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const path = document.querySelector("#drawing-path");
const replayButton = document.querySelector("#replay-button");
const length = path.getTotalLength();
path.style.strokeDasharray = String(length);
path.style.strokeDashoffset = String(length);
function play() {
path.style.transition = "none";
path.style.strokeDashoffset = String(length);
path.getBoundingClientRect();
path.style.transition = "stroke-dashoffset 2s ease-out";
path.style.strokeDashoffset = "0";
}
replayButton.addEventListener("click", play);
play();
중간의 getBoundingClientRect()가 낯설 수 있습니다.
브라우저는 연속된 스타일 변경을 한 번에 합칠 수 있습니다.
초기 상태를 설정한 뒤 레이아웃 정보를 읽으면 브라우저가 현재 스타일을 반영하게 되고, 이후 offset을 0으로 바꾸는 변화가 transition으로 인식됩니다.
다만 강제 레이아웃 계산은 비용이 있으므로 많은 요소에서 반복하면 안 됩니다.
Web Animations API를 사용하면 재생 제어를 더 명확하게 만들 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
11
const animation = path.animate(
[
{ strokeDashoffset: length },
{ strokeDashoffset: 0 },
],
{
duration: 2000,
easing: "ease-out",
fill: "forwards",
},
);
다시 실행할 때는 animation.cancel() 후 새 애니메이션을 만들거나 animation.currentTime = 0과 animation.play()를 사용할 수 있습니다.
pathLength=”1”로 길이 정규화하기
SVG에는 pathLength 속성이 있습니다.
1
2
3
4
<path
pathLength="1"
d="M20 70 C50 15 80 15 95 70 S145 120 170 55 S205 20 220 65"
/>
pathLength="1"을 주면 경로의 전체 길이를 작성자 관점에서 1이라는 기준으로 정규화할 수 있습니다.
그러면 경로마다 실제 길이가 달라도 다음처럼 같은 값을 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
.path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw 2s ease-out forwards;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
이 방식의 장점은 간단합니다.
실제 길이 237.4 → 작성 기준 1
실제 길이 891.2 → 작성 기준 1
여러 경로에 같은 CSS를 적용하기 쉬워집니다.
다만 렌더러와 속성 조합을 폭넓게 지원해야 하는 프로젝트라면 실제 환경에서 확인하는 것이 좋습니다. 가장 명시적인 방식은 getTotalLength()로 실제 길이를 측정하는 것입니다.
또 퍼센트 값은 SVG viewport의 정규화된 대각선 등을 기준으로 계산될 수 있어 경로 전체 길이와 같은 의미가 아닐 수 있습니다.
1
2
/* 경로 길이 기준이라고 단정하기 어려움 */
stroke-dashoffset: 100%;
그리기 효과에서는 실제 길이 숫자나 pathLength="1" 기반 숫자를 사용하는 편이 이해하기 쉽습니다.
여러 경로를 순서대로 그리기
복잡한 로고나 아이콘은 path가 여러 개입니다.
각 path에 animation-delay를 다르게 주면 순서대로 그릴 수 있습니다.
1
2
3
4
5
<svg class="logo-drawing" viewBox="0 0 200 120">
<path class="line line--1" pathLength="1" d="M20 60 L80 20" />
<path class="line line--2" pathLength="1" d="M80 20 L140 60" />
<path class="line line--3" pathLength="1" d="M140 60 L80 100 Z" />
</svg>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.line {
fill: none;
stroke: #111827;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw-line 700ms ease-out forwards;
}
.line--1 {
animation-delay: 0ms;
}
.line--2 {
animation-delay: 500ms;
}
.line--3 {
animation-delay: 1000ms;
}
@keyframes draw-line {
to {
stroke-dashoffset: 0;
}
}
앞 path가 완전히 끝난 뒤 다음 path를 시작하려면 delay를 이전 duration만큼 누적합니다.
첫 번째: delay 0ms, duration 700ms
두 번째: delay 700ms
세 번째: delay 1400ms
조금 겹쳐 자연스럽게 이어지게 하려면 다음 path를 앞선 path가 끝나기 전에 시작하면 됩니다.
JavaScript로 여러 경로의 delay 자동 계산하기
path가 많을 때 CSS 클래스를 일일이 만들기는 번거롭습니다.
JavaScript로 길이와 delay를 계산할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const paths = document.querySelectorAll(".drawing path");
const pixelsPerSecond = 180;
let accumulatedDelay = 0;
paths.forEach((path) => {
const length = path.getTotalLength();
const duration = length / pixelsPerSecond;
path.style.strokeDasharray = String(length);
path.style.strokeDashoffset = String(length);
path.style.animation = [
"draw-path",
`${duration}s`,
"ease-out",
`${accumulatedDelay}s`,
"forwards",
].join(" ");
accumulatedDelay += duration * 0.8;
});
1
2
3
4
5
@keyframes draw-path {
to {
stroke-dashoffset: 0;
}
}
길이에 비례해서 duration을 계산했기 때문에 긴 path는 더 오래, 짧은 path는 더 빨리 그려집니다.
이렇게 하면 전체적으로 펜의 이동 속도가 비슷해 보입니다.
고정 duration
→ 짧은 선은 느리고 긴 선은 빨라 보일 수 있음
길이에 비례한 duration
→ 선을 그리는 속도가 비교적 일정하게 보임
선을 그린 뒤 내부 색 채우기
로고나 일러스트는 외곽선만 그린 뒤 끝나지 않고 내부 색이 나타나야 할 수 있습니다.
stroke 애니메이션과 fill 애니메이션을 나눠서 처리하면 됩니다.
1
2
3
4
5
6
7
<svg viewBox="0 0 160 160">
<path
class="heart"
pathLength="1"
d="M80 135 C20 100 15 55 45 35 C65 22 78 37 80 48 C82 37 95 22 115 35 C145 55 140 100 80 135 Z"
/>
</svg>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.heart {
fill: #ef4444;
fill-opacity: 0;
stroke: #ef4444;
stroke-width: 4;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation:
draw-heart 1.8s ease-out forwards,
fill-heart 500ms ease-out 1.5s forwards;
}
@keyframes draw-heart {
to {
stroke-dashoffset: 0;
}
}
@keyframes fill-heart {
to {
fill-opacity: 1;
}
}
두 애니메이션을 쉼표로 연결했습니다.
draw-heart
→ 외곽선 그리기
fill-heart
→ 1.5초 뒤 내부 색 나타내기
fill을 처음부터 none으로 설정하면 opacity를 올려도 색이 나타나지 않습니다.
1
2
3
/* opacity로 나타낼 예정이라면 실제 fill 색을 지정 */
fill: #ef4444;
fill-opacity: 0;
React에서 재사용 가능한 컴포넌트 만들기
React에서는 ref로 path에 접근해 길이를 측정할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { useLayoutEffect, useRef } from "react";
type DrawingPathProps = {
d: string;
duration?: number;
className?: string;
};
export function DrawingPath({
d,
duration = 1800,
className,
}: DrawingPathProps) {
const pathRef = useRef<SVGPathElement | null>(null);
useLayoutEffect(() => {
const path = pathRef.current;
if (!path) return;
const length = path.getTotalLength();
path.style.setProperty("--path-length", String(length));
path.style.setProperty("--draw-duration", `${duration}ms`);
}, [d, duration]);
return (
<path
ref={pathRef}
className={`drawing-path ${className ?? ""}`}
d={d}
fill="none"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
);
}
1
2
3
4
5
6
7
8
9
10
11
.drawing-path {
stroke-dasharray: var(--path-length);
stroke-dashoffset: var(--path-length);
animation: draw-react-path var(--draw-duration) ease-out forwards;
}
@keyframes draw-react-path {
to {
stroke-dashoffset: 0;
}
}
사용하는 쪽은 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function SignatureLogo() {
return (
<svg
viewBox="0 0 240 100"
className="signature-logo"
role="img"
aria-label="브랜드 서명"
>
<DrawingPath
d="M20 70 C50 15 80 15 95 70 S145 120 170 55 S205 20 220 65"
duration={2200}
/>
</svg>
);
}
길이 측정은 DOM이 준비된 뒤 필요하고, 화면이 그려지기 전에 초기 dash 값을 반영하고 싶어 useLayoutEffect를 사용했습니다.
단순한 등장 효과라면 pathLength="1"을 사용해 JavaScript 측정 자체를 없애는 것도 좋은 선택입니다.
React에서 버튼으로 다시 재생하기
React의 key를 바꾸면 요소를 새로 마운트해 CSS 애니메이션을 다시 시작할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useState } from "react";
export function ReplayableDrawing() {
const [playId, setPlayId] = useState(0);
return (
<section>
<button
type="button"
onClick={() => setPlayId((currentId) => currentId + 1)}
>
다시 그리기
</button>
<svg key={playId} viewBox="0 0 240 100" aria-hidden="true">
<path
className="normalized-drawing-path"
pathLength="1"
d="M20 70 C50 15 80 15 95 70 S145 120 170 55 S205 20 220 65"
fill="none"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
/>
</svg>
</section>
);
}
1
2
3
4
5
6
7
8
9
10
11
.normalized-drawing-path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw-normalized-path 2s ease-out forwards;
}
@keyframes draw-normalized-path {
to {
stroke-dashoffset: 0;
}
}
간단한 데모나 재생 버튼에는 편한 방식입니다.
하지만 복잡한 자식 상태가 있는 SVG 전체를 key로 재마운트하는 것은 비용이 생길 수 있습니다. 세밀한 재생 제어가 필요하면 Web Animations API나 애니메이션 라이브러리를 고려할 수 있습니다.
화면에 들어왔을 때 한 번만 재생하기
페이지 아래쪽 SVG가 첫 로딩부터 재생되면 사용자가 해당 영역에 도착했을 때 이미 애니메이션이 끝났을 수 있습니다.
IntersectionObserver를 사용하면 화면에 들어오는 시점에 클래스를 추가할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const drawing = document.querySelector(".scroll-drawing");
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
drawing.classList.add("is-visible");
observer.unobserve(drawing);
},
{
threshold: 0.4,
},
);
observer.observe(drawing);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.scroll-drawing path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.scroll-drawing.is-visible path {
animation: draw-on-view 1.8s ease-out forwards;
}
@keyframes draw-on-view {
to {
stroke-dashoffset: 0;
}
}
한 번만 재생할 목적이라면 unobserve()로 관찰을 해제합니다.
화면에 들어오고 나갈 때마다 반복하려면 관찰을 유지하고 클래스를 추가·제거할 수 있지만, 스크롤할 때마다 애니메이션이 반복되면 산만할 수 있습니다.
스크롤 진행도와 선 길이 연결하기
애니메이션 시간을 고정하지 않고, 사용자의 스크롤 진행도만큼 선을 그릴 수도 있습니다.
pathLength="1"을 사용하면 진행도를 0부터 1로 다루기 쉽습니다.
1
2
3
4
5
6
7
8
9
10
11
<svg class="route-map" viewBox="0 0 500 300">
<path
class="route-map__path"
pathLength="1"
d="M20 260 C120 220 80 80 220 100 S350 250 480 40"
fill="none"
stroke="#2563eb"
stroke-width="6"
stroke-linecap="round"
/>
</svg>
1
2
3
4
.route-map__path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const section = document.querySelector(".route-section");
const path = document.querySelector(".route-map__path");
function updateDrawing() {
const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const totalDistance = rect.height + viewportHeight;
const traveled = viewportHeight - rect.top;
const progress = Math.min(Math.max(traveled / totalDistance, 0), 1);
path.style.strokeDashoffset = String(1 - progress);
}
window.addEventListener("scroll", updateDrawing, { passive: true });
updateDrawing();
진행도가 0이면 offset은 1이라 숨겨지고, 진행도가 1이면 offset은 0이라 전부 보입니다.
progress = 0 → dashoffset = 1
progress = 0.5 → dashoffset = 0.5
progress = 1 → dashoffset = 0
스크롤 이벤트는 매우 자주 발생하므로 복잡한 화면에서는 requestAnimationFrame으로 업데이트 횟수를 조절하는 것이 좋습니다.
1
2
3
4
5
6
7
8
9
10
11
12
let frameId = null;
function handleScroll() {
if (frameId !== null) return;
frameId = requestAnimationFrame(() => {
updateDrawing();
frameId = null;
});
}
window.addEventListener("scroll", handleScroll, { passive: true });
path 방향이 애니메이션 방향을 결정한다
선이 예상과 반대 방향으로 그려지는 경우가 있습니다.
그리는 방향은 path 데이터의 시작점과 진행 방향에 영향을 받습니다.
1
<path d="M20 50 L180 50" />
이 path는 왼쪽에서 시작해 오른쪽으로 갑니다.
1
<path d="M180 50 L20 50" />
이 path는 오른쪽에서 시작해 왼쪽으로 갑니다.
애니메이션 방향이 마음에 들지 않는다면 다음 방법을 고려할 수 있습니다.
- 디자인 도구에서 path 방향 뒤집기
- SVG path 데이터를 반대 방향으로 만들기
- offset의 시작과 종료 값을 반대로 조정하기
복잡한 곡선은 수동으로 d를 뒤집기 어렵기 때문에 Figma, Illustrator, Inkscape 같은 벡터 도구에서 경로 방향을 확인하는 편이 편합니다.
왜 fill로 만든 그림은 잘 안 될까?
가져온 SVG에 stroke-dasharray를 적용했는데 아무 효과가 없을 수 있습니다.
SVG가 stroke가 아니라 fill로만 만들어졌기 때문입니다.
1
<path d="..." fill="#111827" />
이 path는 면으로 채워진 도형입니다.
stroke-dasharray는 stroke의 패턴을 바꾸므로 stroke가 없으면 움직일 선도 없습니다.
1
2
3
4
5
6
<path
d="..."
fill="none"
stroke="#111827"
stroke-width="3"
/>
글씨를 손으로 쓰는 것처럼 보이게 만들고 싶다면 글자의 윤곽선을 그리는 outline path보다 실제 펜 중심선을 따라가는 single-line path가 더 자연스럽습니다.
outline path를 애니메이션하면 글자 외곽선 두 줄이 각각 그려져, 실제 필기와 다른 느낌이 날 수 있습니다.
서명/필기 효과
→ 펜의 이동 경로를 나타내는 열린 stroke path가 적합
로고 외곽선 효과
→ 닫힌 outline path도 사용 가능
img로 불러온 SVG에는 내부 CSS를 적용하기 어렵다
아래처럼 SVG 파일을 이미지로 불러오면 문서의 CSS에서 내부 path를 직접 선택하기 어렵습니다.
1
<img src="/logo.svg" alt="브랜드 로고" />
이 경우 .logo path 같은 선택자는 SVG 파일 내부에 도달하지 않습니다.
애니메이션을 적용하려면 보통 다음 방법 중 하나를 사용합니다.
- SVG 코드를 HTML 또는 JSX에 inline으로 넣기
- SVG 파일 내부에 자체 style과 animation 넣기
- 빌드 도구의 SVG-to-component 기능 사용하기
React에서는 SVGR 같은 도구를 통해 SVG를 컴포넌트로 가져올 수 있습니다.
1
2
3
4
5
import Logo from "./logo.svg?react";
export function Header() {
return <Logo className="animated-logo" />;
}
구체적인 import 방식은 Vite, Next.js, Webpack 설정에 따라 달라집니다.
핵심은 내부 path를 스타일링하려면 SVG가 현재 문서 DOM에 있어야 한다는 점입니다.
접근성: 애니메이션이 없어도 의미가 전달되어야 한다
SVG가 단순 장식이라면 보조 기술에서 숨길 수 있습니다.
1
2
3
<svg aria-hidden="true" focusable="false">
<!-- decorative paths -->
</svg>
의미가 있는 이미지라면 이름을 제공합니다.
1
2
3
4
<svg role="img" aria-labelledby="logo-title">
<title id="logo-title">회사 로고</title>
<!-- paths -->
</svg>
애니메이션 자체에만 중요한 정보를 의존하면 안 됩니다.
예를 들어 경로 안내선이 그려지는 애니메이션이라면, 최종 경로와 설명을 애니메이션 없이도 이해할 수 있어야 합니다.
prefers-reduced-motion 대응하기
움직임에 민감한 사용자는 운영체제에서 모션 줄이기 설정을 켤 수 있습니다.
이 경우 애니메이션을 생략하고 최종 상태를 바로 보여주는 편이 좋습니다.
1
2
3
4
5
6
7
8
9
@media (prefers-reduced-motion: reduce) {
.drawing-path,
.normalized-drawing-path,
.line {
animation: none;
transition: none;
stroke-dashoffset: 0;
}
}
중요한 점은 단순히 애니메이션만 끄는 것이 아닙니다.
초기 상태가 stroke-dashoffset으로 숨겨져 있으므로 offset을 0으로 바꿔 최종 그림을 보여줘야 합니다.
animation: none만 적용
→ SVG가 숨겨진 채 남을 수 있음
animation: none + stroke-dashoffset: 0
→ 완성된 SVG를 즉시 표시
성능에서 주의할 점
stroke-dashoffset 애니메이션은 충분히 유용하지만, 복잡한 path를 아주 많이 동시에 움직이면 렌더링 비용이 생깁니다.
다음 기준을 고려하면 좋습니다.
- 불필요하게 점이 많은 path는 벡터 도구에서 단순화하기
- 화면에 보이지 않는 애니메이션은 시작하지 않기
- 수십 개 path를 동시에 무한 반복하지 않기
- 스크롤 연동 계산은
requestAnimationFrame으로 조절하기 - blur, filter, 큰 shadow와 동시에 사용하지 않기
- 실제 모바일 기기에서 확인하기
will-change를 무조건 추가한다고 빨라지는 것은 아닙니다.
1
2
3
.path {
will-change: stroke-dashoffset;
}
브라우저 최적화에 도움이 될 수도 있지만, 많은 요소에 남용하면 메모리 비용이 커질 수 있습니다. 실제 성능 문제가 확인된 경우에만 적용하는 편이 좋습니다.
자주 발생하는 문제
1. 선이 처음부터 다 보인다
초기 stroke-dashoffset이 없거나 dash 길이가 경로보다 짧을 수 있습니다.
1
2
3
4
.path {
stroke-dasharray: var(--path-length);
stroke-dashoffset: var(--path-length);
}
2. 중간중간 끊겨 보인다
stroke-dasharray 값이 전체 길이보다 작으면 여러 대시가 반복됩니다.
실제 길이를 getTotalLength()로 측정하거나 pathLength="1"을 사용합니다.
3. 애니메이션 후 다시 사라진다
마지막 상태 유지가 빠졌을 수 있습니다.
1
animation: draw 2s ease-out forwards;
4. CSS를 적용했는데 아무 변화가 없다
SVG가 fill만 사용하는지, <img>로 불러왔는지 확인합니다.
stroke가 있어야 하고 내부 path에 스타일이 도달할 수 있어야 합니다.
5. 반대 방향으로 그려진다
path 자체의 방향을 확인합니다.
애니메이션은 d의 시작점부터 진행됩니다.
6. 크기를 바꾸면 선 두께도 어색해진다
SVG가 확대될 때 stroke 두께를 유지하고 싶다면 vector-effect="non-scaling-stroke"를 고려할 수 있습니다.
1
2
3
4
5
<path
d="..."
vector-effect="non-scaling-stroke"
stroke-width="3"
/>
다만 디자인에 따라 SVG와 함께 선 두께가 비례해 커지는 편이 더 자연스러울 수도 있습니다.
어디에 활용하면 좋을까?
이 효과는 다음 UI에 잘 어울립니다.
- 브랜드 로고 첫 등장
- 서명이나 손글씨 표현
- 체크 아이콘 완료 피드백
- 지도 이동 경로
- 데이터 흐름이나 연결선
- 온보딩 단계 안내선
- 일러스트 외곽선 등장
- 차트 선 애니메이션
하지만 모든 SVG에 적용할 필요는 없습니다.
내비게이션 아이콘처럼 사용자가 반복해서 보는 작은 UI가 매번 그려지면 오히려 조작이 느리게 느껴질 수 있습니다.
콘텐츠의 등장 자체가 경험인 경우
→ 그리기 애니메이션이 잘 어울림
즉시 인식하고 눌러야 하는 조작 UI
→ 정적인 아이콘이 더 적합할 수 있음
장식보다 의미와 반응 속도를 먼저 판단하는 것이 좋습니다.
전체 원리 다시 정리하기
SVG 선 그리기 효과는 네 단계로 이해할 수 있습니다.
1. 경로의 전체 길이를 구한다.
2. 전체 길이만큼 긴 dash를 만든다.
3. dash를 전체 길이만큼 밀어 선을 숨긴다.
4. offset을 0으로 움직여 선을 드러낸다.
코드로 압축하면 이렇습니다.
1
2
3
4
5
6
7
8
9
10
11
<svg viewBox="0 0 240 100">
<path
class="draw"
pathLength="1"
d="M20 70 C50 15 80 15 95 70 S145 120 170 55 S205 20 220 65"
fill="none"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
/>
</svg>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.draw {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw 2s ease-out forwards;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.draw {
animation: none;
stroke-dashoffset: 0;
}
}
실제 길이가 필요하면 getTotalLength()를 사용합니다.
1
2
3
4
const length = path.getTotalLength();
path.style.strokeDasharray = String(length);
path.style.strokeDashoffset = String(length);
마무리
SVG가 그려지는 효과는 복잡한 프레임 애니메이션이 아닙니다.
stroke-dasharray로 경로 전체를 덮는 긴 대시를 만들고, stroke-dashoffset으로 그 대시를 숨겼다가 원래 위치로 이동시키는 방식입니다.
원리를 이해하면 단순한 로고 등장 효과를 넘어 여러 방향으로 확장할 수 있습니다.
여러 path를 순서대로 그리기
길이에 비례해 속도 맞추기
외곽선 이후 fill 나타내기
스크롤 진행도와 연결하기
React 컴포넌트로 재사용하기
IntersectionObserver로 보일 때 재생하기
마지막으로 꼭 기억할 점은 세 가지입니다.
이 효과는 fill이 아니라 stroke를 움직인다.
path의 실제 길이와 방향이 결과를 결정한다.
모션 감소 설정에서는 완성된 그림을 즉시 보여줘야 한다.
이 세 가지를 잡으면 SVG 선 그리기 애니메이션을 예제 복사 없이도 원하는 형태로 설계할 수 있습니다.