CSS 버튼 애니메이션 9종 — 복붙하면 되는 SCSS + React
CSS 버튼 애니메이션 아홉 개를 한 번에 모았습니다. 호버, 클릭, 로딩, 실패, 확인까지 버튼 하나가 겪는 상황을 아홉 장면으로 나눠 하나씩 담았고, 전부 transform과 opacity만 움직입니다. 격자에서 눌러 보고, 마음에 드는 것만 골라 쓰거나 아홉 개를 zip 하나로 받으면 됩니다.
눌러서 발동 · 전부 한 zip
아홉 개는 흔히 보는 순서가 아니라 버튼이 겪는 순서로 놓았습니다. 눈에 띄어야 하고(01~05), 눌렀을 때 답해야 하고(06~07), 실패했을 때 말해야 하고(08), 위험한 동작은 한 번 더 물어야 합니다(09). 항목마다 코드는 핵심 부분만 실었고, 전체 파일은 zip에 있습니다.
01리플
누른 자리에서 원형 물결이 퍼지며 사라집니다. 결제·확인처럼 "눌렀다"가 확실해야 하는 버튼에 씁니다.
.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 떠오르고 그림자가 커집니다. 카드형 버튼이나 상품 담기처럼 "잡을 수 있다"는 느낌이 필요한 곳에 씁니다.
$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밑줄 슬라이드
밑줄이 왼쪽부터 그어지고, 커서가 떠나면 오른쪽으로 빠집니다. 텍스트형 링크 버튼과 내비게이션에 씁니다.
.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에만 씁니다. 두 개 이상이면 장난감이 됩니다.
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 기능이나 프리미엄처럼 "이건 특별하다"를 말해야 하는 버튼 하나에 씁니다.
.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로딩 전환
누르면 글자가 사라지고 스피너가 나타납니다. 저장·전송처럼 기다려야 하는 동작에 씁니다. 폭은 그대로 두어야 옆 요소가 안 밀립니다.
.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화살표 밀기
호버하면 화살표 선이 길어지며 화살촉이 오른쪽으로 밀립니다. 다음·더 보기·계속 버튼에 씁니다. 아이콘 폰트 없이 선 하나와 꺾인 테두리 하나로 그립니다.
.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흔들림
틀렸을 때 좌우로 세 번 흔들리고 멈춥니다. 로그인 실패나 잘못된 입력에 씁니다. 다섯 번 넘게 흔들면 화난 것처럼 보입니다.
.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는 pointerdown에 is-holding을 켜고 pointerup에 끄며, 채우기 애니메이션의 animationend가 실행 신호입니다.
.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); } }
어디서 깨지는가 — 함정 하나
아홉 개 전부 transform과 opacity만 움직이는 데는 이유가 있습니다. width나 top을 애니메이션하면 브라우저가 매 프레임 레이아웃을 다시 계산하지만, transform과 opacity는 합성 단계에서만 처리됩니다. 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로 넘기면 됩니다.
애니메이션이 끊기는 것 같아요.
먼저 transform과 opacity 외의 속성을 애니메이션하고 있지 않은지 확인하세요. 그다음 will-change: transform을 움직이는 요소에만 붙이세요. 모든 요소에 붙이면 오히려 느려집니다.
댓글
댓글은 곧 열립니다. 그동안은 문의 페이지로 보내주세요.