본문 바로가기

CSS 애니메이션과 트랜지션

상태 변화에는 transition을, 다단계 움직임에는 keyframes animation을 적용해 자연스러운 시각 피드백을 만듭니다.

3장까지 HTML로 구조를 잡고 CSS로 스타일과 레이아웃을 꾸미는 방법을 익혔습니다. 4장에서는 CSS의 고급 기법을 다루며, 이 글에서는 페이지에 움직임을 더하는 두 방법인 트랜지션(transition)과 애니메이션(animation)을 살펴봅니다. 트랜지션은 속성값이 바뀔 때 그 변화를 부드럽게 이어 주고, 애니메이션은 여러 단계로 이루어진 움직임을 정의해 재생합니다.

움직임은 버튼의 상태가 바뀌었다거나 새 내용이 어디에 나타났다는 것을 알려 줄 때 쓸모가 있습니다. 그래서 먼저 무엇을 알려 줄지 정한 뒤 효과를 고릅니다. 의미 없이 반복되거나 크게 움직이는 효과는 읽기를 방해하고, 움직임에 민감한 사용자에게 어지럼증을 줄 수 있으므로, 움직임을 줄여도 같은 정보가 전달되도록 함께 설계합니다.

CSS 움직임: 상태 전환과 keyframes 재생

CSS MOTION · TIMING

CSS 움직임: 상태 전환과 keyframes 재생

Transition은 바뀐 상태 값으로 전환하고, animation은 정의한 keyframes를 재생한다. 움직임의 목적과 사용자 선호를 확인한 뒤 필요한 시간 제어를 선택한다.

Transition · 바뀐 상태 값으로

hover·focus·class 변경 등으로 계산된 속성값이 달라질 때, 시작값에서 새 목표값으로 보간한다. property·duration·easing·delay를 정한다.

Animation · 정해 둔 keyframes로

@keyframes의 0% → 중간(예: 50%) → 100% 상태를 재생한다. duration·delay와 iteration·direction·fill-mode·play-state로 반복, 방향, 지연 전후 적용과 일시 정지를 제어한다.

Transition과 keyframes의 시간 비교Transition은 바뀐 목표값 A에서 B로 전환하며 animation은 한 주기0/50/100%를 재생한다. 두 행/세로 구역은 별도 방식이며 둘 사이에 연결선은 없다. 반복·역방향은 설정에 따른다.TRANSITION · 목표 스타일 변화A시작 값B새 목표 값duration · easingANIMATION · KEYFRAME TIMELINE0%시작 keyframe50%중간 keyframe100%끝 keyframe반복·역방향 재생은 iteration / direction 설정에 따른다
Transition과 keyframes의 시간 비교Transition은 바뀐 목표값 A에서 B로 전환하며 animation은 한 주기0/50/100%를 재생한다. 두 행/세로 구역은 별도 방식이며 둘 사이에 연결선은 없다. 반복·역방향은 설정에 따른다.TRANSITION · 목표 값A시작 값duration · easingB새 목표 값ANIMATION · 주기0%시작 keyframe50%중간 keyframe100%끝 keyframe반복·역방향: 별도 설정

움직임이 필요 없거나 reduced motion이면

문구·아이콘·색상 등으로 상태를 전달한다. prefers-reduced-motion 선호에 맞춰 큰 이동·반복·자동 재생을 줄이거나 즉시 적용하며 의미를 유지한다.

완료·중단과 성능

설정과 상태 변화에 따라 재생이 완료되거나 취소될 수 있다. 완료 이벤트만 기다리며 자원을 남기지 않는다. transform·opacity도 실제 렌더 비용과 가독성을 확인한다.

재생이 끝나도 결과 스타일은 설정에 따라 다르다

Animation의 forwards/both는 재생 방향과 마지막 iteration에 해당하는 끝 keyframe 값을 유지한다. none은 animation 밖의 스타일로 돌아간다. 새 목표값으로 바뀌는 transition과 keyframes animation의 재시작·취소 규칙을 구분한다.

