GODRICH

css 버튼 클릭 효과 9종 — 누른 다음이 진짜다

css 버튼 클릭 효과는 누른 순간부터 결과가 나올 때까지 버튼이 스스로 모습을 바꿔 알려 주는 방식입니다. 아홉 개 모두 색만 바뀌는 흉내가 아니라 실제로 잠기고 돌고 되돌아오며, 그 사실을 ARIA 속성 한 곳에 적습니다.

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

순서는 인기순이 아니라 버튼 하나가 겪는 순서입니다. 먼저 눌린 뒤 결과를 기다렸다 돌아오고(01), 아직 누를 수 없는 이유를 밝히고(02), 눌린 채로 머무는 법을 배웁니다(03). 가운데 셋은 누른 결과가 화면에 남는 경우입니다. 숫자로 남고(04), 몇 초짜리 진행으로 남고(05), 되돌릴 수 없어서 한 번 더 확인받습니다(06). 마지막 셋은 버튼이 혼자가 아닐 때입니다. 옆에 다른 길이 붙고(07), 서로 배타적인 셋 중 하나가 되고(08), 눌렀다는 사실을 잠깐만 말하고 사라집니다(09). 아홉 개가 공유하는 원칙은 하나입니다. 지금 상태는 클래스가 아니라 aria-busy·aria-pressed·aria-disabled·aria-checked·aria-expanded 가운데 그 상황에 맞는 속성 한 곳에만 적고, 보이는 색은 그 속성을 읽어서 정합니다. 두 곳에 적으면 언젠가 반드시 어긋나고, 어긋난 쪽은 늘 눈에 보이지 않는 쪽입니다.

01로딩에서 완료로 도는 한 바퀴

한 번 누르면 라벨이 스피너 달린 '저장 중'으로 바뀌고, 1.2초 뒤 체크 표시로 끊겼다가, 다시 2초 뒤 원래 라벨로 돌아옵니다. 도는 동안 버튼은 disabled 라서 두 번 눌리지 않고 aria-busy 가 진행 중임을 알립니다. 상태 이름 하나를 바꾸는 함수가 속성 셋을 한꺼번에 갱신하므로 화면과 보조기기가 갈라질 틈이 없습니다.

aria-busysteps(1, end)setTimeout
function 상태(s) {
  btn.setAttribute('data-state', s);
  btn.setAttribute('aria-busy', String(s === 'busy'));
  btn.disabled = s !== 'idle';
}
btn.addEventListener('click', function () {
  root.classList.remove('is-demo');
  clearTimeout(t1);
  clearTimeout(t2);
  상태('busy');
  t1 = setTimeout(function () { 상태('done'); }, 1200);   // 서버가 답한 시점
  t2 = setTimeout(function () { 상태('idle'); }, 3200);   // 완료 표시를 2초 보여 주고 원복
});

02조건이 차야 열리는 버튼

입력칸이 이메일 형식을 갖추기 전에는 버튼이 흐리게 잠겨 있지만, disabled 대신 aria-disabled 를 쓰기 때문에 초점은 그대로 받습니다. 잠긴 채로 누르면 아무 일도 일어나지 않는 대신, 왜 안 되는지가 말풍선으로 1.4초 동안 붙습니다. 진짜 disabled 였다면 그 버튼은 초점을 받지 못해 이유를 읽어 줄 방법 자체가 없습니다.

aria-disabledcheckValiditypointer-events
function 검사() {
  var ok = mail.checkValidity() && mail.value.length > 0;
  btn.setAttribute('aria-disabled', String(!ok));
  return ok;
}
btn.addEventListener('click', function () {
  root.classList.remove('is-demo');
  if (!검사()) {
    btn.setAttribute('data-why', '1');           // 잠긴 이유를 한 번 흔들어 보여 준다
    setTimeout(function () { btn.removeAttribute('data-why'); }, 1400);
    return;
  }
  btn.setAttribute('data-sent', '1');
});

03켜짐과 꺼짐을 한 버튼이 쥔다

버튼 안에 얼굴 두 장이 같은 자리에 겹쳐 있고, 어느 쪽이 보일지는 aria-pressed 값만 읽는 선택자가 정합니다. JS 가 하는 일은 그 속성을 뒤집는 것 하나뿐이라 색과 아이콘, 읽히는 상태가 모두 같은 값에서 나옵니다. 같은 방향으로 다시 눌러도 반동이 돌게 하려면 클래스를 뗀 뒤 리플로를 한 번 강제해야 합니다.

