GODRICH

CSS 버튼 애니메이션 9종 — 복붙하면 되는 SCSS + React

CSS 버튼 애니메이션 아홉 개를 한 번에 모았습니다. 호버, 클릭, 로딩, 실패, 확인까지 버튼 하나가 겪는 상황을 아홉 장면으로 나눠 하나씩 담았고, 전부 transform과 opacity만 움직입니다. 격자에서 눌러 보고, 마음에 드는 것만 골라 쓰거나 아홉 개를 zip 하나로 받으면 됩니다.

눌러서 발동 · 전부 한 zip

아홉 개는 흔히 보는 순서가 아니라 버튼이 겪는 순서로 놓았습니다. 눈에 띄어야 하고(01~05), 눌렀을 때 답해야 하고(06~07), 실패했을 때 말해야 하고(08), 위험한 동작은 한 번 더 물어야 합니다(09). 항목마다 코드는 핵심 부분만 실었고, 전체 파일은 zip에 있습니다.

01리플

누른 자리에서 원형 물결이 퍼지며 사라집니다. 결제·확인처럼 "눌렀다"가 확실해야 하는 버튼에 씁니다.

600ms느리게 900ms어두운 물결
.btn {
  position: relative; overflow: hidden; isolation: isolate;
  &::after {
    content: ""; position: absolute;
    left: var(--x, 50%); top: var(--y, 50%);
    width: var(--d, 0); height: var(--d, 0);
    border-radius: 50%; background: $ripple;
    transform: translate(-50%, -50%) scale(0); opacity: 0;
  }
  &.is-rippling::after { animation: ripple $duration $easing forwards; }
}
@keyframes ripple { to { transform: translate(-50%, -50%) scale(1); opacity: 0; } }

02떠오름

호버하면 6px 떠오르고 그림자가 커집니다. 카드형 버튼이나 상품 담기처럼 "잡을 수 있다"는 느낌이 필요한 곳에 씁니다.

6px높게 10px그림자 없음
$lift: 6px; $duration: 220ms; $shadow: rgba(23, 20, 26, .32);
.btn {
  will-change: transform;
  transition: transform $duration cubic-bezier(.2, .8, .2, 1), box-shadow $duration;
  &:hover, &:focus-visible { transform: translateY(-$lift); box-shadow: 0 14px 24px $shadow; }
  &:active { transform: translateY(-2px); box-shadow: 0 6px 12px $shadow; }
}

03밑줄 슬라이드

밑줄이 왼쪽부터 그어지고, 커서가 떠나면 오른쪽으로 빠집니다. 텍스트형 링크 버튼과 내비게이션에 씁니다.

2px굵게 3px가운데서
.link::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: $thickness; background: currentColor;
  transform: scaleX(0); transform-origin: right;
  transition: transform $duration cubic-bezier(.2, .8, .2, 1);
}
.link:hover::after { transform: scaleX(1); transform-origin: left; }

04자석

커서가 반경 안에 들어오면 버튼이 커서 쪽으로 살짝 끌려갑니다. 랜딩 페이지에서 단 하나뿐인 CTA에만 씁니다. 두 개 이상이면 장난감이 됩니다.

0.35강하게 0.6반경 120px
document.addEventListener('pointermove', function (e) {
  var r = btn.getBoundingClientRect();
  var dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
  if (Math.hypot(dx, dy) < radius) btn.style.transform = 'translate(' + dx * strength + 'px,' + dy * strength + 'px)';
  else btn.style.transform = '';
});

05테두리 회전

그라데이션 테두리가 버튼 둘레를 계속 돕니다. AI 기능이나 프리미엄처럼 "이건 특별하다"를 말해야 하는 버튼 하나에 씁니다.