위 그림은 두 방식이 시간을 다루는 법을 비교합니다. 트랜지션은 :hover나 :focus, 클래스 변경 등으로 계산된 속성값이 A에서 B로 바뀔 때, 정한 시간과 속도 곡선에 따라 그 사이를 이어 줍니다. 애니메이션은 @keyframes에 정의한 0%, 50%, 100% 같은 단계를 한 주기로 재생하며, 반복 횟수와 방향, 시작 전후의 상태, 일시 정지를 따로 정할 수 있습니다. 두 방식은 서로 연결된 단계가 아니라 별개의 도구입니다.

그림 아래쪽은 움직임을 쓸 때 함께 생각할 점을 정리합니다. 움직임이 필요 없거나 사용자가 움직임 줄이기를 설정했다면 문구, 아이콘, 색으로 상태를 전하고, 큰 이동과 반복, 자동 재생을 줄입니다. 애니메이션이 끝난 뒤의 모습은 animation-fill-mode 설정에 따라 다르며, transform과 opacity만 바꾸는 움직임도 실제 화면에서 성능과 가독성을 확인합니다.


트랜지션

3장에서 배운 선택자와 cascade를 떠올려 보세요. transition을 선언하기만 해서는 요소가 움직이지 않습니다. 같은 요소의 계산된 속성값이 어떤 이유로 바뀌어야, 원래 값에서 새 값으로 바로 바뀌지 않고 정한 시간 동안 점점 바뀝니다. 주로 마우스를 올렸을 때(:hover), 누르고 있을 때(:active), 초점이 갔을 때(:focus)처럼 상태에 따라 값이 바뀌는 경우에 씁니다.

트랜지션 속성

트랜지션은 네 가지 속성으로 정합니다. transition-property는 트랜지션을 적용할 속성입니다. 기본값인 all은 전환할 수 있는 모든 속성에 적용하고, background-color나 transform처럼 특정 속성을 쉼표로 나열할 수도 있습니다.

.width-only {
    transition-property: width; /* 너비 변화에만 트랜지션 적용 */
}
.two-properties {
    transition-property: background-color, transform; /* 배경색과 변형에 적용 */
}
.every-property {
    transition-property: all; /* 값이 바뀐 전환 가능한 속성 모두에 적용 */
}

transition-duration은 전환에 걸리는 시간으로, 초(s)나 밀리초(ms)로 적습니다. 기본값 0s이면 전환 없이 바로 바뀌며, 0.5s와 500ms는 같습니다.

.half-second {
    transition-duration: 0.5s; /* 0.5초 동안 변화 */
}
.also-half-second {
    transition-duration: 500ms; /* 500밀리초, 0.5초와 같음 */
}

transition-timing-function은 전환하는 동안 속도가 바뀌는 모양, 즉 속도 곡선입니다.

  • ease: 기본값입니다. 천천히 시작해 빨라졌다가 천천히 끝납니다.
  • linear: 처음부터 끝까지 같은 속도입니다.
  • ease-in: 천천히 시작해 점점 빨라집니다.
  • ease-out: 빠르게 시작해 점점 느려집니다.
  • ease-in-out: 천천히 시작하고 천천히 끝나며, ease보다 시작과 끝의 감속이 고릅니다.
  • cubic-bezier(x1, y1, x2, y2): 곡선을 직접 정의합니다.
  • steps(n): 부드럽게 바뀌지 않고 n단계로 끊어서 바뀝니다.
.box {
    transition-timing-function: ease-in-out; /* 시작과 끝이 부드러운 변화 */
}

같은 시간 동안의 전환이라도 곡선에 따라 중간 지점까지 얼마나 진행했는지가 달라집니다. 그래서 끝나는 시각은 같고 움직임의 느낌만 바뀝니다.

transition-delay는 전환이 시작되기 전에 기다릴 시간입니다.

.box {
    transition-delay: 0.2s; /* 0.2초 후에 트랜지션 시작 */
}

이 값을 .box에만 적으면 마우스를 올릴 때와 뗄 때 모두 0.2초 늦게 반응합니다. 전환에는 바뀐 뒤 상태의 transition 값이 쓰이므로, 올릴 때만 기다리게 하려면 :hover 규칙에만 지연을 적습니다.

한 줄로 쓰기: transition

transition 단축 속성은 네 가지 속성을 한 줄에 적습니다. 순서는 자유롭지만, 시간 값이 두 개 있으면 첫 번째가 duration, 두 번째가 delay입니다. 쉼표로 나누면 속성마다 다른 전환을 정할 수 있습니다.