aria-pressedsteps(1, end)scale
.tp:not(.is-demo) .tp__btn[aria-pressed="false"] .tp__face[data-face="off"],
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] .tp__face[data-face="on"] { opacity: 1; }
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] { background-color: $color; color: $stage-yellow; }

.tp__btn.is-pop {
  animation-name: tp-tap;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: 1;
}
@keyframes tp-tap {
  0%   { transform: scale(.94); }
  60%  { transform: scale(1.03); }
  100% { transform: scale(1); }
}

04숫자가 굴러 올라가는 좋아요

숫자 창은 높이를 한 줄로 못 박고 그 안에 옛 값과 새 값을 세로로 쌓아 둡니다. 좋아요를 누르면 textContent 를 바꾸는 대신 두 값이 든 줄을 22px 만큼 올려 새 값이 아래에서 올라오게 합니다. 숫자 폭이 자리마다 달라지면 창이 흔들리므로 font-variant-numeric: tabular-nums 로 자릿수 폭을 고정합니다.

aria-livetranslateYscale
.lk__win {
  display: block;
  overflow: hidden;
  height: 22px;
  width: 46px;
}
.lk__roll { display: block; transform: translateY(0); }
.lk__n { display: block; height: 22px; font-variant-numeric: tabular-nums; }
.lk:not(.is-demo) .lk__roll { transition: transform $duration $easing; }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] .lk__roll { transform: translateY(-22px); }

05버튼 자신이 진행 막대가 된다

퍼센트를 변수 하나에만 적고, 채움 너비와 화면에 찍히는 숫자가 둘 다 그 변수를 읽습니다. 그런데 그냥 --p: 0 이라고 두면 브라우저는 그 값을 글자로만 보기 때문에 0에서 100으로 흐르지 않고 뚝뚝 끊깁니다. @property 로 정수라고 등록해야 비로소 보간되고, 같은 변수를 counter-reset 에 넣으면 숫자로도 찍힙니다.