3s빠르게 1.5s2색
.btn {
  position: relative; overflow: hidden; isolation: isolate;
  padding: $border; border-radius: 16px;
  &::before {
    content: ""; position: absolute; inset: -60%;
    background: conic-gradient(from 0deg, $colors);
    animation: spin $duration linear infinite; z-index: -1;
  }
}
.btn__in { display: block; border-radius: 13px; background: #17141a; }
@keyframes spin { to { transform: rotate(360deg); } }

06로딩 전환

누르면 글자가 사라지고 스피너가 나타납니다. 저장·전송처럼 기다려야 하는 동작에 씁니다. 폭은 그대로 두어야 옆 요소가 안 밀립니다.

300ms폭 유지체크로 끝
.btn { display: grid; place-items: center; min-width: 200px; }
.btn__label, .btn__spin { grid-area: 1 / 1; transition: opacity $duration, transform $duration; }
.btn__spin {
  width: 22px; height: 22px; border-radius: 50%;
  border: 3px solid rgba(255, 255, 255, .35); border-top-color: #fff;
  opacity: 0; transform: scale(.6); animation: spin $spin linear infinite;
}
.btn.is-loading .btn__label { opacity: 0; transform: translateY(6px); }
.btn.is-loading .btn__spin  { opacity: 1; transform: scale(1); }

07화살표 밀기

호버하면 화살표 선이 길어지며 화살촉이 오른쪽으로 밀립니다. 다음·더 보기·계속 버튼에 씁니다. 아이콘 폰트 없이 선 하나와 꺾인 테두리 하나로 그립니다.

8px멀리 14px선 없이
.arrow__line { transform-origin: left; transform: scaleX(.55); transition: transform $duration; }
.arrow__head {
  width: 12px; height: 12px;
  border-top: 2.5px solid currentColor; border-right: 2.5px solid currentColor;
  transform: rotate(45deg); transition: transform $duration;
}
.btn:hover .arrow__line { transform: scaleX(1); }
.btn:hover .arrow__head { transform: rotate(45deg) translate($push * .7, -$push * .7); }

08흔들림

틀렸을 때 좌우로 세 번 흔들리고 멈춥니다. 로그인 실패나 잘못된 입력에 씁니다. 다섯 번 넘게 흔들면 화난 것처럼 보입니다.

6px세게 10px두 번만
.btn.is-shaking { animation: shake $duration cubic-bezier(.36, .07, .19, .97) both; }
@keyframes shake {
  10%, 90% { transform: translateX(-$amount * .5); }
  20%, 80% { transform: translateX($amount); }
  30%, 70% { transform: translateX(-$amount); }
  40%, 60% { transform: translateX($amount * .7); }
  50%      { transform: translateX(-$amount * .7); }
}

09길게 눌러 확인

누르고 있는 동안 배경이 차오르고 다 차면 실행되며, 중간에 떼면 되돌아갑니다. 삭제·탈퇴처럼 실수하면 안 되는 동작에 확인 모달 대신 씁니다. JS는 pointerdownis-holding을 켜고 pointerup에 끄며, 채우기 애니메이션의 animationend가 실행 신호입니다.

1.2s빠르게 0.8s세로로
.btn__fill {
  position: absolute; inset: 0; background: $fill;
  transform: scaleX(0); transform-origin: left; z-index: -1;
  transition: transform 200ms ease-out;
}
.btn.is-holding .btn__fill { animation: fill $hold linear forwards; }
@keyframes fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }

어디서 깨지는가 — 함정 하나

아홉 개 전부 transformopacity만 움직이는 데는 이유가 있습니다. widthtop을 애니메이션하면 브라우저가 매 프레임 레이아웃을 다시 계산하지만, transformopacity는 합성 단계에서만 처리됩니다. MDN의 애니메이션 성능 문서가 이 둘을 권하는 이유입니다. 06 로딩 전환에서 버튼 폭을 줄이고 싶어지는데, 그 순간 이 규칙이 깨지고 옆 요소까지 흔들립니다. 폭은 유지하고 안쪽만 바꾸세요.

함정은 아니지만 덧붙일 것이 둘 있습니다. 하나는 크기입니다. 01 리플을 처음 만들 때 지름을 320px로 두고 작은 버튼에 얹었더니, 원이 두 프레임 만에 버튼을 다 덮어서 물결이 아니라 "잠깐 밝아짐"으로 보였습니다. 물결 지름은 버튼 긴 변의 두 배로 계산하고, 퍼지는 시간은 600ms 밑으로 줄이지 마세요. 이 값들이 이미 들어간 아홉 개 파일의 압축 비밀번호는 xu8ewsq8 이고, 아래 버튼으로 받을 수 있습니다.

다른 하나는 모바일 호버입니다. 02·03·07은 호버가 없는 터치 화면에서는 아무 반응이 없습니다. 그래서 zip의 파일들은 :focus-visible에도 같은 동작을 걸어 두었고, 터치에서는 눌렀을 때 :active로 대신합니다.

접근성

시스템에서 "동작 줄이기"를 켠 사용자를 위해 아홉 개 전부 prefers-reduced-motion: reduce를 처리합니다. 움직임은 끄되 피드백은 남기는 게 원칙입니다. 02는 뜨지 않고 그림자만 바뀌고, 06은 링이 돌지 않으며, 08은 흔들림 대신 색이 바뀝니다. 09는 예외입니다. 길게 누르기는 장식이 아니라 안전장치라서 채우기를 유지합니다. 이 미디어 쿼리의 지원 범위는 MDN prefers-reduced-motion에서 확인할 수 있습니다.

@media (prefers-reduced-motion: reduce) {
  .btn.is-demo, .btn.is-shaking { animation: none; }
  .btn:hover { transform: none; }
}

다른 버튼·로딩 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.

FAQ

아홉 개를 한 프로젝트에 다 넣어도 되나요?

넣을 수는 있지만 한 화면에는 두 종류 이하가 좋습니다. 04 자석과 05 테두리 회전은 "특별한 버튼 하나"용이라 페이지에 하나씩만 두세요. 나머지는 상황별이라 겹칠 일이 적습니다.

React에서는 클래스 토글 대신 뭘 쓰나요?

zip의 react/ 폴더는 상태를 props로 받습니다. 06은 loading, 08은 실패 횟수 shakeKey, 09는 onConfirm 콜백입니다. SCSS 모듈은 var()만 읽으므로 변수 세 개를 props로 넘기면 됩니다.

애니메이션이 끊기는 것 같아요.

먼저 transformopacity 외의 속성을 애니메이션하고 있지 않은지 확인하세요. 그다음 will-change: transform을 움직이는 요소에만 붙이세요. 모든 요소에 붙이면 오히려 느려집니다.

댓글

댓글은 곧 열립니다. 그동안은 문의 페이지로 보내주세요.