.full {
    /* property | duration | timing-function | delay */
    transition: all 0.5s ease-in-out 0.1s;
}
.background-only {
    /* 배경색 변화에 0.3초 ease 효과 */
    transition: background-color 0.3s ease;
}
.per-property {
    /* 너비 변화에 0.5초, 배경색 변화에 0.3초 */
    transition: width 0.5s, background-color 0.3s;
}

트랜지션 예제

버튼에 마우스를 올렸을 때의 효과를 만들어 봅니다. 아래 <style>과 버튼을 HTML 파일의 <body>에 함께 넣어 저장한 뒤 브라우저에서 엽니다. CSS를 따로 저장한다면 앞 장처럼 <link>로 연결합니다.

<style>
    .my-button {
        background-color: #3498db;
        color: white;
        padding: 10px 20px;
        border: none;
        border-radius: 5px;
        cursor: pointer;
        font-size: 16px;

        /* 트랜지션 적용 */
        transition: background-color 0.3s ease-out, transform 0.3s ease-out;
    }

    .my-button:hover {
        background-color: #2980b9; /* 배경색 변경 */
        transform: translateY(-3px); /* 위로 살짝 이동 */
        box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); /* 그림자 추가 */
    }
</style>

<button class="my-button">마우스를 올려보세요</button>

마우스를 올리면 배경색이 진해지면서 버튼이 3px 위로 올라가는 변화가 0.3초에 걸쳐 일어납니다. 반면 box-shadow는 transition의 목록에 없으므로 그림자는 즉시 나타납니다. 마우스를 내리면 원래 규칙으로 돌아가는 변화에도 같은 전환이 적용되며, 움직이는 도중에 마우스를 내리면 그 순간의 중간값에서 원래 값으로 돌아갑니다. 트랜지션은 항상 처음부터 끝까지 재생되는 영상이 아니라, 지금 값에서 새 목표값으로 이어 주는 기능이기 때문입니다.

키보드 사용자는 Tab 키로 버튼에 초점을 옮길 수 있지만, 이 규칙은 :hover에만 있어 같은 효과가 보이지 않습니다. 선택자를 .my-button:hover, .my-button:focus-visible로 바꾸면 키보드로 초점이 갔을 때도 같은 변화가 생깁니다. 터치 화면에는 마우스를 올려 둔 상태가 없으므로, 버튼의 의미와 동작을 이런 효과에만 맡기지 않습니다.

더 깊이: 처음 나타나는 요소에는 왜 트랜지션이 없을까

display: none이던 메뉴에 클래스를 붙여 display: block과 opacity: 1로 바꾸면, opacity에 트랜지션을 걸어 두었어도 메뉴는 서서히 나타나지 않고 바로 보입니다. 트랜지션은 이전에 계산된 값에서 새 값으로 이어 주는 기능인데, 상자가 없던 요소에는 이어 줄 이전 값이 없기 때문입니다. 그래서 CSS는 예상하지 못한 움직임을 막기 위해 요소의 첫 스타일이 정해질 때와 display가 none에서 다른 값으로 바뀔 때는 기본으로 트랜지션을 실행하지 않습니다. 사라질 때도 display: none은 중간값이 없는 값이라 곧바로 적용되어, 투명해지는 과정을 보기 전에 요소가 없어집니다.

이를 위해 최근에 두 기능이 더해졌습니다. @starting-style 규칙 안에는 처음 나타날 때 출발할 값을 적습니다. 예를 들어 @starting-style { .menu.open { opacity: 0; } }이라고 적으면, 메뉴가 열릴 때 opacity가 0에서 1로 이어집니다. 그리고 transition에 display 0.3s allow-discrete처럼 allow-discrete를 붙이면 display 같은 이산적인 값도 트랜지션에 참여합니다. 이때 display는 none에서 바뀔 때는 시작 시점에, none으로 바뀔 때는 끝 시점에 값을 바꾸므로, 나타나고 사라지는 동안 내내 요소가 보입니다.

