CSS 애니메이션 | Transition, Animation, Transform

이 글의 핵심

애니메이션을 넣었더니 스크롤이 버벅이고 60fps가 나오지 않았던 경험에서 출발합니다. width나 top 대신 transform과 opacity를 움직여야 하는 이유, will-change를 남발하면 오히려 메모리를 낭비하는 함정을 짚고, transition과 @keyframes 중 무엇을 고를지 예제로 판단할 수 있게 합니다.

들어가며

60fps 안 나와서…

예전에 대시보드와 랜딩 페이지 두 곳 모두에서 스크롤할 때 프레임이 40대로 떨어지는 것을 본 적이 있습니다. 맥에서는 “어? 부드러운데?” 싶었지만, 구형 안드로이드나 저전력 노트북에서는 화면이 툭툭 끊겼습니다. Performance 패널을 열어 보니 width·left·box-shadow·filter 변경이 한 프레임에 몰려 있었습니다. 저는 여기서 답이 하나라고 봅니다. GPU 가속이 답입니다. transform과 opacity로 흉내 낼 수 있으면 그쪽으로 옮기고, 그럴 수 없으면 애초에 그 모션을 빼는 것입니다. 그렇게 정리하니 60fps에 가깝게 돌아왔습니다. (완벽한 60fps에 목숨을 걸지는 않지만, 체감은 완전히 달라졌습니다.)

transition·animation은 상태(호버, 로딩, 열림/닫힘)를 보여 주는 데 쓰고, 복잡한 로직은 JS로 넘기는 방식이 좋습니다. 보기 좋기만 하면 끝나는 것이 아니라, 눈에 보이는 전환과 메인 스레드를 막지 않는 것을 둘 다 챙겨야 한다고 봅니다.

애니메이션을 써도 좋은 경우 (제 기준):

  • 체감·피드백: 눌렸다, 열렸다, 돌고 있다는 신호
  • 시선: 정말 중요한 데만 살짝
  • 성능: 기준에 맞지 않으면 “멋”을 포기하는 편입니다

뼈대만 세면 이 세 가지:

  1. Transition: A → B 한 번의 변화
  2. Transform: 레이아웃을 건드리지 않고 시각적으로만 움직이기
  3. Animation: 키프레임·반복·자동 재생

실제로 이렇게 문제가 생겼습니다

문제는 서버나 데이터 쪽이 아니라 스크롤·리플로우 쪽이었습니다. 문서들은 대개 “가능하면 GPU” 정도로만 적어 두지만, 현장에서는 transition: all에 width를 흔드는 조합이 너무 많습니다. 저도 처음에는 그랬습니다. 프레임이 나오지 않을 때 GPU 가속이 답이라는 말은, DevTools에서 한 번 기록해 보고 Layout/Paint가 붉게 뜨는 것을 직접 봤을 때 제대로 와닿습니다. 아래 코드와 섹션은 그 기준으로 골랐고, 트러블슈팅 부분에도 비슷한 사례를 몇 개 넣었습니다.

Transition (전환)

기본 사용

Transition은 CSS 속성이 부드럽게 변화하도록 합니다.

.box {
    width: 100px;
    height: 100px;
    background: #3498db;
    
    /* 속성 | 시간 | 타이밍 함수 | 지연 */
    transition: all 0.3s ease 0s;
}
.box:hover {
    width: 200px;
    background: #2ecc71;
}

동작 원리:

상태 A (초기)          상태 B (hover)
width: 100px    →     width: 200px
background: blue →    background: green
transition이 0.3초 동안 부드럽게 보간

개별 속성 지정

.box {
    /* 여러 속성 */
    transition: width 0.3s ease,
                background-color 0.5s linear,
                transform 0.2s ease-out;
}
/* 또는 개별 지정 */
.box {
    transition-property: width, background-color;
    transition-duration: 0.3s, 0.5s;
    transition-timing-function: ease, linear;
    transition-delay: 0s, 0.1s;
}

transition: all은 편하지만 피하는 편이 좋습니다. 이후에 다른 개발자가 같은 요소에 width나 box-shadow 변경을 추가하면 그것까지 모두 애니메이션되어, 의도하지 않은 레이아웃 애니메이션이 생기고 성능 문제가 여기서 시작됩니다. 또 all은 호버를 벗어날 때 테마 전환이나 반응형 레이아웃 변경까지 부드럽게 만들어 화면이 “늦게 따라오는” 느낌을 줍니다. 움직일 속성을 명시하면 코드를 읽는 사람도 어떤 변화가 애니메이션되는지 바로 알 수 있습니다.

transition은 두 계산된 값 사이를 보간하는 방식이라, 중간값을 계산할 수 없는 속성에는 동작하지 않습니다. display: none과 block 사이, height: auto로의 전환이 대표적입니다. 아코디언을 만들 때 height: 0에서 auto로 바꾸면 애니메이션 없이 순간적으로 열리는데, 흔히 max-height를 충분히 큰 값으로 전환하는 우회를 쓰고, 최신 브라우저에서는 interpolate-size: allow-keywords나 grid-template-rows: 0fr → 1fr 전환 같은 방법도 있습니다.

