한 줄 요약 —
@keyframes로 "0%에서는 이 모습, 100%에서는 저 모습"처럼 장면을 그려 두면,animation이 그 사이를 채워 스스로 움직이는 UI를 만든다. 정지된 그림 여러 장에 시간 축을 더해 책장을 넘기듯 재생하는 짧은 만화와 같다. 지난 회차의transition이 호버 같은 변화 한 번을 부드럽게 잇는 일이었다면,animation은 여러 장면을 순서대로·스스로·필요하면 무한히 반복해서 보여 주는 일이다.
학습 목표#
@keyframes로 애니메이션의 장면을 정의할 수 있다.animation줄임 표기로 이름·시간·곡선·반복 횟수를 한 줄에 줄 수 있다.- 무한 반복하는 로딩 스피너를 CSS만으로 만들 수 있다.
animation-fill-mode로 애니메이션이 끝난 뒤의 모습을 유지할 수 있다.prefers-reduced-motion으로 모션을 불편해하는 사용자를 배려할 수 있다.
오늘의 비유 — 그림 몇 장에 시간 축을 더한 짧은 만화#
공책 귀퉁이에 조금씩 다른 그림을 여러 장 그려 두고 빠르게 넘기면, 멈춰 있던 그림이 움직이는 것처럼 보인다. 플립북이다. 우리가 직접 그리는 건 사실 몇 장 안 된다. 처음 모습 한 장, 끝 모습 한 장, 중요한 순간 몇 장. 나머지 사이 그림은 눈이 알아서 이어 준다.
animation이 바로 이 플립북이다. @keyframes에서 "시작 장면"과 "끝 장면", 그리고 필요하면 중간 장면을 그려 두면, 브라우저가 그 사이의 모든 사이 그림을 자동으로 그려 정해 준 시간에 걸쳐 넘겨 준다. 우리가 정하는 건 어떤 장면들을 그릴지, 몇 초에 걸쳐 넘길지, 몇 번 반복할지다.
핵심 개념#
두 단계로 나뉜다 — 장면을 그리고, 재생을 건다#
animation은 항상 두 부분으로 이루어진다. @keyframes로 장면을 그리는 부분과, animation 속성으로 그 장면을 요소에 재생하는 부분이다.
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fade-up 0.4s ease;
}
from은 0%, to는 100%의 다른 이름이다. 위 만화는 "투명하고 12px 아래에 있는 첫 장면"에서 "불투명하고 제자리에 온 끝 장면"으로 넘어가고, 브라우저가 그 사이를 0.4초에 걸쳐 채운다.
중간 장면이 필요하면 퍼센트로#
시작·끝만으로 부족하면 퍼센트로 중간 장면을 더 그린다. 통통 튀는 움직임처럼 한 번에 끝나지 않는 동작에 쓴다.
@keyframes bounce {
0% { transform: translateY(0); }
50% { transform: translateY(-20px); }
100% { transform: translateY(0); }
}
0%와 100%를 같게 두면 "올라갔다 제자리로" 돌아오는 한 사이클이 된다.
animation 줄임 표기#
animation도 transition처럼 여러 값을 한 줄에 이어 적는 줄임 표기다. 자주 쓰는 순서는 이렇다.
.spinner {
animation: spin 0.8s linear infinite;
/* ┗이름 ┗시간 ┗곡선 ┗반복 횟수 */
}
- 이름:
@keyframes에 붙인 이름. - 시간(duration): 장면을 한 번 다 넘기는 데 걸리는 시간.
- 속도 곡선(timing-function): 지난 회차의
ease·linear등. 회전 스피너는 멈춤 없이 돌아야 하니linear가 자연스럽다. - 반복 횟수(iteration-count):
infinite면 무한 반복, 숫자면 그만큼만.
transition과 가장 큰 차이가 이 반복이다. transition은 변화가 생길 때 한 번 건너가고 끝이지만, animation은 마우스를 올리지 않아도 스스로 시작하고, infinite로 계속 돌릴 수 있다.
fill-mode — 마지막 장면에서 멈춰 있기#
기본적으로 애니메이션이 끝나면 요소는 @keyframes 밖에 적힌 평소 스타일로 돌아간다. 끝 장면 모습을 그대로 유지하고 싶으면 animation-fill-mode: forwards를 준다. 플립북을 다 넘긴 뒤 마지막 그림을 펼쳐 둔 채로 두는 것과 같다.
.card {
opacity: 0; /* 평소엔 안 보이게 */
animation: fade-up 0.4s ease forwards; /* 끝 장면(보임)을 유지 */
}
함께 따라하기 — 로딩 스피너와 첫 진입 카드#
무한히 도는 로딩 스피너와, 화면에 들어올 때 한 번 떠오르는 카드를 같이 만들어 본다.
<div class="spinner" role="status" aria-label="불러오는 중"></div>
<article class="card">
<h2>오늘의 메뉴</h2>
<p>아래에서 살짝 떠오르며 나타납니다.</p>
</article>
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #d8dee9;
border-top-color: #2d6cdf;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.card {
opacity: 0;
padding: 20px;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
animation: fade-up 0.4s ease forwards;
}
저장하고 열어 보면, 스피너는 한쪽 테두리 색만 다른 원이 0.8초마다 한 바퀴씩 끝없이 돈다. 카드는 페이지에 들어오는 순간 12px 아래에서 투명하게 시작해 0.4초 동안 제자리로 떠오르며 나타나고, forwards 덕에 다 나타난 모습 그대로 멈춰 있다.
흔한 실수 3가지#
1. prefers-reduced-motion을 무시해 강한 모션을 강제한다#
화면 전체가 출렁이거나 빙글빙글 도는 모션은 일부 사용자에게 어지럼증·멀미를 일으킨다. 운영체제에 "동작 줄이기"를 켜 둔 사람에게는 큰 움직임을 꺼 줘야 한다.
/* (이 코드는 누구에게나 똑같이 큰 모션을 강제합니다) */
.hero {
animation: bounce 1s ease infinite;
}
브라우저는 사용자의 이 설정을 prefers-reduced-motion 미디어 쿼리로 알려 준다. 장식용 모션은 이 안에서 꺼 주는 게 기본이다.
@media (prefers-reduced-motion: reduce) {
.hero,
.card {
animation: none;
}
}
로딩 스피너처럼 "지금 처리 중"을 알리는 꼭 필요한 모션까지 다 끌 필요는 없다. 어지럼증을 일으키는 큰 움직임부터 끄거나 약하게 바꾼다.
2. fill-mode를 빼서 애니메이션이 끝난 뒤 튀어버린다#
끝 장면이 평소 스타일과 다른데 fill-mode를 안 주면, 다 나타났다가 마지막 순간 원래 모습으로 툭 되돌아간다.
/* (다 떠오른 뒤 다시 투명해져 사라집니다) */
.card {
opacity: 0;
animation: fade-up 0.4s ease;
}
opacity: 0이 평소 스타일이라, 애니메이션이 끝나면 요소는 그 평소 모습(투명)으로 돌아간다. 플립북을 다 넘긴 뒤 첫 장으로 되돌려 덮는 셈이다. 끝 장면을 유지하려면 forwards를 더한다.
.card {
opacity: 0;
animation: fade-up 0.4s ease forwards; /* 끝 장면을 유지 */
}
3. 모든 요소를 한꺼번에 애니메이션해서 산만하다#
페이지에 들어온 모든 카드·제목·버튼이 동시에 출렁이면, 어디를 봐야 할지 알 수 없는 어수선한 화면이 된다.
/* (모든 카드가 같은 순간 한꺼번에 움직여 시선이 흩어집니다) */
.card {
animation: fade-up 0.4s ease forwards;
}
만화도 모든 칸이 한 번에 움직이면 무엇이 중요한지 안 보인다. 카드마다 animation-delay를 조금씩 다르게 줘 차례로 등장시키면, 시선이 자연스럽게 위에서 아래로 흐른다.
.card:nth-child(2) { animation-delay: 0.1s; }
.card:nth-child(3) { animation-delay: 0.2s; }
오늘 배운 것 체크리스트#
-
@keyframes로 장면을 그리고animation으로 재생하는, 두 단계 구조를 안다. -
from/to또는 퍼센트로 시작·중간·끝 장면을 정의할 수 있다. -
animation: 이름 시간 곡선 반복의 줄임 표기와infinite를 쓸 수 있다. -
animation-fill-mode: forwards로 끝 장면을 유지할 수 있다. -
prefers-reduced-motion으로 큰 모션을 꺼 사용자를 배려한다.
자주 묻는 질문#
Q. CSS만으로 로딩 스피너를 만들 수 있나요?
A. 네, JavaScript 없이 @keyframes와 animation만으로 충분합니다. 한쪽 테두리 색만 다른 원에 transform: rotate(360deg)로 끝나는 @keyframes를 만들고, animation: spin 0.8s linear infinite처럼 무한 반복을 걸면 됩니다. 회전은 멈춤 없이 일정해야 자연스러우므로 속도 곡선은 ease보다 linear가 어울립니다.
Q. animation-fill-mode는 정확히 무슨 역할인가요?
A. 애니메이션이 시작 전·종료 후에 어떤 모습을 유지할지 정합니다. 기본값은 애니메이션이 끝나면 @keyframes 밖의 평소 스타일로 돌아가는데, forwards를 주면 마지막 장면(100%)의 모습을 그대로 유지합니다. 반대로 backwards는 시작을 기다리는 delay 동안 첫 장면(0%) 모습을 미리 보여 줍니다.
Q. 모션을 불편해하는 사용자를 위해 무엇을 해야 하나요?
A. @media (prefers-reduced-motion: reduce) 안에서 장식용 애니메이션을 animation: none으로 끄거나 약하게 바꿉니다. 운영체제의 "동작 줄이기" 설정을 켠 사용자에게 자동으로 적용됩니다. 단, 로딩 표시처럼 상태를 알리는 꼭 필요한 모션까지 모두 끌 필요는 없고, 어지럼증을 유발하는 큰 움직임부터 줄이는 것이 좋습니다.
다음 시간 예고#
내일은 그림자·필터·클립패스 — 비주얼 디테일 3종 세트를 다룬다. box-shadow로 입체감을 주고, backdrop-filter로 유리 같은 느낌을 내고, clip-path로 요소를 원하는 도형으로 잘라 내는 법을 한 번에 정리한다.