MDN의 @starting-style 설명에 따르면 @starting-style은 2024년 8월부터 주요 브라우저의 최신 버전에서 함께 쓸 수 있습니다. 반면 같은 MDN에서 transition-behavior 속성은 아직 널리 쓰이는 일부 브라우저가 지원하지 않는 기능으로 분류되어 있으므로, 대상 브라우저의 지원 여부를 확인한 뒤 씁니다. 지원하지 않는 브라우저에서도 메뉴는 움직임 없이 나타나고 사라질 뿐이므로, 기능 자체가 망가지지는 않습니다.


애니메이션

트랜지션이 두 상태 사이의 변화를 이어 준다면, 애니메이션은 여러 단계에 걸친 움직임을 정의하고, 정한 시간 동안 반복해서 재생할 수 있습니다. 단계는 @keyframes 규칙으로 정합니다.

@keyframes

@keyframes 규칙에는 애니메이션의 이름과 단계마다의 스타일을 적습니다. 단계는 한 주기에서의 진행률을 백분율로 적으며, 0%는 from, 100%는 to로 써도 됩니다. 중간 단계는 몇 개든 둘 수 있고, 같은 스타일의 단계는 0%, 100%처럼 쉼표로 묶습니다.

@keyframes slideIn {
    0% { /* 시작 상태 */
        transform: translateX(-100%); /* 자기 너비만큼 왼쪽에서 시작 */
        opacity: 0;
    }
    50% { /* 50% 지점 */
        opacity: 0.5;
    }
    100% { /* 끝 상태 */
        transform: translateX(0); /* 원래 위치로 돌아옴 */
        opacity: 1;
    }
}

@keyframes bounce {
    0%, 20%, 50%, 80%, 100% {
        transform: translateY(0);
    }
    40% {
        transform: translateY(-30px);
    }
    60% {
        transform: translateY(-15px);
    }
}

slideIn의 translateX(-100%)에서 퍼센트는 화면의 너비가 아니라 움직이는 요소 자신의 너비입니다. 그래서 요소가 화면 왼쪽 바깥으로 완전히 사라진다고는 할 수 없고, 요소의 너비만큼 왼쪽에서 시작한다는 뜻입니다. 50% 단계에는 transform이 없으므로 그 지점의 위치는 앞뒤 단계 사이에서 계산되고, opacity만 0.5로 정해집니다. bounce는 40%에서 30px, 60%에서 15px 올라가고 나머지 단계에서는 제자리에 있어, 한 주기 안에서 두 번 튀어 오릅니다.

애니메이션 속성

정의한 @keyframes를 요소에 적용할 때는 다음 속성을 씁니다.

  • animation-name: 적용할 @keyframes의 이름입니다. 초기값은 none이므로 재생하려면 이름을 반드시 적습니다.
  • animation-duration: 한 주기의 시간으로, s나 ms로 적습니다. 초기값이 0s라 생략하면 움직임이 보이지 않습니다.
  • animation-timing-function: 속도 곡선이며, 값은 트랜지션과 같습니다. 단계와 단계 사이마다 적용됩니다.
  • animation-delay: 재생을 시작하기 전에 기다릴 시간입니다.
  • animation-iteration-count: 반복 횟수입니다. 기본값은 1이고, infinite는 끝없이 반복합니다.
  • animation-direction: 재생 방향입니다. 기본값인 normal은 0%에서 100%로, reverse는 거꾸로, alternate는 주기마다 방향을 바꿔 왔다 갔다 하며, alternate-reverse는 거꾸로 시작해 방향을 바꿉니다.
  • animation-fill-mode: 재생하기 전과 끝난 뒤의 스타일입니다. 기본값인 none은 재생할 때만 키프레임을 적용하고, 그 밖에는 원래 스타일로 돌아갑니다. forwards는 마지막 주기가 끝난 상태를 유지하며, 그 상태는 재생 방향과 반복 횟수에 따라 0%일 수도 100%일 수도 있습니다. backwards는 지연 시간 동안 첫 주기가 시작할 상태를 적용하므로, normal이면 0%, reverse이면 100%의 상태가 됩니다. both는 두 가지를 모두 적용합니다.
  • animation-play-state: 재생 상태로, running이 기본값이고 paused이면 그 자리에서 멈춥니다.