타이밍 함수 (Timing Function)

.box {
    /* 기본 함수 */
    transition-timing-function: linear;      /* 일정한 속도 */
    transition-timing-function: ease;        /* 천천히-빠르게-천천히 (기본값) */
    transition-timing-function: ease-in;     /* 천천히 시작 */
    transition-timing-function: ease-out;    /* 천천히 끝 */
    transition-timing-function: ease-in-out; /* 천천히 시작과 끝 */
    
    /* 커스텀 베지어 곡선 */
    transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);
    
    /* 단계 함수 */
    transition-timing-function: steps(4, end);
}

시각적 비교:

linear:      ────────────
ease:        ╱‾‾‾‾‾‾‾╲
ease-in:     ╱─────────
ease-out:    ─────────╲
ease-in-out: ╱‾‾‾‾‾‾╲

실전 예제

<!DOCTYPE html>
<html lang="ko">
<head>
    <style>
        .demo-container {
            display: flex;
            gap: 20px;
            padding: 20px;
            flex-wrap: wrap;
        }
        
        .box {
            width: 100px;
            height: 100px;
            background: #3498db;
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 10px;
            cursor: pointer;
        }
        
        .box-1 {
            transition: background 0.3s ease;
        }
        .box-1:hover {
            background: #e74c3c;
        }
        
        .box-2 {
            transition: transform 0.3s ease;
        }
        .box-2:hover {
            transform: scale(1.2);
        }
        
        .box-3 {
            transition: all 0.3s ease;
        }
        .box-3:hover {
            transform: rotate(45deg);
            background: #2ecc71;
        }
        
        .box-4 {
            transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
        }
        .box-4:hover {
            transform: scale(1.3) rotate(10deg);
        }
    </style>
</head>
<body>
    <div class="demo-container">
        <div class="box box-1">색상</div>
        <div class="box box-2">크기</div>
        <div class="box box-3">회전</div>
        <div class="box box-4">바운스</div>
    </div>
</body>
</html>

Transform (변형)

2D Transform

.box {
    /* 이동 (translate) */
    transform: translate(50px, 100px);  /* x, y */
    transform: translateX(50px);        /* x만 */
    transform: translateY(100px);       /* y만 */
    
    /* 크기 조절 (scale) */
    transform: scale(1.5);       /* 1.5배 */
    transform: scale(2, 0.5);    /* x 2배, y 0.5배 */
    transform: scaleX(2);        /* x만 */
    transform: scaleY(0.5);      /* y만 */
    
    /* 회전 (rotate) */
    transform: rotate(45deg);    /* 45도 회전 */
    transform: rotate(-90deg);   /* -90도 회전 */
    
    /* 기울이기 (skew) */
    transform: skew(10deg, 20deg);  /* x, y */
    transform: skewX(10deg);        /* x만 */
    transform: skewY(20deg);        /* y만 */
    
    /* 여러 개 조합 (순서 중요!) */
    transform: translate(50px, 50px) rotate(45deg) scale(1.5);
}

transform이 레이아웃에 영향을 주지 않는다는 것은 장점이자 함정입니다. translateX(100px)로 옮긴 요소가 차지하던 원래 자리는 그대로 비어 있고, 옆의 요소들도 움직이지 않습니다. 그래서 목록 항목을 “밀어내는” 효과가 필요하면 transform만으로는 안 되고, 레이아웃 변화를 측정한 뒤 그 차이를 transform으로 되돌려 재생하는 FLIP 기법 같은 방법을 씁니다. 또 scale(2)로 키운 요소는 선명도가 떨어질 수 있는데, 브라우저가 원래 크기로 그린 레이어를 늘려서 합성하기 때문입니다.

조합 순서의 중요성:

/* 다른 결과 */
.box-1 {
    transform: rotate(45deg) translate(100px, 0);
    /* 회전 후 이동 → 대각선 이동 */
}
.box-2 {
    transform: translate(100px, 0) rotate(45deg);
    /* 이동 후 회전 → 오른쪽 이동 + 회전 */
}

3D Transform

.box {
    /* 3D 이동 */
    transform: translateZ(100px);                    /* z축 */
    transform: translate3d(50px, 50px, 100px);      /* x, y, z */
    
    /* 3D 회전 */
    transform: rotateX(45deg);                       /* x축 회전 */
    transform: rotateY(45deg);                       /* y축 회전 */
    transform: rotateZ(45deg);                       /* z축 회전 (= rotate) */
    transform: rotate3d(1, 1, 1, 45deg);            /* 벡터 기준 회전 */
    
    /* 3D 크기 조절 */
    transform: scaleZ(2);
    transform: scale3d(1.5, 1.5, 2);
}

Perspective (원근감)

/* 부모에 적용 */
.container {
    perspective: 1000px;  /* 원근 거리 */
    perspective-origin: 50% 50%;  /* 시점 */
}
/* 또는 자식에 적용 */
.box {
    transform: perspective(1000px) rotateY(45deg);
}

예제:

<style>
    .perspective-demo {
        display: flex;
        gap: 50px;
        padding: 50px;
    }
    
    .container {
        perspective: 1000px;
        width: 200px;
        height: 200px;
    }
    
    .box {
        width: 100%;
        height: 100%;
        background: #3498db;
        color: white;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 2rem;
        transition: transform 0.6s;
    }
    
    .container:hover .box {
        transform: rotateY(180deg);
    }
    
    .container-1 { perspective: 500px; }   /* 가까움 */
    .container-2 { perspective: 1000px; }  /* 중간 */
    .container-3 { perspective: 2000px; }  /* 멀음 */
</style>
<div class="perspective-demo">
    <div class="container container-1">
        <div class="box">500px</div>
    </div>
    <div class="container container-2">
        <div class="box">1000px</div>
    </div>
    <div class="container container-3">
        <div class="box">2000px</div>
    </div>
</div>

Transform Origin (기준점)

.box {
    /* 기준점 변경 */
    transform-origin: center center;  /* 기본값 */
    transform-origin: top left;       /* 왼쪽 위 */
    transform-origin: bottom right;   /* 오른쪽 아래 */
    transform-origin: 50% 50%;        /* 퍼센트 */
    transform-origin: 0 0;            /* 픽셀 */
}

예제:

<style>
    .origin-demo {
        display: flex;
        gap: 50px;
        padding: 50px;
    }
    
    .box {
        width: 100px;
        height: 100px;
        background: #3498db;
        transition: transform 0.3s;
    }
    
    .box-1 {
        transform-origin: center;
    }
    .box-1:hover {
        transform: rotate(45deg);  /* 중심 기준 */
    }
    
    .box-2 {
        transform-origin: top left;
    }
    .box-2:hover {
        transform: rotate(45deg);  /* 왼쪽 위 기준 */
    }
    
    .box-3 {
        transform-origin: bottom right;
    }
    .box-3:hover {
        transform: rotate(45deg);  /* 오른쪽 아래 기준 */
    }
</style>
<div class="origin-demo">
    <div class="box box-1"></div>
    <div class="box box-2"></div>
    <div class="box box-3"></div>
</div>

Animation (@keyframes)

Keyframes 정의

/* from-to 형식 */
@keyframes slide {
    from {
        transform: translateX(0);
        opacity: 0;
    }
    to {
        transform: translateX(300px);
        opacity: 1;
    }
}
/* 퍼센트 형식 (더 유연) */
@keyframes bounce {
    0% {
        transform: translateY(0);
    }
    50% {
        transform: translateY(-100px);
    }
    100% {
        transform: translateY(0);
    }
}
/* 복잡한 애니메이션 */
@keyframes complex {
    0% {
        transform: translateX(0) rotate(0deg);
        background: #3498db;
    }
    25% {
        transform: translateX(100px) rotate(90deg);
        background: #2ecc71;
    }
    50% {
        transform: translateX(200px) rotate(180deg);
        background: #e74c3c;
    }
    75% {
        transform: translateX(100px) rotate(270deg);
        background: #f39c12;
    }
    100% {
        transform: translateX(0) rotate(360deg);
        background: #3498db;
    }
}

Animation 적용

.box {
    /* 개별 속성 */
    animation-name: slide;                    /* 애니메이션 이름 */
    animation-duration: 2s;                   /* 지속 시간 */
    animation-timing-function: ease-in-out;   /* 타이밍 함수 */
    animation-delay: 0s;                      /* 지연 시간 */
    animation-iteration-count: infinite;      /* 반복 횟수 */
    animation-direction: alternate;           /* 방향 */
    animation-fill-mode: forwards;            /* 채우기 모드 */
    animation-play-state: running;            /* 재생 상태 */
    
    /* 축약형 (권장) */
    animation: slide 2s ease-in-out 0s infinite alternate forwards;
}

Animation 속성 상세

.box {
    /* 반복 횟수 */
    animation-iteration-count: 1;        /* 1번 */
    animation-iteration-count: 3;        /* 3번 */
    animation-iteration-count: infinite; /* 무한 */
    
    /* 방향 */
    animation-direction: normal;           /* 정방향 (기본값) */
    animation-direction: reverse;          /* 역방향 */
    animation-direction: alternate;        /* 정방향-역방향 반복 */
    animation-direction: alternate-reverse;/* 역방향-정방향 반복 */
    
    /* 채우기 모드 */
    animation-fill-mode: none;      /* 기본값, 원래 상태 */
    animation-fill-mode: forwards;  /* 마지막 상태 유지 */
    animation-fill-mode: backwards; /* 첫 상태로 시작 */
    animation-fill-mode: both;      /* forwards + backwards */
    
    /* 재생 상태 */
    animation-play-state: running;  /* 재생 (기본값) */
    animation-play-state: paused;   /* 일시정지 */
}

실전 예제

<style>
    @keyframes pulse {
        0%, 100% {
            transform: scale(1);
            opacity: 1;
        }
        50% {
            transform: scale(1.1);
            opacity: 0.8;
        }
    }
    
    .notification {
        position: relative;
        display: inline-block;
        padding: 10px 20px;
        background: #3498db;
        color: white;
        border-radius: 5px;
    }
    
    .notification::after {
        content: ';
        position: absolute;
        top: -5px;
        right: -5px;
        width: 20px;
        height: 20px;
        background: #e74c3c;
        border-radius: 50%;
        animation: pulse 1.5s ease-in-out infinite;
    }
