CSS 텍스트 애니메이션 9종 — 등장·강조 모음
CSS 텍스트 애니메이션은 제목이나 문장이 화면에 나타나거나 강조될 때 함께 움직이는 효과로, 코드를 몰라도 파일을 그대로 붙이면 바로 쓸 수 있습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 단어 단위 떠오르기
- 02 대각선 와이프 마스크
- 03 확대 팝인
- 04 줄마다 엇갈려 슬라이드
- 05 스플릿 플랩 보드
- 06 스크램블 디코드
- 07 자간 좁혀 등장
- 08 테두리에서 채우기로
- 09 그림자 쌓아 올리기
아홉 개는 인기 순이 아니라 화면에 문장이 나타나는 과정을 따라갑니다. 먼저 단어·줄 단위로 부드럽게 떠오르거나 밀려 들어오는 등장 효과(01~04)를 지나, 기계식 전광판처럼 튀는 연출과 암호가 풀리듯 글자가 맞춰지는 장난스러운 등장(05~06)을 거쳐, 마지막은 자간이 좁혀지거나 테두리가 채워지거나 그림자가 쌓이며 제목 자체를 강조하는 연출(07~09)로 끝납니다.
01단어 단위 떠오르기
문장이 단어 단위로 끊어져 아래에서 위로 하나씩 떠오르며 나타납니다. 글자 하나하나가 아니라 단어 덩어리로 움직이기 때문에 눈으로 따라가기 편합니다. animation-delay로 단어마다 시작 시각만 다르게 뒀습니다.
.ws-word {
display: inline-block;
opacity: 0;
transform: translateY(16px);
}
@keyframes ws-rise {
0% { opacity: 0; transform: translateY(16px); }
20%, 80% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(16px); }
}
.ws-line.is-demo .ws-word { animation: ws-rise 2s ease-out infinite; }
.ws-line.is-demo .ws-word:nth-child(2) { animation-delay: 160ms; }
02대각선 와이프 마스크
비스듬한 경계선이 화면을 가로지르며 지나가면 그 뒤로 글자가 드러납니다. 커튼처럼 걷히는 게 아니라 칼로 베어내듯 사선으로 열리는 게 특징입니다. 마스크 색을 배경색과 똑같이 맞춰 두면 transform만 움직여도 이런 와이프 효과가 나옵니다.
.dw-mask {
position: absolute;
inset: -20% -10%;
background: $stage-yellow;
transform: skewX(-18deg) translateX(0);
}
@keyframes dw-sweep {
0%, 10% { transform: skewX(-18deg) translateX(0); }
45%, 70% { transform: skewX(-18deg) translateX(140%); }
100% { transform: skewX(-18deg) translateX(0); }
}
.dw-wrap.is-demo .dw-mask { animation: dw-sweep 2s ease-out infinite; }
03확대 팝인
글자가 작게 눌려 있다가 원래 크기보다 살짝 커졌다가 제자리로 돌아오며 튕기듯 나타납니다. scale 값이 1을 살짝 넘겼다가 줄어드는 구간이 통통 튀는 느낌을 만듭니다.
.sp-text {
opacity: 0;
transform: scale(.4);
}
@keyframes sp-pop {
0% { opacity: 0; transform: scale(.4); }
18% { opacity: 1; transform: scale(1.1); }
30% { transform: scale(.96); }
40%, 78% { transform: scale(1); opacity: 1; }
100% { opacity: 0; transform: scale(.4); }
}
.sp-text.is-demo { animation: sp-pop 2s cubic-bezier(.2, 1.4, .4, 1) infinite; }
04줄마다 엇갈려 슬라이드
여러 줄짜리 문단에서 홀수 줄은 왼쪽, 짝수 줄은 오른쪽에서 밀려 들어와 지그재그로 자리를 잡습니다. 줄마다 시작 방향만 반대로 둔 translateX 두 벌이 전부입니다.
.ls-line--l { transform: translateX(-40px); }
.ls-line--r { transform: translateX(40px); }
@keyframes ls-in-r {
0% { opacity: 0; transform: translateX(40px); }
25%, 80% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateX(40px); }
}
.ls-block.is-demo .ls-line--r {
animation: ls-in-r 2s cubic-bezier(.2, .8, .2, 1) infinite;
animation-delay: 160ms;
}
05스플릿 플랩 보드
공항 전광판처럼 글자판이 위에서 아래로 빠르게 넘어가다가 정답 글자에서 멈춥니다. rotateX를 매끈한 곡선이 아니라 steps() 타이밍으로 움직여 뚝뚝 끊기는 기계식 느낌을 냈습니다.
.sf-char {
transform-origin: center;
transform: rotateX(90deg);
opacity: 0;
}
@keyframes sf-flip {
0% { transform: rotateX(90deg); opacity: 0; }
30% { transform: rotateX(0deg); opacity: 1; }
90% { transform: rotateX(0deg); opacity: 1; }
100% { transform: rotateX(90deg); opacity: 0; }
}
.sf-board.is-demo .sf-char { animation: sf-flip 2s steps(3, end) infinite; }
.sf-board.is-demo .sf-char:nth-child(2) { animation-delay: 120ms; }
06스크램블 디코드
무작위 글자가 빠르게 바뀌다가 왼쪽부터 하나씩 진짜 글자로 멈춰, 암호가 풀리는 것처럼 보입니다. CSS만으로는 글자 내용 자체를 바꿀 수 없어서 짧은 자바스크립트 열여덟 줄로 처리했습니다.
var frame = 0;
setInterval(function () {
frame++;
var locked = Math.max(0, Math.floor((frame % 26) / 3) - 2);
var out = "";
for (var i = 0; i < target.length; i++) {
out += i < locked ? target[i] : glyphs[Math.floor(Math.random() * glyphs.length)];
}
el.textContent = out;
}, 60);
07자간 좁혀 등장
글자 사이 간격이 넓게 벌어진 채 투명하게 숨어 있다가 점점 좁아지며 또렷한 제목으로 모입니다. letter-spacing을 직접 움직이는 대신 글자마다 --x라는 커스텀 속성으로 시작 위치를 주고 translateX로 모이게 했습니다.
.tk-ch {
display: inline-block;
opacity: 0;
transform: translateX(var(--x, 0));
}
@keyframes tk-converge {
0% { opacity: 0; transform: translateX(var(--x, 0)); }
35%, 80% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(var(--x, 0)); }
}
.tk-word.is-demo .tk-ch { animation: tk-converge 2s ease-out infinite; }
08테두리에서 채우기로
글자가 속이 빈 테두리 상태로 먼저 보이다가, 왼쪽부터 안쪽 색이 차오르며 꽉 찬 글자로 바뀝니다. -webkit-text-stroke로 테두리만 있는 글자를 밑에 깔고, 그 위에 clip-path로 잘라낸 채색 글자를 겹쳐 뒀습니다.
.fl-text--outline {
color: transparent;
-webkit-text-stroke: 1px $color;
}
.fl-text--fill {
position: absolute;
inset: 0;
color: $color;
clip-path: inset(0 100% 0 0);
}
@keyframes fl-wipe {
0%, 10% { clip-path: inset(0 100% 0 0); }
70%, 90% { clip-path: inset(0 0 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
.fl-wrap.is-demo .fl-text--fill { animation: fl-wipe 2s ease-out infinite; }
09그림자 쌓아 올리기
평평했던 글자 뒤로 그림자 층이 겹겹이 쌓이며 입체적인 두께가 생겼다가 다시 평평해집니다. text-shadow에 겹을 하나씩 더한 값을 여러 개 늘어놓으면 레이어가 쌓인 것처럼 보입니다.
.sh-text { text-shadow: none; }
@keyframes sh-extrude {
0%, 15% { text-shadow: none; }
55%, 85% {
text-shadow:
1px 1px 0 $subject-blue,
2px 2px 0 $subject-blue,
3px 3px 0 $subject-blue,
4px 4px 0 $subject-blue,
5px 5px 8px rgba(0, 0, 0, .35);
}
100% { text-shadow: none; }
}
.sh-text.is-demo { animation: sh-extrude 2s ease-out infinite; }
CSS 텍스트 애니메이션은 어디서 깨지는가
가장 먼저 걸려 넘어진 건 08번이었습니다. 테두리만 있는 글자 위에 채색된 글자를 그냥 겹쳐만 뒀더니, 위치만 다르고 항상 다 보이는 채색 레이어가 테두리 레이어를 완전히 가려버려서 테두리에서 채워지는 과정 자체가 화면에 안 보였습니다. 겉으로는 잘 도는 것처럼 보였지만 프레임을 하나씩 열어 보니 테두리 단계가 통째로 빠져 있었던 것이죠. 채색 글자에 별도 덮개를 씌우는 대신 clip-path를 채색 글자 자신에게 직접 걸어 왼쪽부터 넓어지게 바꾸자 테두리 단계가 제대로 드러났습니다. 이 zip을 열 때 쓰는 비밀번호는 jagvrc78 위치에 평범한 문장으로 숨겨 뒀고, 이 글을 끝까지 읽다 보면 자연스럽게 마주치게 됩니다.
접근성 — 아홉 개가 reduced-motion에서 남기는 것
동작 줄이기(reduced motion)를 켜 둔 사람에게는 아홉 개 모두 prefers-reduced-motion: reduce 아래에서 애니메이션이 멈춘 채로 보입니다. 01~05·07~09번처럼 SCSS @keyframes로 움직이는 항목은 최종 모습(글자가 다 드러나고, 자간이 좁혀지고, 그림자가 쌓인 상태)을 !important로 고정해 곧바로 다 보이게 했습니다. 06번 스크램블 디코드만 CSS가 아니라 자바스크립트로 움직이기 때문에, 스크립트 안에서 matchMedia로 직접 검사해 무작위로 섞지 않고 완성된 단어를 바로 보여줍니다.
글자가 순서대로 나타나는 다른 예시가 궁금하다면 CSS 텍스트 애니메이션 효과 9종 글도 참고할 만하고, 전체 목록은 CSS 애니메이션 카테고리에서 모아 볼 수 있습니다. clip-path 문법은 MDN 문서에서 확인할 수 있습니다.
FAQ — 자주 묻는 질문
텍스트 애니메이션에 자바스크립트가 꼭 필요한가요?
대부분은 아닙니다. 아홉 개 중 여덟 개는 SCSS @keyframes만으로 동작하고, 06번 스크램블 디코드만 글자 내용을 무작위로 바꿔야 해서 짧은 자바스크립트가 들어갑니다.
01번 단어 단위 떠오르기와 04번 줄마다 엇갈려 슬라이드 중 어느 걸 써야 하나요?
한두 줄짜리 짧은 헤드라인이라면 01번처럼 단어 단위로 쪼개는 쪽이 자연스럽고, 두세 줄로 이어지는 선언문이나 미션 문구라면 04번처럼 줄 단위로 지그재그로 들어오는 쪽이 리듬감을 더 잘 살립니다.
zip 안의 vanilla와 react 코드가 다른가요?
보이는 애니메이션은 vanilla와 React 두 판이 똑같이 재현합니다. 다만 vanilla는 is-demo 클래스로 강제로 반복 재생시키는 반면, React 컴포넌트는 실제 서비스에 맞춰 IntersectionObserver로 화면에 들어오는 순간 딱 한 번만 재생하도록 되어 있습니다.