.three-times {
    animation-iteration-count: 3; /* 3번 반복 */
}
.forever {
    animation-iteration-count: infinite; /* 무한 반복 */
}
.element {
    animation-direction: alternate; /* 왔다 갔다 반복 */
}
.element {
    animation-fill-mode: forwards; /* 애니메이션 끝난 상태 유지 */
}
.element:hover {
    animation-play-state: paused; /* 마우스 오버 시 애니메이션 일시 정지 */
}

한 줄로 쓰기: animation

animation 단축 속성은 모든 애니메이션 속성을 한 줄에 적습니다. 적지 않은 속성은 초기값이 되며, 시간 값을 두 개 쓰면 첫 번째가 duration, 두 번째가 delay입니다.

.element {
    /* name | duration | timing-function | delay | iteration-count | direction | fill-mode */
    animation: slideIn 1s ease-out 0.5s 3 alternate forwards;
}

이 요소는 0.5초를 기다린 뒤 1초짜리 slideIn을 세 번 재생합니다. alternate이므로 첫째와 셋째 주기는 정방향, 둘째 주기는 역방향으로 재생되고, 끝나면 셋째 주기가 끝난 100% 상태를 forwards가 유지합니다. 반복 횟수가 infinite라면 애니메이션이 끝나지 않으므로 forwards는 아무 의미가 없습니다.

애니메이션 예제

페이지를 열면 나타나는 상자와, 끝없이 도는 로딩 스피너를 만들어 봅니다. 두 효과 모두 먼저 @keyframes로 단계별 모습을 정의하고, 요소의 animation 속성에 그 이름과 시간을 적어 연결합니다. 코드에서 fadeInSlideUp과 rotate360이 각각 어느 요소에 연결되는지 찾아보세요.

<style>
    /* 애니메이션 정의 */
    @keyframes fadeInSlideUp {
        0% {
            opacity: 0;
            transform: translateY(20px);
        }
        100% {
            opacity: 1;
            transform: translateY(0);
        }
    }

    @keyframes rotate360 {
        0% { transform: rotate(0deg); }
        100% { transform: rotate(360deg); }
    }

    .fade-in-box {
        width: 200px;
        height: 100px;
        background-color: #2ecc71; /* 에메랄드 */
        color: white;
        display: flex;
        justify-content: center;
        align-items: center;
        margin: 50px auto;
        border-radius: 8px;
        font-size: 1.2em;

        /* 애니메이션 적용 */
        animation-name: fadeInSlideUp;
        animation-duration: 0.8s;
        animation-timing-function: ease-out;
        animation-fill-mode: backwards; /* 애니메이션 시작 전 0% 상태 유지 (투명, 아래로) */
        animation-delay: 0.5s; /* 0.5초 후에 시작 */
    }

    .spinner {
        width: 50px;
        height: 50px;
        border: 5px solid #f3f3f3; /* 연한 회색 테두리 */
        border-top: 5px solid #3498db; /* 파란색 상단 테두리 */
        border-radius: 50%; /* 원형 */
        margin: 30px auto;

        /* 애니메이션 적용 */
        animation: rotate360 1s linear infinite; /* 1초 동안 무한히 선형 회전 */
    }
</style>

<div class="fade-in-box">나타나는 박스</div>
<div class="spinner"></div>

.fade-in-box는 0.5초를 기다린 뒤 0.8초 동안 20px 아래에서 올라오며 나타납니다. animation-fill-mode: backwards 덕분에 기다리는 동안에도 0% 단계의 투명하고 아래로 내려간 상태가 적용되므로, 상자가 잠깐 보였다가 사라졌다 나타나는 일이 없습니다. 스피너는 위쪽 테두리만 파란 원을 1초에 한 바퀴씩 일정한 속도로 끝없이 돌립니다.


트랜지션과 애니메이션 고르기

트랜지션은 버튼에 마우스를 올렸을 때, 메뉴를 펼치고 접을 때, 탭을 바꿀 때처럼 두 상태 사이의 간단한 변화에 알맞습니다. :hover, :focus, :active나 자바스크립트로 클래스를 붙이고 떼는 일처럼, 어떤 일이 일어나 속성값이 바뀔 때 동작하며 코드도 간단합니다.