</style>
<div class="notification">
    새 메시지
</div>

버튼·로딩·카드 플립·페이드 인 애니메이션 모음

버튼 효과 모음

<!DOCTYPE html>
<html lang="ko">
<head>
    <style>
        .button-demo {
            display: flex;
            gap: 20px;
            padding: 50px;
            flex-wrap: wrap;
        }
        
        .btn {
            padding: 15px 30px;
            background: #3498db;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 1rem;
            cursor: pointer;
            text-decoration: none;
            display: inline-block;
        }
        
        /* 1. 호버 리프트 */
        .btn-lift {
            transition: all 0.3s ease;
        }
        .btn-lift:hover {
            transform: translateY(-5px);
            box-shadow: 0 10px 20px rgba(0,0,0,0.2);
        }
        .btn-lift:active {
            transform: translateY(-2px);
            box-shadow: 0 5px 10px rgba(0,0,0,0.2);
        }
        
        /* 2. 확대 */
        .btn-scale {
            transition: transform 0.3s ease;
        }
        .btn-scale:hover {
            transform: scale(1.1);
        }
        
        /* 3. 그라데이션 이동 */
        .btn-gradient {
            background: linear-gradient(90deg, #3498db, #2ecc71);
            background-size: 200% 100%;
            background-position: 0% 0%;
            transition: background-position 0.5s ease;
        }
        .btn-gradient:hover {
            background-position: 100% 0%;
        }
        
        /* 4. 테두리 애니메이션 */
        .btn-border {
            position: relative;
            overflow: hidden;
            transition: color 0.3s ease;
        }
        .btn-border::before {
            content: ';
            position: absolute;
            top: 0;
            left: -100%;
            width: 100%;
            height: 100%;
            background: #2ecc71;
            transition: left 0.3s ease;
            z-index: -1;
        }
        .btn-border:hover {
            color: white;
        }
        .btn-border:hover::before {
            left: 0;
        }
        
        /* 5. 흔들기 */
        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            25% { transform: translateX(-10px); }
            75% { transform: translateX(10px); }
        }
        .btn-shake:hover {
            animation: shake 0.3s ease;
        }
    </style>
</head>
<body>
    <div class="button-demo">
        <button class="btn btn-lift">리프트</button>
        <button class="btn btn-scale">확대</button>
        <button class="btn btn-gradient">그라데이션</button>
        <button class="btn btn-border">테두리</button>
        <button class="btn btn-shake">흔들기</button>
    </div>
</body>
</html>

로딩 애니메이션

<style>
    /* 스피너 */
    @keyframes spin {
        to {
            transform: rotate(360deg);
        }
    }
    .spinner {
        width: 50px;
        height: 50px;
        border: 5px solid #f3f3f3;
        border-top: 5px solid #3498db;
        border-radius: 50%;
        animation: spin 1s linear infinite;
    }
    
    /* 점 3개 */
    @keyframes bounce {
        0%, 80%, 100% {
            transform: scale(0);
        }
        40% {
            transform: scale(1);
        }
    }
    
    .dots {
        display: flex;
        gap: 10px;
    }
    
    .dot {
        width: 15px;
        height: 15px;
        background: #3498db;
        border-radius: 50%;
        animation: bounce 1.4s infinite ease-in-out;
    }
    
    .dot:nth-child(1) { animation-delay: -0.32s; }
    .dot:nth-child(2) { animation-delay: -0.16s; }
    .dot:nth-child(3) { animation-delay: 0s; }
    
    /* 바 */
    @keyframes progress {
        0% {
            transform: translateX(-100%);
        }
        100% {
            transform: translateX(400%);
        }
    }
    
    .progress-bar {
        width: 200px;
        height: 4px;
        background: #ecf0f1;
        border-radius: 2px;
        overflow: hidden;
    }
    
    .progress-bar::after {
        content: ';
        display: block;
        width: 20%;
        height: 100%;
        background: #3498db;
        animation: progress 1.5s ease-in-out infinite;
    }
</style>
<div style="display: flex; gap: 50px; padding: 50px;">
    <div class="spinner"></div>
    
    <div class="dots">
        <div class="dot"></div>
        <div class="dot"></div>
        <div class="dot"></div>
    </div>
    
    <div class="progress-bar"></div>
</div>

카드 플립