@propertylinear-gradientaria-valuenow
@property --p {
  syntax: "<integer>";
  inherits: true;
  initial-value: 0;
}
.dl__fill {
  background-image: linear-gradient(90deg, $color 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__pct::after {
  counter-reset: p var(--p);
  content: counter(p) "%";
  font-variant-numeric: tabular-nums;
}

06길게 눌러야 지워지는 삭제

누르고 있는 동안 둘레의 고리가 stroke-dashoffset 으로 한 바퀴 그려지고, 다 그려졌을 때만 삭제가 일어납니다. 손을 떼면 같은 트랜지션이 그대로 되감겨 아무 일도 없었던 것이 됩니다. 누르는 순간 setPointerCapture 를 걸어 두지 않으면 손가락이 버튼 밖으로 나갔을 때 취소 이벤트를 받지 못해 고리가 계속 찹니다.

pointerdownstroke-dashoffsetsetPointerCapture
btn.addEventListener('pointerdown', function (e) {
  root.classList.remove('is-demo');
  root.setAttribute('data-state', 'idle');
  btn.setPointerCapture(e.pointerId);    // 손가락이 버튼 밖으로 나가도 취소를 여기서 받는다
  root.classList.add('is-holding');
  clearTimeout(끝);
  끝 = setTimeout(function () { root.setAttribute('data-state', 'done'); root.classList.remove('is-holding'); }, 1200);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (t) {
  btn.addEventListener(t, function () { clearTimeout(끝); root.classList.remove('is-holding'); });
});

07주 동작과 다른 길이 붙은 버튼

한 테두리 안에 들어 있지만 실제로는 button 이 둘이고, 목록을 여는 것은 오른쪽 화살표 칸뿐입니다. 왼쪽 넓은 칸은 가장 흔한 동작을 바로 실행하면서 목록을 닫기만 할 뿐 aria-expanded 를 참으로 만들지 않습니다. 목록 자체는 role="menu" 이고 줄마다 role="menuitem" 이라 키보드로도 같은 길이 열립니다.

aria-expandedaria-haspopuptranslateY
<div class="sp__group" role="group" aria-label="보내기 선택">
  <button class="sp__main" id="sp-main" type="button">
    <span class="sp__t">바로 보내기</span>
  </button>
  <button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="보내는 방법 더 보기">
    <span class="sp__caret" aria-hidden="true"></span>
  </button>
</div>
<ul class="sp__menu" id="sp-menu" role="menu" aria-labelledby="sp-more">
  <li role="none"><button class="sp__item" type="button" role="menuitem">예약해서 보내기</button></li>
  <li role="none"><button class="sp__item" type="button" role="menuitem">임시 보관함에 넣기</button></li>
  <li role="none"><button class="sp__item" type="button" role="menuitem">보내기 전에 미리 보기</button></li>
</ul>

08고른 칸을 따라 미끄러지는 알약

세 칸은 flex: 0 0 auto 에 폭이 80px 로 못 박혀 있고, 알약은 그 값의 배수만큼만 옮겨 갑니다. 칸을 균등하게 나누는 flex: 1 1 0 과 고정 이동폭을 섞으면 실제로 그려진 칸 폭이 짐작과 달라져 알약이 옆 라벨에 걸칩니다. 고른 칸은 aria-checked 에 적히고 글자 색도 그 속성에서 갈립니다.

aria-checkedflex: 0 0 autotranslateX
.sg__pill {
  position: absolute;
  width: $칸;
  border-radius: $r-pill;
  transform: translateX(0);
}
.sg__seg {
  flex: 0 0 auto;
  width: $칸;
  height: 36px;
}
.sg:not(.is-demo) .sg__pill { transition: transform $duration $easing; }
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill { transform: translateX($칸); }
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill { transform: translateX($칸 * 2); }

09복사했다고 잠깐만 말하는 버튼

navigator.clipboard.writeText 가 끝난 뒤에 라벨이 바뀌고, 1.6초가 지나면 조용히 돌아옵니다. 권한이 막힌 곳에서도 사람에게는 반응이 보여야 하므로 성공과 실패 양쪽에 같은 마무리 함수를 겁니다. 잠깐 바뀌는 라벨은 눈에만 보이기 때문에 같은 말이 role="status" 영역으로도 한 번 나갑니다.

navigator.clipboardwriteTextaria-live
btn.addEventListener('click', function () {
  root.classList.remove('is-demo');
  var 끝내기 = function () {
    root.setAttribute('data-copied', '1');
    clearTimeout(되돌림);
    되돌림 = setTimeout(function () { root.removeAttribute('data-copied'); }, 1600);
  };
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(src.textContent).then(끝내기, 끝내기);
  } else {
    끝내기();
  }
});

어디서 깨지는가 — 함정

상태가 바뀌는 부품은 대표 이미지에서 먼저 무너집니다. 데모마다 프레임 24장을 뽑고 그중 앞 장과 가장 크게 달라진 한 장을 대표로 고르는데, 07번 첫 판은 루프에서 목록을 열었다가 다시 닫았습니다. 목록이 사라지는 프레임이 열리는 프레임보다 언제나 크게 변하기 때문에, 대표 이미지가 주황 바탕에 닫힌 버튼 하나만 남은 장면으로 나왔습니다. 고친 방법은 루프에서 목록을 아예 닫지 않는 것입니다. 8%에 한 번 열고 그대로 둔 뒤, 줄마다 다른 지연으로 translateY 를 풀고 그다음엔 배경색을 하나씩 훑게 했습니다. 그 결과 누적면적 24.58%에 움직인 프레임 12/23이 나왔고 대표 이미지는 목록이 펼쳐진 장면이 됐습니다.

두 번째는 컷과 프레임 수가 서로 부딪히는 문제입니다. 상태가 갈리는 순간을 steps(1, end) 로 끊으면 반투명하게 겹친 유령 프레임은 사라지지만, 컷만으로는 프레임이 두 장밖에 나오지 않습니다. 그래서 아홉 개 모두 컷 옆에 transform 으로 움직이는 작은 동작을 하나씩 붙였습니다. 01번은 계속 도는 스피너를, 03번은 scale 반동을, 09번은 복사된 순간에만 퍼지는 테두리를 겹쳤습니다. 09번은 되돌림도 한 번에 하지 않고 76%에 아이콘, 84%에 색과 라벨로 나눠 끊었습니다. 되돌리는 변화가 복사되는 변화보다 작아야 대표 이미지가 원하는 장면에 걸립니다. 세 번째는 눈으로만 보면 절대 걸리지 않는 함정이었습니다. 02번 루프는 가짜 입력 글자를 steps(8, end) 로 드러내는데, 폭을 108px 로 잡았더니 마지막 글자가 반쯤 잘려 [email protected] 으로 보였습니다. 120px 로 넓히고 나서야 주소가 온전해졌습니다. 아홉 벌 소스가 든 압축 파일의 비밀번호는 sqph35dy 이고, 여기서 고친 값이 그대로 들어 있습니다.

네 번째는 크기입니다. 320×200 무대에서 여백을 빼면 세로로 쓸 수 있는 것은 174px 뿐인데, 07번의 목록은 절대 배치라서 부모 높이 계산에 잡히지 않습니다. 화면은 멀쩡해 보여도 문서 높이만 늘어나 격자 칸에 스크롤바가 생기므로, 감싸는 상자에 목록 높이만큼 padding-bottom 을 미리 비워 두었습니다. 좁은 화면에서는 그 값을 116px 에서 100px 로 줄입니다. 좁은 쪽에서는 간격을 반올림해 올리지 않고 내리는 것이 규칙입니다.

접근성

움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)이 켜져 있으면 아홉 개 모두 자동 재생만 멈추고 지금 상태는 그대로 둡니다. 01번 스피너는 돌기를 멈추고, 05번 채움은 빈 채로 서 있으며, 06번 고리는 자라는 대신 한 번에 채워집니다. 상태를 알리는 속성은 어느 쪽에서도 꺼지지 않습니다.

항목 마우스 키보드 상태가 적히는 곳
01 비동기 한 바퀴 클릭 Enter · Space aria-busy · disabled
02 조건 잠금 클릭 Tab 초점 유지 aria-disabled · aria-describedby
03 토글 클릭 Enter · Space aria-pressed
04 좋아요 클릭 Enter · Space aria-pressed · aria-live
05 진행 클릭 Enter · Space role="progressbar" · aria-valuenow
06 길게 누르기 누른 채 유지 Enter · Space role="status"
07 스플릿 두 칸 각각 클릭 Tab · Escape aria-expanded · aria-haspopup
08 세그먼트 클릭 ← → 화살표 role="radio" · aria-checked
09 복사 클릭 Enter · Space aria-live="polite"

토글 버튼의 눌림 상태를 왜 클래스가 아니라 속성에 적어야 하는지는 MDN 의 aria-pressed 문서에 정리되어 있습니다. 05번이 기대고 있는 커스텀 속성 등록도 MDN 의 @property 문서에 문법과 보간 조건이 그대로 적혀 있는데, syntax 를 빠뜨리면 등록 자체가 무시됩니다. 손으로 눌러서 시작하는 부품은 클릭 카테고리에, 화면에서 딱 하나만 눌리게 하고 싶은 버튼은 CTA 카테고리에 모여 있습니다.

FAQ

잠긴 버튼에 disabled 와 aria-disabled 중 뭘 써야 하나요?

이유를 알려 줄 것이 있으면 aria-disabled, 없으면 disabled 입니다. 진짜 disabled 는 초점을 받지 못해 키보드로 그 버튼에 닿는 순간 자체가 사라지고, 그러면 aria-describedby 로 걸어 둔 이유도 읽히지 않습니다. 대신 aria-disabled 는 브라우저가 클릭을 막아 주지 않으므로 눌렸을 때 아무 일도 하지 않는 코드를 직접 써야 합니다.

로딩 상태를 유지하는 시간은 얼마가 적당한가요?

완료 표시는 2초면 충분합니다. 01번은 응답을 1.2초로 두고 완료를 2초 보여 준 뒤 되돌리는데, 이보다 짧으면 결과를 놓치고 길면 다음 동작이 막힌 것처럼 느껴집니다. 응답이 언제 올지 모르는 일이라면 완료 시각이 아니라 응답 시각에 맞춰 끊고, 완료 표시만 고정 시간으로 두는 편이 안전합니다.

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

클래스 접두사가 항목마다 달라서 충돌하지 않습니다. 다만 05번의 @property --p 는 문서 전체에 한 번만 등록되는 전역 이름이라, 다른 곳에서 같은 이름을 다른 syntax 로 다시 등록하면 나중에 등록한 쪽이 이깁니다. 여러 진행 막대를 함께 쓸 계획이면 이름을 --dl-p 처럼 부품 이름으로 바꿔 두는 편이 낫습니다.

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

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