GODRICH

css 버튼 hover 효과 모음 9종 — 수상작에서 반복되는 기법

css 버튼 hover 효과 모음은 마우스를 올렸을 때 버튼이 어떻게 반응하는지를 기법별로 나눠 정리한 것입니다. 아홉 개 중 하나는 누르는 순간 버튼 폭이 원으로 줄어 스피너가 돌고, 끝나면 체크가 그려진 뒤 원래 폭으로 돌아옵니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

디자인상을 받은 사이트를 뜯어보면 버튼 효과가 제각각인 것 같아도 쓰이는 자리는 늘 같습니다. 그래서 인기순이 아니라 버튼이 사람을 만나는 순서대로 놓았습니다. 먼저 멀리서 눈에 띄고(01·02), 커서가 다가오며 반응하고(03), 가리키는 동안 표면·글자·아이콘이 바뀌고(04·05·06), 누르기 직전에 조준이 모이고 누르는 순간 반응이 손끝에 돌아오고(07·08), 마지막으로 기다리는 동안 상태를 알립니다(09). 브랜드 이름이 아니라 기법만 가져왔으므로 아홉 개 전부 자기 색·자기 글자로 바꿔 쓰면 됩니다.

01커서 스포트라이트

커서가 지나간 자리를 따라 둥근 빛무리가 버튼 표면을 훑습니다. 빛의 중심 가로 위치만 --mx 한 줄로 넘기면 나머지는 그라디언트가 그립니다. 이 속성을 @property로 등록해 두는 것이 핵심입니다.

radial-gradient@propertypointermove
@property --mx {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}
.spot::before {
  background: radial-gradient(circle 96px at var(--mx) 50%, rgba($color, .95), rgba($color, 0) 72%);
}
@keyframes spot-sweep {
  0%   { --mx: 8%; }
  50%  { --mx: 92%; }
  100% { --mx: 8%; }
}

02테두리 빛 러너

밝은 점 하나가 테두리를 따라 끊김 없이 한 바퀴씩 돕니다. 원뿔형 그라디언트(conic-gradient)를 통째로 돌리고 테두리 굵기만큼만 마스크로 남기는 방식이라 알약이든 사각형이든 모양을 그대로 따라갑니다.

conic-gradientmask-composite@property --angle
.glow::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: $sp-1;
  background: conic-gradient(from var(--angle),
    rgba($color, 0) 0deg,
    rgba($color, 0) 232deg,
    rgba($color, 1) 300deg,
    rgba($color, .2) 330deg,
    rgba($color, 0) 360deg);
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  animation: glow-run $duration $easing infinite;
}

03마그네틱 당김 + 라벨 역시차

버튼은 커서 쪽으로 끌려오고, 안쪽 글자는 같은 좌표에 음수를 곱해 반대로 밀립니다. 이 부호 하나가 두 층을 갈라놓아 납작한 사각형이 살짝 떠 있는 것처럼 보입니다.

translate역시차pointerleave
field.addEventListener('pointermove', function (e) {
  var r = btn.getBoundingClientRect();
  var dx = (e.clientX - (r.left + r.width / 2)) * 0.35;
  var dy = (e.clientY - (r.top + r.height / 2)) * 0.35;
  btn.classList.remove('is-demo');
  btn.style.translate = dx + 'px ' + dy + 'px';
  label.style.translate = (-dx * 0.4) + 'px ' + (-dy * 0.4) + 'px';
});

04액체 채움 (물결 수면)

아래에서 색이 차오르는데 수면이 평평하지 않고 물결처럼 기울어집니다. 완전한 원이 아니라 네 모서리를 42%만큼 둥글린 260px 정사각형을 통째로 돌리면서 동시에 위로 밀어 올리기 때문입니다.

물결 타일translateoverflow
.liq__wave {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 260px;
  height: 260px;
  border-radius: 42%;
  background: $color;
  transform: translate(-50%, 100%);
}
@keyframes liq-rise {
  0%   { transform: translate(-50%, 100%) rotate(0deg); }
  52%  { transform: translate(-50%, 68%) rotate(180deg); }
  66%  { transform: translate(-50%, 68%) rotate(230deg); }
  100% { transform: translate(-50%, 100%) rotate(360deg); }
}

05글자 분할 슬라이드

글자가 하나씩 위로 빠져나가고 같은 글자가 아래에서 올라와 자리를 메웁니다. 글자마다 창(overflow: hidden) 하나에 같은 글자를 두 겹 쌓아 두고, 지연 시간만 글자 번호에 비례시키면 물결처럼 번집니다.