<style>
    .flip-container {
        perspective: 1000px;
        width: 300px;
        height: 200px;
    }
    .flip-card {
        width: 100%;
        height: 100%;
        position: relative;
        transform-style: preserve-3d;
        transition: transform 0.6s;
    }
    .flip-container:hover .flip-card {
        transform: rotateY(180deg);
    }
    .flip-front, .flip-back {
        position: absolute;
        width: 100%;
        height: 100%;
        backface-visibility: hidden;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 10px;
        font-size: 1.5rem;
        font-weight: bold;
    }
    .flip-front {
        background: linear-gradient(135deg, #667eea, #764ba2);
        color: white;
    }
    .flip-back {
        background: linear-gradient(135deg, #f093fb, #f5576c);
        color: white;
        transform: rotateY(180deg);
    }
</style>
<div class="flip-container">
    <div class="flip-card">
        <div class="flip-front">앞면</div>
        <div class="flip-back">뒷면</div>
    </div>
</div>

페이드 인 애니메이션

<style>
    @keyframes fadeIn {
        from {
            opacity: 0;
            transform: translateY(30px);
        }
        to {
            opacity: 1;
            transform: translateY(0);
        }
    }
    
    @keyframes fadeInLeft {
        from {
            opacity: 0;
            transform: translateX(-50px);
        }
        to {
            opacity: 1;
            transform: translateX(0);
        }
    }
    
    @keyframes fadeInScale {
        from {
            opacity: 0;
            transform: scale(0.8);
        }
        to {
            opacity: 1;
            transform: scale(1);
        }
    }
    .animate-on-scroll {
        animation: fadeIn 1s ease-out;
    }
    
    .fade-in-1 { animation: fadeIn 1s ease-out 0s both; }
    .fade-in-2 { animation: fadeIn 1s ease-out 0.2s both; }
    .fade-in-3 { animation: fadeIn 1s ease-out 0.4s both; }
    .fade-in-4 { animation: fadeInLeft 1s ease-out 0.6s both; }
    .fade-in-5 { animation: fadeInScale 1s ease-out 0.8s both; }
    
    .content-box {
        padding: 20px;
        margin: 20px;
        background: white;
        border-radius: 10px;
        box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    }
</style>
<div class="content-box fade-in-1">첫 번째 요소</div>
<div class="content-box fade-in-2">두 번째 요소</div>
<div class="content-box fade-in-3">세 번째 요소</div>
<div class="content-box fade-in-4">네 번째 요소 (왼쪽에서)</div>
<div class="content-box fade-in-5">다섯 번째 요소 (확대)</div>

무한 애니메이션

<style>
    @keyframes float {
        0%, 100% {
            transform: translateY(0);
        }
        50% {
            transform: translateY(-20px);
        }
    }
    
    @keyframes rotate {
        from {
            transform: rotate(0deg);
        }
        to {
            transform: rotate(360deg);
        }
    }
    
    @keyframes pulse {
        0%, 100% {
            transform: scale(1);
            opacity: 1;
        }
        50% {
            transform: scale(1.05);
            opacity: 0.8;
        }
    }
    
    .float-box {
        width: 100px;
        height: 100px;
        background: #3498db;
        border-radius: 10px;
        animation: float 3s ease-in-out infinite;
    }
    
    .rotate-box {
        width: 100px;
        height: 100px;
        background: #e74c3c;
        animation: rotate 4s linear infinite;
    }
    
    .pulse-box {
        width: 100px;
        height: 100px;
        background: #2ecc71;
        border-radius: 50%;
        animation: pulse 2s ease-in-out infinite;
    }
</style>
<div style="display: flex; gap: 50px; padding: 50px;">
    <div class="float-box"></div>
    <div class="rotate-box"></div>
    <div class="pulse-box"></div>
</div>

GPU 가속 속성과 will-change

GPU 가속 속성

빠른 속성 (GPU 가속):

.box {
    /* ✅ 권장: 레이아웃에 영향 없음 */
    transform: translateX(100px);
    transform: scale(1.5);
    transform: rotate(45deg);
    opacity: 0.5;
}

느린 속성 (레이아웃 변경):

// 실행 예제
.box {
    /* ❌ 비권장: 레이아웃 재계산 */
    width: 200px;
    height: 200px;
    left: 100px;
    top: 100px;
    margin: 20px;
    padding: 20px;
}

성능비 — 표 말고 감으로

저는 합성(Composite) 단계만 타는 속성을 기본으로 둡니다. 그것이 곧 GPU 가속 경로와 잘 맞습니다.

  • transform·opacity: 레이아웃을 건드리지 않고 합성 위주로 처리 → 빠르다고 믿고 씁니다.
  • background-color 같은 속성은 페인트 단계 → 괜찮지만, 동시에 많이 돌리면 부담이 됩니다.
  • width·height·left·top·margin 류: 레이아웃·페인트·합성을 모두 탐 → 60fps가 안 나올 때 가장 먼저 의심합니다.

즉, “표로 암기”하기보다 DevTools에서 빨간 부분이 어디인지를 보는 것이 빠릅니다. 제 취향으로는 의심스러우면 transform으로 우회하는 것이 1순위입니다.

will-change

애니메이션 전에 브라우저에 미리 알립니다:

.box {
    /* 애니메이션 전 최적화 힌트 */
    will-change: transform, opacity;
}
.box:hover {
    transform: scale(1.2);
    opacity: 0.8;
}

주의사항:

/* ❌ 나쁨: 모든 요소에 적용 */
* {
    will-change: transform;  /* 메모리 낭비 */
}
/* ✅ 좋음: 필요한 요소만 */
.animated-element {
    will-change: transform;
}
/* ✅ 더 좋음: JavaScript로 동적 추가/제거 */
element.addEventListener('mouseenter', () => {
    element.style.willChange = 'transform';
});
element.addEventListener('animationend', () => {
    element.style.willChange = 'auto';
});

transform3d 강제 GPU 가속

.box {
    /* 2D transform도 GPU 가속 */
    transform: translateZ(0);  /* 또는 translate3d(0, 0, 0) */
}

끊김·즉시 시작·원래 상태 복귀 같은 문제

애니메이션이 끊김

문제: 60fps를 유지하지 못함 해결:

/* ❌ 나쁨 */
.box {
    transition: width 0.3s;  /* 레이아웃 재계산 */
}
.box:hover {
    width: 200px;
}
/* ✅ 좋음 */
.box {
    transition: transform 0.3s;  /* GPU 가속 */
}
.box:hover {
    transform: scaleX(2);
}

애니메이션이 즉시 시작됨

문제: 페이지 로드 시 애니메이션이 바로 실행 해결:

/* ❌ 나쁨 */
.box {
    animation: slide 2s ease-in-out infinite;
}
/* ✅ 좋음: 클래스로 제어 */
.box.animate {
    animation: slide 2s ease-in-out infinite;
}
// JavaScript로 클래스 추가
setTimeout(() => {
    document.querySelector('.box').classList.add('animate');
}, 100);

애니메이션 후 원래 상태로 돌아감

문제: 애니메이션 종료 후 초기 상태로 복귀 해결:

.box {
    animation: slide 2s ease-in-out forwards;
    /*                              ↑ 마지막 상태 유지 */
}

transform 조합 순서

문제: 예상과 다른 결과

/* 다른 결과 */
transform: rotate(45deg) translate(100px, 0);
/* vs */
transform: translate(100px, 0) rotate(45deg);

해결: 순서를 이해하고 의도에 맞게 작성

z-index가 작동하지 않음

문제: transform 사용 시 z-index 무시

실제 원인은 z-index가 무시되는 것이 아니라 새로운 쌓임 맥락(stacking context)이 생기는 것입니다. transform, opacity가 1 미만인 값, filter, will-change: transform 중 하나라도 걸린 요소는 자기만의 쌓임 맥락을 만들고, 그 안의 자식은 z-index를 9999로 줘도 부모의 쌓임 맥락 밖에 있는 요소보다 위로 올라갈 수 없습니다. 카드에 호버 애니메이션용 transform을 넣은 뒤 카드 안의 드롭다운 메뉴가 옆 카드 아래로 깔리는 현상이 이것입니다. 아래 코드처럼 같은 쌓임 맥락 안에서의 순서는 z-index로 조정할 수 있지만, 모달이나 툴팁처럼 화면 전체에서 위에 떠야 하는 요소는 transform이 걸린 조상 밖으로 꺼내 body 가까이에 두는 것이 근본적인 해결책입니다. 같은 이유로 transform이 걸린 조상 안에서는 position: fixed도 뷰포트가 아니라 그 조상을 기준으로 배치됩니다. 해결:

.box {
    position: relative;  /* 또는 absolute */
    z-index: 10;
    transform: translateX(50px);
}

타이밍 함수 선택·반응형 애니메이션·디버깅

자주 사용하는 애니메이션

/* 페이드 인 */
@keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}
/* 슬라이드 인 */
@keyframes slideIn {
    from { transform: translateX(-100%); }
    to { transform: translateX(0); }
}
/* 바운스 */
@keyframes bounce {
    0%, 20%, 50%, 80%, 100% {
        transform: translateY(0);
    }
    40% {
        transform: translateY(-30px);
    }
    60% {
        transform: translateY(-15px);
    }
}
/* 흔들기 */
@keyframes shake {
    0%, 100% { transform: translateX(0); }
    10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
    20%, 40%, 60%, 80% { transform: translateX(10px); }
}
/* 회전 */
@keyframes rotate {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
}

타이밍 함수 선택

/* 자연스러운 움직임 */
.natural { transition: all 0.3s ease-out; }
/* 바운스 효과 */
.bounce { transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); }
/* 부드러운 시작 */
.smooth { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); }