애니메이션은 로딩 스피너, 슬라이드 쇼, 정해진 경로를 따라 움직이는 요소처럼 여러 단계를 거치는 움직임에 알맞습니다. 페이지를 열 때 저절로 시작하거나 끝없이 반복할 수 있고, @keyframes로 단계마다의 상태를 자세히 정할 수 있습니다. 정리하면, 간단한 상태 변화에는 트랜지션을, 여러 단계나 반복이 필요한 움직임에는 애니메이션을 씁니다.


실습: 움직임이 있는 페이지

트랜지션과 애니메이션을 함께 써 보는 페이지를 만들어 봅니다. 다음 구조로 파일을 준비합니다.

dynamic_web.html
motion.css

dynamic_web.html에는 다음 코드를 씁니다.

dynamic_web.html
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS 애니메이션과 트랜지션 실습</title>
    <link rel="stylesheet" href="css/motion.css">
</head>
<body>
    <header>
        <h1 class="page-title">동적인 웹 페이지 만들기</h1>
        <p class="subtitle">CSS Transition과 Animation의 힘!</p>
    </header>

    <main>
        <section class="transition-section">
            <h2>1. CSS 트랜지션 예시</h2>
            <div class="button-container">
                <button class="hover-button">마우스 오버해보세요</button>
                <div class="box-grow">크기/색깔 변환 박스</div>
            </div>
        </section>

        <section class="animation-section">
            <h2>2. CSS 애니메이션 예시</h2>
            <div class="loading-spinner"></div>
            <div class="bouncing-ball"></div>
            <div class="slide-in-text">짜잔! 등장하는 텍스트!</div>
        </section>
    </main>

    <footer>
        <p>&copy; 2026 CSS Motion Practice. 모든 권리 보유.</p>
    </footer>
</body>
</html>

css/motion.css에는 다음 CSS를 씁니다. 버튼의 효과는 키보드로 초점이 갔을 때도 보이도록 :focus-visible을 함께 적었습니다.

css/motion.css
/* 기본 설정 */
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: #f0f2f5;
    color: #333;
    line-height: 1.6;
    padding-bottom: 50px;
}

header {
    background-color: #34495e;
    color: white;
    text-align: center;
    padding: 30px 20px;
    box-shadow: 0 3px 6px rgba(0,0,0,0.2);
    margin-bottom: 30px;
}
.page-title {
    font-size: 2.8em;
    margin-bottom: 5px;
}
.subtitle {
    font-size: 1.2em;
    font-style: italic;
}

main {
    max-width: 900px;
    margin: 0 auto;
    padding: 0 20px;
}

section {
    background-color: white;
    padding: 30px;
    margin-bottom: 30px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
section h2 {
    color: #2980b9;
    font-size: 2em;
    margin-bottom: 25px;
    text-align: center;
}

/* ------------------------------------------- */
/* 1. CSS 트랜지션 예시 */
/* ------------------------------------------- */
.button-container {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 40px;
    flex-wrap: wrap;
    padding: 20px;
}

.hover-button {
    background-color: #2ecc71; /* 에메랄드 */
    color: white;
    padding: 15px 30px;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    font-size: 1.1em;
    font-weight: bold;

    /* 트랜지션 적용: 모든 변화에 0.3초 ease-out 효과 */
    transition: all 0.3s ease-out;
}

.hover-button:hover,
.hover-button:focus-visible {
    background-color: #27ae60; /* 진한 에메랄드 */
    transform: scale(1.05) translateY(-5px); /* 확대 및 살짝 위로 이동 */
    box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2); /* 그림자 진하게 */
}

.box-grow {
    width: 150px;
    height: 150px;
    background-color: #e74c3c; /* 벽돌색 */
    color: white;
    display: flex;
    justify-content: center;
    align-items: center;
    border-radius: 15px;
    font-size: 1.1em;
    cursor: pointer;

    /* 트랜지션 적용: 너비, 높이, 배경색, border-radius에 0.4초 ease 효과 */
    transition: width 0.4s ease, height 0.4s ease,
                background-color 0.4s ease, border-radius 0.4s ease;
}

.box-grow:hover {
    width: 200px; /* 너비 증가 */
    height: 200px; /* 높이 증가 */
    background-color: #c0392b; /* 배경색 변경 */
    border-radius: 50%; /* 원형으로 변경 */
}

/* ------------------------------------------- */
/* 2. CSS 애니메이션 예시 */
/* ------------------------------------------- */

/* 로딩 스피너 애니메이션 */
@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