글자별 지연translate: 0 -50%clip
.split__ch {
  display: inline-block;
  overflow: hidden;
  height: 1.25em;
  line-height: 1.25;
}
.split.is-demo .split__ch b {
  animation: split-roll $duration $easing infinite;
  animation-delay: calc(var(--i) * #{$stagger});
}
@keyframes split-roll {
  0%,  12% { translate: 0 0; }
  42%, 68% { translate: 0 -50%; }
  100%     { translate: 0 0; }
}

06아이콘 회전 교체

아이콘이 세로축으로 반 바퀴 돌면서 뒷면에 있던 다른 아이콘으로 바뀝니다. 두 아이콘을 같은 칸에 앞뒤로 겹쳐 두고 각자 뒷면만 숨기면 끝이라, 담기에서 담김으로 뜻이 바뀔 때도 라벨 글자는 그대로 두면 됩니다.

rotateYbackface-visibilityPhosphor
.swap__ic {
  position: relative;
  width: 34px;
  height: 34px;
  transform-style: preserve-3d;
}
.swap__face { position: absolute; inset: 0; display: flex; backface-visibility: hidden; }
.swap__face--back { transform: rotateY(180deg); }
@keyframes swap-spin {
  0%,  22%  { transform: rotateY(0deg); }
  50%, 72%  { transform: rotateY(180deg); }
  100%      { transform: rotateY(360deg); }
}

07눌림 반동 (스쿼시·스트레치)

누르는 순간 세로로 납작해지며 가로로 퍼지고, 놓으면 크게 한 번 튀어 올랐다가 작게 한 번 더 흔들린 뒤 안착합니다. 그림자도 같은 박자에 맞춰 얕아져야 비로소 고무공처럼 보입니다.

scale$ease-popbox-shadow
.squash { transform-origin: center bottom; }
@keyframes squash-press {
  0%,  34%  { transform: scale(1, 1);      box-shadow: $shadow-raised; }
  46%       { transform: scale(1.06, .88); box-shadow: $shadow-press; }
  60%       { transform: scale(.97, 1.06); box-shadow: $shadow-raised; }
  72%       { transform: scale(1.02, .98); box-shadow: $shadow-raised; }
  86%, 100% { transform: scale(1, 1);      box-shadow: $shadow-raised; }
}
.squash:active {
  animation: none;
  transform: scale(1.06, .88);
  box-shadow: $shadow-press;
}

08모서리 괄호 조준

네 모서리의 ㄱ자 조각이 바깥에서 모여들어 테두리를 완성합니다. 조각마다 다른 것은 지우는 변 두 쌍과 이동 방향이고 이동 거리는 18px로 같아서, 이 거리를 바꾸면 네 조각이 한꺼번에 넓어지거나 좁아집니다.

translatefocus-visible순차 지연
.brk__c {
  position: absolute;
  width: 22px;
  height: 22px;
  border: 3px solid $color;
  --tx: -18px;
  --ty: -18px;
}
.brk__c--tr { top: -8px; right: -8px; border-left: 0; border-bottom: 0; --tx: 18px; --i: 1; }
.brk.is-demo .brk__c {
  animation: brk-close $duration $easing infinite;
  animation-delay: calc(var(--i) * 60ms);
}
@keyframes brk-close {
  0%       { translate: var(--tx) var(--ty); opacity: 0; }
  38%, 66% { translate: 0 0;                 opacity: 1; }
  100%     { translate: var(--tx) var(--ty); opacity: 0; }
}

09로딩 모프 (버튼→스피너→체크)

누르면 글자가 사라지며 폭이 원으로 좁아지고, 스피너가 돌다가 체크가 그려진 뒤 원래 폭으로 돌아옵니다. 세 상태가 한 요소 안에서 이어지므로 로딩 화면을 따로 띄우지 않아도 되고, 기다리는 동안 두 번 눌리지도 않습니다.

widthstroke-dashoffsetaria-busy
@keyframes morph-box {
  0%,  10%  { width: 200px; }
  28%, 62%  { width: 56px; }
  84%, 100% { width: 200px; }
}
@keyframes morph-draw {
  0%,  60%  { stroke-dashoffset: 26; opacity: 0; }
  64%       { stroke-dashoffset: 26; opacity: 1; }
  76%, 86%  { stroke-dashoffset: 0;  opacity: 1; }
  94%, 100% { stroke-dashoffset: 26; opacity: 0; }
}
.morph.is-busy { width: 56px; }
.morph.is-busy .morph__label { opacity: 0; }
.morph.is-busy .morph__spin { opacity: 1; animation: morph-turn 800ms linear infinite; }

어디서 깨지는가 — 함정

아홉 개를 만들면서 제일 오래 잡고 있던 것은 01번과 02번이 아예 안 움직이던 문제였습니다. --mx--angle을 키프레임에 넣어 두면 당연히 부드럽게 변할 줄 알았는데, 브라우저는 등록되지 않은 사용자 지정 속성을 그냥 문자열로 봅니다. 문자열에는 중간값이 없으니 0%에서 100%로 뚝 끊기고, 24프레임을 뽑아 보면 빛이 두 칸에만 서 있다가 사라집니다. @propertysyntax: "<percentage>""<angle>"을 선언해 타입을 알려 준 뒤에야 러너가 실제로 한 바퀴를 돌았습니다.

두 번째는 루프의 끝과 처음이 어긋나는 순간이동이었습니다. 02번 러너를 340deg에서 끊고 0deg로 되돌렸더니 한 바퀴마다 빛이 튀어서, 각도를 정확히 360deg까지 돌려 다음 바퀴의 0deg와 맞물리게 했습니다. 06번 아이콘도 180deg에서 멈추고 되감으면 뒷면이 앞으로 슬쩍 되돌아오는 것이 보여 360deg로 이어 붙였습니다. 04번은 반대로 너무 빨랐습니다. 처음에는 320px짜리 물방울을 썼는데, 렌더한 다섯 번째 프레임에서 이미 버튼이 가득 차 물결 수면이 보이지 않아 260px로 줄이고 차오르는 구간을 전체의 52%까지 늘린 뒤에야 곡면이 글자를 가로지르는 장면이 프레임에 남았습니다. 05번은 창 높이를 1.1em으로 잡았다가 한글 받침이 잘려 1.25em으로 올렸고, 그러고 나서야 아홉 개 전부 320px 폭 검사에서 넘침 0으로 통과했습니다. 마지막으로 09번만은 transform이 아니라 width를 움직입니다. 레이아웃을 다시 계산하게 만드는 값이라 되도록 피하는 것이 맞지만, 알약이 진짜 원이 되려면 폭이 줄어야 해서 overflow: hidden과 격자 겹치기로 글자가 찌그러지지 않게 잡아 두고 이 하나만 예외로 두었습니다. 압축 비밀번호 cpm3zu6e로 여는 zip 안의 소스에는 위 수정이 전부 반영돼 있어, 받은 파일을 그대로 열면 여기 적힌 값과 같은 코드가 들어 있습니다.

접근성

아홉 개 전부 prefers-reduced-motion: reduce에서 장식 루프를 끄되 신호는 남깁니다. 빛무리는 가운데 고정, 러너는 은은한 테두리 한 겹, 물결은 채워진 상태, 조각 넷은 모인 상태로 멈춥니다. 08번 조준선은 마우스 없이도 쓸 수 있도록 :focus-visible에 같은 모양을 붙였고, 09번은 기다리는 동안 aria-busy="true"로 상태를 알린 뒤 끝나면 지웁니다.

상태 움직임이 켜져 있을 때 reduced-motion 일 때
가리킴 (01·02·04·05·06) 빛·물결·글자가 2초 주기로 돈다 최종 모습으로 정지, 색 대비는 그대로
누름 (03·07) 당김과 눌림 반동이 이어진다 이동·스케일 없이 색과 그림자만
포커스·대기 (08·09) 조각이 모이고 체크가 그려진다 조각은 모인 채 고정, 체크는 즉시 표시

사용자 지정 속성을 애니메이션하려면 타입 등록이 필요하다는 규칙은 MDN의 @property 문서에 정리돼 있습니다. 같은 방아쇠를 쓰는 다른 효과는 호버 카테고리에, 실제 전환 버튼에 쓰는 것들은 CTA 카테고리에 모아 두었습니다.

FAQ

자바스크립트 없이 쓸 수 있는 건 몇 개인가요?

아홉 개 중 다섯 개는 CSS만으로 끝납니다. 커서 좌표를 읽는 01번과 03번, 글자를 한 칸씩 쪼개 쌓는 05번, 누름 상태를 바꾸는 09번만 각각 스무 줄 안쪽의 바닐라 자바스크립트를 씁니다. 그 네 곳이 하는 일도 CSS 변수와 클래스를 바꾸고 글자 칸을 만들어 주는 것뿐이라, 스크롤 라이브러리나 애니메이션 라이브러리는 하나도 들어가지 않습니다.

터치 기기에서는 호버가 없는데 그대로 써도 되나요?

호버가 시작 신호인 항목(01·02·04·05·06)은 터치에서 아무 일도 일어나지 않으므로, 그런 자리에는 07번처럼 :active로도 같은 결과가 나오는 효과를 함께 두는 편이 안전합니다. 07번과 09번은 누름과 대기 상태를 쓰므로 터치에서도 그대로 작동하고, 08번은 :focus-visible 덕분에 키보드에서도 같은 모양이 나옵니다.

색과 속도를 우리 브랜드에 맞게 바꾸려면 어디를 고치나요?

각 SCSS 파일 맨 위 세 줄($duration·$easing·$color, 05번만 $color 대신 $stagger)만 바꾸면 됩니다. React 판도 같은 세 값을 props로 받아 CSS 사용자 지정 속성으로 넘기므로, 컴포넌트를 부를 때 duration={1200}처럼 넣으면 되고 나머지 코드는 건드리지 않습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.