반응형 애니메이션

움직임이 큰 애니메이션은 전정 기관 장애가 있는 사용자에게 어지럼증을 일으킬 수 있어서, 운영체제에 “동작 줄이기” 설정이 있습니다. CSS에서는 @media (prefers-reduced-motion: reduce)로 이 설정을 감지할 수 있습니다. 애니메이션을 완전히 없애기보다 이동·회전·확대는 끄고 짧은 페이드 정도만 남기는 방식이 일반적이며, 로딩 스피너처럼 상태를 알리는 애니메이션은 속도를 낮춰 유지하는 편이 좋습니다.

/* 데스크톱: 애니메이션 활성화 */
.box {
    transition: transform 0.3s ease;
}
/* 모바일: 애니메이션 비활성화 (성능) */
@media (max-width: 768px) {
    .box {
        transition: none;
    }
}
/* 사용자 설정 존중 */
@media (prefers-reduced-motion: reduce) {
    * {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

디버깅

/* 애니메이션 느리게 */
.box {
    animation: slide 10s ease-in-out infinite;
    /*              ↑ 10초로 느리게 */
}
/* 일시정지 */
.box {
    animation-play-state: paused;
}

Chrome DevTools:

  1. Elements 패널
  2. Animations 탭
  3. 애니메이션 타임라인 확인

다중 애니메이션·체이닝·JavaScript 제어

다중 애니메이션

.box {
    animation: 
        slide 2s ease-in-out infinite,
        rotate 3s linear infinite,
        pulse 1s ease-in-out infinite;
}

애니메이션 체이닝

@keyframes sequence {
    0% {
        transform: translateX(0);
        background: #3498db;
    }
    33% {
        transform: translateX(100px);
        background: #2ecc71;
    }
    66% {
        transform: translateX(100px) translateY(100px);
        background: #e74c3c;
    }
    100% {
        transform: translateX(0) translateY(0);
        background: #3498db;
    }
}
.box {
    animation: sequence 3s ease-in-out infinite;
}

JavaScript 제어

<style>
    @keyframes slide {
        from { transform: translateX(0); }
        to { transform: translateX(300px); }
    }
    
    .box {
        width: 100px;
        height: 100px;
        background: #3498db;
    }
    
    .box.animate {
        animation: slide 1s ease-in-out forwards;
    }
</style>
<div class="box" id="box"></div>
<button id="startBtn">시작</button>
<button id="pauseBtn">일시정지</button>
<button id="resetBtn">리셋</button>
<script>
    const box = document.getElementById('box');
    const startBtn = document.getElementById('startBtn');
    const pauseBtn = document.getElementById('pauseBtn');
    const resetBtn = document.getElementById('resetBtn');
    
    startBtn.addEventListener('click', () => {
        box.classList.add('animate');
        box.style.animationPlayState = 'running';
    });
    
    pauseBtn.addEventListener('click', () => {
        box.style.animationPlayState = 'paused';
    });
    
    resetBtn.addEventListener('click', () => {
        box.classList.remove('animate');
        void box.offsetWidth;  /* 리플로우 강제 */
        box.classList.add('animate');
    });
    
    box.addEventListener('animationend', () => {
        console.log('애니메이션 완료');
    });
</script>

인터랙티브 카드 만들기

프로젝트: 인터랙티브 카드

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>인터랙티브 카드</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            font-family: system-ui, -apple-system, sans-serif;
            background: linear-gradient(135deg, #667eea, #764ba2);
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }
        
        .card-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
            gap: 30px;
            max-width: 1200px;
        }
        
        .card {
            background: white;
            border-radius: 15px;
            overflow: hidden;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            cursor: pointer;
        }
        
        .card:hover {
            transform: translateY(-10px);
            box-shadow: 0 20px 40px rgba(0,0,0,0.3);
        }
        
        .card-image {
            width: 100%;
            height: 200px;
            background: linear-gradient(135deg, #667eea, #764ba2);
            position: relative;
            overflow: hidden;
        }
        
        .card-image::before {
            content: ';
            position: absolute;
            top: -50%;
            left: -50%;
            width: 200%;
            height: 200%;
            background: linear-gradient(
                45deg,
                transparent,
                rgba(255,255,255,0.3),
                transparent
            );
            transform: translateX(-100%);
            transition: transform 0.6s;
        }
        
        .card:hover .card-image::before {
            transform: translateX(100%);
        }
        
        .card-content {
            padding: 25px;
        }
        
        .card h3 {
            margin: 0 0 10px 0;
            color: #2c3e50;
            font-size: 1.5rem;
        }
        
        .card p {
            margin: 0 0 20px 0;
            color: #7f8c8d;
            line-height: 1.6;
        }
        
        .card-footer {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        
        .price {
            font-size: 1.8rem;
            font-weight: bold;
            color: #3498db;
        }
        
        .btn {
            padding: 10px 20px;
            background: #3498db;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            transition: all 0.3s ease;
            position: relative;
            overflow: hidden;
        }
        
        .btn::before {
            content: ';
            position: absolute;
            top: 50%;
            left: 50%;
            width: 0;
            height: 0;
            background: rgba(255,255,255,0.3);
            border-radius: 50%;
            transform: translate(-50%, -50%);
            transition: width 0.6s, height 0.6s;
        }
        
        .btn:hover::before {
            width: 300px;
            height: 300px;
        }
        
        .btn:hover {
            background: #2980b9;
            transform: scale(1.05);
        }
        
        @keyframes fadeInUp {
            from {
                opacity: 0;
                transform: translateY(30px);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }
        
        .card {
            animation: fadeInUp 0.6s ease-out;
        }
        
        .card:nth-child(1) { animation-delay: 0s; }
        .card:nth-child(2) { animation-delay: 0.1s; }
        .card:nth-child(3) { animation-delay: 0.2s; }
    </style>
</head>
<body>
    <div class="card-grid">
        <div class="card">
            <div class="card-image"></div>
            <div class="card-content">
                <h3>상품 1</h3>
                <p>멋진 상품입니다. 지금 구매하세요!</p>
                <div class="card-footer">
                    <span class="price">29,000원</span>
                    <button class="btn">구매</button>
                </div>
            </div>
        </div>
        
        <div class="card">
            <div class="card-image"></div>
            <div class="card-content">
                <h3>상품 2</h3>
                <p>최고의 품질을 자랑합니다.</p>
                <div class="card-footer">
                    <span class="price">39,000원</span>
                    <button class="btn">구매</button>
                </div>
            </div>
        </div>
        
        <div class="card">
            <div class="card-image"></div>
            <div class="card-content">
                <h3>상품 3</h3>
                <p>특별한 할인 중입니다.</p>
                <div class="card-footer">
                    <span class="price">49,000원</span>
                    <button class="btn">구매</button>
                </div>
            </div>
        </div>
    </div>
</body>
</html>

브라우저 지원과 벤더 프리픽스

지원 현황 (표 대신 짧게)

요즘 쓰는 크로미움·파이어폭스·사파리·엣지라면 transition / transform / animation은 당연히 지원된다고 봐도 됩니다(구형 기준으로는 각각 26·36·43 버전 이후쯤부터였던 것으로 기억합니다). will-change는 IE를 제외하고, 사파리는 9.1+부터 지원합니다. 버전을 외우기보다 caniuse를 한 번 확인하는 편이 낫습니다. 저는 표를 외우는 대신 “프로젝트가 지원하는 최소 브라우저”에만 맞춥니다.

벤더 프리픽스 (구형 브라우저)

.box {
    -webkit-transition: all 0.3s ease;  /* Safari, Chrome */
    -moz-transition: all 0.3s ease;     /* Firefox */
    -o-transition: all 0.3s ease;       /* Opera */
    transition: all 0.3s ease;          /* 표준 */
    
    -webkit-transform: rotate(45deg);
    -moz-transform: rotate(45deg);
    -ms-transform: rotate(45deg);
    transform: rotate(45deg);
}
@-webkit-keyframes slide {
    from { -webkit-transform: translateX(0); }
    to { -webkit-transform: translateX(300px); }
}
@keyframes slide {
    from { transform: translateX(0); }
    to { transform: translateX(300px); }
}

CSS 애니메이션 요약

핵심 요약

  1. Transition: A→B. 피드백용입니다.
  2. Transform: 시각적으로만 옮기고, 레이아웃은 건드리지 않습니다.
  3. Animation: 키프레임·루프. 스피너 같은 용도입니다.
  4. 성능: 저는 transform·opacity를 믿는 편입니다. 60fps가 안 나오면 여기부터 봅니다.
  5. will-change: 힌트일 뿐이니 남발하지 않습니다(위에서도 설명했습니다).
  6. 타이밍: UI에는 ease-out을 많이 쓰고, 스피너에는 linear를 씁니다.
  7. fill-mode: 멈출 프레임을 정할 때 forwards를 함께 씁니다.

transition이냐 animation이냐 (표 대신 리스트로)

  • transition: 두 상태 사이만 부드럽게 이어 줍니다. hover·focus·클래스 토글처럼 한 번에 끝나는 변화에 씁니다.
  • animation: 여러 키프레임, 자동 재생·반복이 필요할 때 씁니다. 로드하자마자 돌아가야 한다면 이쪽입니다.

성능 최적화 체크리스트

  • transform·opacity를 먼저 씁니다. 이것이 1번입니다.
  • will-change는 짧게, 필요한 요소에만 적용합니다
  • translateZ(0) 류의 꼼수는 남용하지 말고, 병목이 확인될 때만 씁니다
  • 60fps가 안 나오면 16.67ms를 떠올리기보다 Performance 기록부터 합니다
  • prefers-reduced-motion은 실제로 켜는 사용자가 있습니다
  • width·left로 모션을 주고 있다면 가능한 한 transform으로 우회합니다
  • *에 will-change를 걸지 않습니다

실전 팁

  1. ease-out: 클릭·호버 느낌이 좋습니다
  2. 0.2~0.35s 정도가 많이 쓰는 구간입니다(팀마다 다릅니다)
  3. forwards로 끝 자세를 고정합니다
  4. transform은 곱하는 순서에 따라 결과가 다르므로, 헷갈리면 한 줄씩 확인합니다
  5. JS에서는 클래스 토글이 가장 깔끔합니다
  6. 모바일에서는 효과를 줄이는 편이 사용자 반응이 좋습니다
  7. 모션을 끄는 사용자를 고려해 prefers-reduced-motion을 지원합니다

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 애니메이션이 끝나면 요소가 처음 상태로 돌아가는 이유는 무엇인가요?

A. @keyframes 애니메이션은 기본적으로 재생이 끝나면 적용을 멈추고 원래 스타일로 돌아갑니다. 마지막 키프레임 상태를 유지하려면 animation-fill-mode: forwards를 지정하거나 단축 속성 끝에 forwards를 붙입니다. 상태가 바뀌는 인터랙션이라면 애니메이션보다 클래스를 토글하고 transition을 거는 방식이 최종 상태를 관리하기 더 쉽습니다.