.loading-spinner {
    width: 60px;
    height: 60px;
    border: 6px solid #f3f3f3; /* 밝은 회색 테두리 */
    border-top: 6px solid #3498db; /* 파란색 상단 테두리 */
    border-radius: 50%;
    margin: 30px auto; /* 중앙 정렬 */

    /* 애니메이션 적용 */
    animation: spin 1.2s linear infinite; /* 1.2초 동안 선형 무한 회전 */
}

/* 통통 튀는 공 애니메이션 */
@keyframes bounce {
    0%, 100% { transform: translateY(0); } /* 시작과 끝은 제자리 */
    50% { transform: translateY(-50px); } /* 중간에 위로 50px */
}

.bouncing-ball {
    width: 50px;
    height: 50px;
    background-color: #f1c40f; /* 노란색 */
    border-radius: 50%;
    margin: 50px auto;
    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);

    /* 애니메이션 적용 */
    animation: bounce 1.5s ease-in-out infinite alternate; /* 1.5초 동안 ease-in-out으로 무한히 왕복 */
}

/* 슬라이드인 텍스트 애니메이션 */
@keyframes slideInFromLeft {
    0% {
        transform: translateX(-100%); /* 자기 너비만큼 왼쪽에서 시작 */
        opacity: 0;
    }
    100% {
        transform: translateX(0); /* 원래 위치로 이동 */
        opacity: 1;
    }
}

.slide-in-text {
    font-size: 2.5em;
    font-weight: bold;
    color: #27ae60;
    text-align: center;
    margin-top: 50px;
    opacity: 0; /* 초기에는 투명하게 */

    /* 애니메이션 적용 */
    animation: slideInFromLeft 1s ease-out forwards; /* 1초 동안 ease-out으로 재생 후 마지막 상태 유지 */
    animation-delay: 0.5s; /* 0.5초 후에 애니메이션 시작 */
}

/* 푸터 */
footer {
    background-color: #34495e;
    color: #ecf0f1;
    text-align: center;
    padding: 20px 0;
    margin-top: 50px;
    font-size: 0.9em;
}

Live Server로 dynamic_web.html을 엽니다. 초록 버튼에 마우스를 올리거나 Tab 키로 초점을 옮기면 색이 진해지면서 살짝 커지고 위로 떠오르며, 빨간 상자에 마우스를 올리면 크기가 커지면서 0.4초에 걸쳐 원으로 바뀝니다. 페이지를 열자마자 스피너가 돌기 시작하고 노란 공이 계속 튀어 오르며, 0.5초 뒤에는 “짜잔! 등장하는 텍스트!”가 왼쪽에서 미끄러져 나타납니다.

.slide-in-text는 0.5초 동안 원래 스타일의 opacity: 0으로 보이지 않다가, 1초 동안 나타난 뒤 forwards로 보이는 상태를 유지합니다. 앞 예제의 .fade-in-box는 반대로 backwards로 기다리는 동안 0% 단계를 적용하고, 끝난 뒤에는 원래 스타일로 돌아갑니다. 원래 스타일도 보이는 상태라 두 요소의 마지막 화면은 비슷하지만, 그 상태를 만드는 방법은 다릅니다. .bouncing-ball의 0%와 100%는 같은 위치이므로 한 주기 안에서 올라갔다 내려오며, alternate는 다음 주기의 재생 방향을 뒤집을 뿐입니다.

움직임 줄이기

운영체제에서 움직임 줄이기를 켠 사용자를 위해, motion.css 맨 뒤에 다음 규칙을 더합니다. prefers-reduced-motion: reduce는 사용자가 그 설정을 켰을 때만 적용되는 미디어 쿼리입니다.

@media (prefers-reduced-motion: reduce) {
    .hover-button, .box-grow { transition: none; }
    .hover-button:hover, .hover-button:focus-visible { transform: none; }
    .loading-spinner, .bouncing-ball, .slide-in-text { animation: none; }
    .slide-in-text { opacity: 1; transform: none; }
}

애니메이션만 끄면 원래 스타일이 opacity: 0인 등장 문구가 계속 숨어 있게 되므로, 보이는 상태를 함께 지정했습니다. 개발자 도구의 Rendering 패널에서 prefers-reduced-motion을 reduce로 바꾸면 이 설정을 흉내 내어 확인할 수 있습니다. 이 실습의 스피너는 장식일 뿐 실제 진행 상황을 나타내지 않으며, 실제 로딩 표시에는 “불러오는 중” 같은 읽을 수 있는 문구도 함께 둡니다.


정리

  • 트랜지션은 계산된 속성값이 바뀔 때 그 변화를 정한 시간 동안 이어 주며, transition-property, duration, timing-function, delay로 정합니다.
  • 트랜지션은 지금 값에서 새 목표값으로 이어 주므로, 도중에 목표가 바뀌면 그 순간의 값에서 다시 시작합니다.
  • 애니메이션은 @keyframes로 단계를 정하고 animation-name과 양의 animation-duration을 지정해야 재생됩니다.
  • animation-iteration-count, direction, fill-mode, play-state로 반복, 방향, 재생 전후의 상태, 일시 정지를 정합니다.
  • forwards는 마지막 주기가 끝난 상태를, backwards는 지연 중에 첫 주기의 시작 상태를 적용합니다.
  • 간단한 상태 변화에는 트랜지션을, 여러 단계나 반복에는 애니메이션을 쓰고, prefers-reduced-motion으로 움직임을 줄인 경우에도 정보가 전달되게 합니다.

연습 문제

1. 시간에 따른 등장 문구

.slide-in-text의 animation-delay를 1초로, duration을 2초로 바꿨습니다. 다른 값은 그대로이고 움직임 줄이기는 꺼져 있습니다. 페이지를 연 뒤 0.5초, 2초, 3.5초에 문구는 각각 기다리는 중, 재생 중, 끝난 상태 가운데 무엇일까요?

해설 보기

0.5초는 아직 1초의 지연 시간 안이므로 기다리는 중입니다. 이 요소는 backwards가 아니라 원래 스타일의 opacity: 0이 적용되어 보이지 않습니다.

2초는 지연 1초 뒤 2초짜리 주기의 절반이므로 재생 중입니다. 다만 ease-out 곡선이 있어 위치와 불투명도가 정확히 절반이라고 할 수는 없습니다. 3.5초에는 3초에 끝난 상태를 forwards가 유지하므로 문구가 보입니다. 브라우저 탭이 백그라운드에 있으면 애니메이션이 멈췄다 재개되어 실제로 보는 시점이 달라질 수 있습니다.

2. 그림자는 왜 바로 나타날까

트랜지션 예제의 .my-button에서 마우스를 올리면 배경색과 위치는 0.3초에 걸쳐 바뀌는데, 그림자는 즉시 나타납니다. 그림자도 함께 부드럽게 나타나게 하려면 무엇을 바꿔야 할까요?

해설 보기

transition에 나열한 속성이 background-color와 transform뿐이라, 목록에 없는 box-shadow의 변화는 전환 없이 바로 적용되기 때문입니다.

transition: background-color 0.3s ease-out, transform 0.3s ease-out, box-shadow 0.3s ease-out;처럼 box-shadow를 목록에 더하면 그림자도 0.3초에 걸쳐 나타납니다. transition: all 0.3s ease-out;으로 바꿔도 되지만, 의도하지 않은 속성까지 전환될 수 있으므로 필요한 속성을 나열하는 편이 안전합니다.

3. 지연 중의 모습

.fade-in-box의 animation-fill-mode를 backwards에서 none으로 바꿨습니다. 페이지를 연 뒤 0.5초 동안 상자는 어떻게 보일까요?

해설 보기

0.5초 동안 상자가 원래 스타일대로 완전히 보입니다. none은 재생하는 동안에만 키프레임을 적용하므로, 기다리는 동안에는 원래 스타일인 불투명하고 제자리에 있는 상태가 됩니다.

그러다 0.5초가 되어 재생이 시작되는 순간 0% 단계가 적용되어 상자가 갑자기 투명해지고 20px 아래로 내려갔다가, 다시 올라오며 나타납니다. 이렇게 깜박이는 것을 막으려고 backwards를 썼습니다.

다음 글 CSS 변수와 계산식에서는 CSS 변수로 색과 간격 같은 값을 한곳에서 관리하고, calc()로 서로 다른 단위를 섞어 계산하는 방법을 다룹니다.