GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

CSS 스위치 버튼 9종 — 실물처럼 눌리는 레버·다이얼·이젝트

css 스위치 버튼은 화면에서 켜고 끄는 스위치를 실물 레버·로커·다이얼처럼 보이게 만든 버튼입니다. 여기 아홉 개는 라이브러리 없이 SCSS 와 짧은 JS 로만 짰고, 누르면 그 자리에서 기울고 돌고 튀어나오며 화면낭독기에도 상태가 읽힙니다.

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

아홉 개를 이 순서로 늘어놓은 기준은 손이 하는 일입니다. 앞의 둘(01·02)은 한 번 눌러 켜고 끄는 전원, 다음 둘(03·04)은 값을 연속으로 정하는 조절기, 가운데 둘(05·06)은 누르고 당겨 상태를 바꾸는 큰 동작이고, 뒤의 둘(07·08)은 판과 손잡이를 젖혀 골라 끄는 물건이며, 마지막(09)은 꺼내는 동작으로 문을 닫습니다. 무대는 먹·페이퍼·옐로·주황 넷으로 돌렸고, 주황 무대 위 글자는 전부 먹으로 찍었습니다. css 스위치를 고르는 일은 곧 상태를 몇 개 둘지 정하는 일인데, 이어지는 항목마다 그 답이 코드로 남아 있습니다. 누르는 순간의 움직임을 더하고 싶으면 CSS 버튼 애니메이션 9종이, 눌린 뒤 알려주는 마크업이 궁금하면 버튼 클릭 효과 9종이 각각 이웃입니다.

01토글 레버 스위치

금속 레버가 관절 위에서 좌우로 기울어 켜짐·꺼짐을 가르는 스위치로, 오디오 랙 전원 패널이나 기기 설정 화면의 모드 전환에 어울립니다.

토글 레버 스위치
새 창에서 열기

01 · 토글 레버 스위치 — 측정 누적면적 4.119% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

role="switch"aria-checkedcubic-bezier(.2,1.4,.4,1)linear-gradient

관절 위에서 도는 레버 팔

레버 팔(.tl__armWrap)은 transform-origin: 50% 100% 로 밑단 돔의 중심을 축으로 삼아 rotate(-24deg) 와 rotate(24deg) 사이만 오가고, 켜지면 LED 점과 ON 글자, 상태 단어가 함께 바뀝니다.

.tl__armWrap {
  position: absolute; left: calc(50% - 5px); bottom: 15px; width: 10px; height: 80px;
  border-radius: $r-pill; transform-origin: 50% 100%; transform: rotate(-24deg);
  transition: transform $duration $easing;
  background: linear-gradient(90deg,
    rgba($stage-ink, .55), rgba($subject-cream, .82) 42%,
    rgba($subject-cream, .95) 50%, rgba($subject-cream, .70) 62%, rgba($stage-ink, .50));
  box-shadow: 0 4px 8px rgba($stage-ink, .55);
}

오버슈트로 살아나는 스프링 결

손을 떼는 순간의 튕김은 cubic-bezier(.2, 1.4, .4, 1) 의 오버슈트 한 번에서 나오고, 미리보기에도 38% 에 28도를 지나쳤다가 48% 에 24도로 돌아오는 결이 그대로 들어 있습니다(움직인 프레임 18/23).

@keyframes tlSwing {
  0%   { transform: rotate(-24deg); }
  8%   { transform: rotate(-24deg); }
  38%  { transform: rotate(28deg); }
  44%  { transform: rotate(23deg); }
  48%  { transform: rotate(24deg); }
  68%  { transform: rotate(24deg); }
  88%  { transform: rotate(-28deg); }
  94%  { transform: rotate(-23deg); }
  97%  { transform: rotate(-24deg); }
  100% { transform: rotate(-24deg); }
}

02로커 전원 스위치

윗면 I·아랫면 O 두 cap 을 가진 실물 전원 스위치 목업으로, 기기 뒷면이나 무선 마우스 설정 카드에 두는 결입니다.

로커 전원 스위치
새 창에서 열기

02 · 로커 전원 스위치 — 측정 누적면적 3.633% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

role="switch"aria-checkedtranslateYinset

누른 쪽이 가라앉고 반대쪽이 뜨는 구조

두 cap 의 기본값은 translateY(-3px)·translateY(3px) 인데, 켜지면 두 값이 정확히 맞바뀌고, 안쪽 그림자 두 장도 같은 모양 그대로 서로 뒤집힙니다.

.rs__capTop {
  border-radius: $r-sm $r-sm 0 0;
  background: linear-gradient(180deg, $color, rgba($subject-ink, .10)), $color;
  transform: translateY(-3px);
  box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85);
}
.rs__capBottom {
  border-radius: 0 0 $r-sm $r-sm;
  background: linear-gradient(180deg, rgba($subject-ink, .05), rgba($subject-ink, .17)), $color;
  transform: translateY(3px);
  box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40);
}

여덟 프레임만 움직이는 짧은 숨

미리보기 시간표는 12~18% 의 누름과 58~64% 의 놓음에만 움직임을 몰아넣고 나머지 구간은 그대로 붙잡고 있어, 이 판은 움직인 프레임이 8/23 로 아홉 개 중 가장 조용합니다.

@keyframes rsPressTop {
  0%   { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
  12%  { transform: translateY(4px);  box-shadow: inset 0 5px 9px rgba($subject-ink, .38), inset 0 -1px 0 rgba($stage-paper, .35); }
  18%  { transform: translateY(3px);  box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
  46%  { transform: translateY(3px);  box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
  58%  { transform: translateY(-4px); box-shadow: inset 0 -4px 7px rgba($subject-ink, .08), inset 0 3px 0 rgba($stage-paper, .9); }
  64%  { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
  100% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
}

03회전 다이얼 노브

노브를 드래그하거나 화살표 키로 돌려 밝기를 0부터 100까지 고르는 조절기로, role="slider" 가 최소·최대·현재 값을 함께 읽어 줍니다.

회전 다이얼 노브
새 창에서 열기

03 · 회전 다이얼 노브 — 측정 누적면적 13.382% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

role="slider"aria-valuenowpointermoveconic-gradient

conic-gradient 로 그린 눈금 링과 진행 호

눈금은 repeating-conic-gradient 가 27도 간격으로 찍어 가동 폭 양끝까지 11개가 놓이고, 진행 호는 conic-gradient 가 --fill(0~1) 만큼의 270도 부채꼴을 채우는데, --fill 이 @property 에 <number> 로 등록돼 있어 값이 숫자답게 보간됩니다.

.dk__ticks {
  position: absolute; inset: 0; border-radius: 50%;
  background: repeating-conic-gradient(from 224deg,
    rgba($subject-ink, .85) 0 1.6deg, rgba($subject-ink, 0) 1.6deg 27deg);
}
.dk__arc {
  position: absolute; inset: 7px; border-radius: 50%;
  background: conic-gradient(from 225deg,
    rgba($subject-ink, .82) 0 calc(var(--fill, 0) * 270deg),
    rgba($subject-ink, .10) calc(var(--fill, 0) * 270deg) 270deg,
    rgba($subject-ink, 0) 270deg 360deg);
}

atan2 의 인자를 뒤집는 이유

바늘 각도는 Math.atan2(x - cx, cy - y) 로 정위(12시)를 0도·시계 방향을 양수로 맞추는데, 인자를 원래 순서로 두면 0점과 도는 방향이 화면과 어긋나고, 이 판의 애니메이션 9개는 아홉 개 가운데 가장 많으며 누적면적은 13.382% 입니다.

function apply() {
  var angle = MIN + (MAX - MIN) * value / 100; // 백분율 값을 각도로
  root.style.setProperty('--angle', String(Math.round(angle)));
  root.style.setProperty('--fill', (value / 100).toFixed(3));
  knob.setAttribute('aria-valuenow', String(value));
  knob.setAttribute('aria-valuetext', VAL_TEXT.replace('{v}', String(value)));
  valueEl.textContent = value + '%';
}
function angleOf(x, y) {
  var r = ring.getBoundingClientRect();
  var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
  var deg = Math.atan2(x - cx, cy - y) * 180 / Math.PI; // 정위가 0도, 시계 방향이 양수
  return Math.max(MIN, Math.min(MAX, deg));
}

04밀기 페이더 슬라이더

채널 페이더를 좌우로 미는 믹서 콘솔의 조절기로, 값 0~10 은 aria-valuenow 와 눈금 아래 숫자에 함께 나타납니다.

밀기 페이더 슬라이더
새 창에서 열기

04 · 밀기 페이더 슬라이더 — 측정 누적면적 16.537% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

role="slider"aria-valuenowpointerdowntranslateX

이동과 눌림을 두 요소에 나눈 설계

캡의 이동은 래퍼의 translateX 로, 끝에서 눌리는 쿠션은 캡 자신의 scaleX(.82) 로 나눴는데, scaleX 는 제 중심을 기준으로 줄어들기 때문에 한 요소에 합치면 눌림이 중앙 쪽으로 밀리는 그림이 됩니다.

@keyframes pfTravel {
  0%   { transform: translateX(0); }
  5%   { transform: translateX(0); }
  44%  { transform: translateX(calc(100% - 48px)); }
  55%  { transform: translateX(calc(100% - 48px)); }
  89%  { transform: translateX(0); }
  100% { transform: translateX(0); }
}
@keyframes pfCushion {
  0%   { transform: scaleX(1); }
  3%   { transform: scaleX(.82); }
  9%   { transform: scaleX(1); }
  44%  { transform: scaleX(1); }
  48%  { transform: scaleX(.82); }
  54%  { transform: scaleX(1); }
  89%  { transform: scaleX(1); }
  93%  { transform: scaleX(.82); }
  97%  { transform: scaleX(1); }
  100% { transform: scaleX(1); }
}

끝에 닿아야만 울리는 쿠션

쿠션은 드래그든 키보드든 캡이 실제로 끝에 닿는 순간에만 180ms 동안 붙었다 떨어지고, 누적면적 16.537% 는 조절기 두 판 가운데 더 넓게 움직이는 값입니다.

  function travel() { return Math.max(0, wrap.clientWidth - cap.offsetWidth); }
  function human() { root.classList.remove('is-demo'); }
  function cushion() {
    cap.classList.add('is-press');
    clearTimeout(pressTimer);
    pressTimer = setTimeout(function () { cap.classList.remove('is-press'); }, 180);
  }
  function apply(px) {
    wrap.style.setProperty('--x', px + 'px');
    lastPx = px;
    value = travel() > 0 ? Math.round(px / travel() * MAX) : 0;
    well.setAttribute('aria-valuenow', String(value));
    nums.forEach(function (n, i) { n.classList.toggle('is-on', i === value); });
  }
  apply(value / MAX * travel());

05비상 뱅 버튼

붉은 돔을 누르면 원근이 걸린 장면 안으로 가라앉으며(translateZ(-120px)) 경고등이 켜지고, 눌림은 대기·가동 글자와 aria-pressed 로 전달됩니다.

비상 뱅 버튼
새 창에서 열기

05 · 비상 뱅 버튼 — 측정 누적면적 4.902% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-pressedperspectivetranslateZbox-shadow

장면의 깊이를 만드는 세 겹

버튼의 플라스틱 재질은 하이라이트 radial-gradient 두 장과 두꺼운 box-shadow 로 쌓았고, 받침 그림자는 버튼과 같은 타이밍에 scaleX(.55) 로 좁아지며 깊이를 함께 말합니다.

.bb__well { position: relative; display: flex; justify-content: center; perspective: 600px; }
.bb__shadow {
  position: absolute; left: 50%; bottom: -8px; width: 92%; height: 14px;
  transform: translateX(-50%) scaleX(1); border-radius: 50%;
  background: rgba($subject-ink, .34);
  transition: transform $duration $easing, opacity $duration $easing;
}
.bb__btn {
  position: relative; display: block; width: clamp(88px, 24vw, 108px); height: clamp(64px, 17vw, 80px);
  border: 0; border-radius: 50%; padding: 0; cursor: pointer; touch-action: manipulation;
  background:
    radial-gradient(120% 90% at 32% 22%, rgba(255, 255, 255, .5), rgba(255, 255, 255, 0) 46%),
    radial-gradient(140% 120% at 50% 118%, rgba($subject-ink, .5), rgba($subject-ink, 0) 52%),
    $color;
  box-shadow: 0 10px 0 rgba($subject-ink, .3), 0 14px 18px rgba($subject-ink, .35), inset 0 2px 3px rgba(255, 255, 255, .55), inset 0 -4px 6px rgba($subject-ink, .45);
  transition: transform $duration $easing, box-shadow $duration $easing;
}

가라앉고 돌아오는 시간표

미리보기에서 버튼은 6% 에 내려가기 시작해 14% 에 바닥에 닿고 경고등 깜빡임(12~47%)이 이어지는데, 강도 평균 35.4 의 차분한 템포는 긴급 정지 컨트롤에 어울립니다.

@keyframes bbSink {
  0%, 6%   { transform: translateZ(0); }
  14%, 46% { transform: translateZ(-120px); }
  54%, 100% { transform: translateZ(0); }
}
@keyframes bbShadow {
  0%, 6%   { transform: translateX(-50%) scaleX(1); opacity: 1; }
  14%, 46% { transform: translateX(-50%) scaleX(.55); opacity: .55; }
  54%, 100% { transform: translateX(-50%) scaleX(1); opacity: 1; }
}

06당겨 여는 풀 노브

노브를 아래로 당기면 줄기가 늘어나고, 30픽셀 넘게 당겼다 놓으면 잠금이 풀려 덮개가 열리며 노브는 제자리로 돌아가는, 기계 커버·금고 잠금 해제 위젯용 결입니다.

당겨 여는 풀 노브
새 창에서 열기

06 · 당겨 여는 풀 노브 — 측정 누적면적 20.842% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-expandedpointerupscaletransition

당김 하나가 구동하는 이동과 늘어남

사용자 지정 속성 --pull 하나가 translateY 로 노브를 내리는 동시에 scale(1, calc(1 + var(--pull) / 220)) 로 위 원점(transform-origin: 50% 0)에서 세로로 늘립니다.

.pk__knob {
  position: relative; width: clamp(56px, 15vw, 68px); height: clamp(36px, 10vw, 44px);
  border: 0; border-radius: $r-control; padding: 0; cursor: grab; touch-action: none;
  transform-origin: 50% 0; // 줄기에 붙어 있어 늘어남은 위 원점에서(비중심 원점 사유)
  transform: translateY(calc(var(--pull, 0) * 1px)) scale(1, calc(1 + var(--pull, 0) / 220));
  background:
    radial-gradient(120% 140% at 30% 0%, rgba($stage-paper, .35), rgba($stage-paper, 0) 50%),
    $subject-ink;
  box-shadow: 0 6px 10px rgba($subject-ink, .4), inset 0 1px 0 rgba($subject-cream, .3), inset 0 -3px 0 rgba(0, 0, 0, .5);
  transition: transform $dur-base $easing;
}

임계값과 탭을 가르는 경계

미리보기에서 당김이 가장 깊은 프레임은 화면의 18.543% 를 한 번에 바꿔 순간 면적이 아홉 개 중 가장 넓은데, 임계값 30픽셀을 넘겨야 잠금이 풀리고 ±6픽셀 안의 움직임은 탭으로 보아 열림을 토글합니다.

  knob.addEventListener('pointermove', function (e) {
    if (!dragging) { return; }
    var dy = e.clientY - startY;
    setPull(Math.min(PULL_MAX, Math.max(0, dy)));
  });
  knob.addEventListener('pointerup', function (e) {
    if (!dragging) { return; }
    dragging = false;
    var moved = e.clientY - startY;
    root.classList.remove('is-drag');
    setPull(0);
    if (moved >= THRESHOLD) { setOpen(true); }
    else if (moved >= -6 && moved < 6) { setOpen(knob.getAttribute('aria-expanded') !== 'true'); }
    if (knob.hasPointerCapture(e.pointerId)) { knob.releasePointerCapture(e.pointerId); }
  });

07시소 틸트 스위치

가운데 축을 두고 양쪽 패드를 번갈아 눌러 A·B 를 고르는 선택기로, 선택은 기울어진 각도·가라앉은 패드·아래 글자 셋으로 읽힙니다.

시소 틸트 스위치
새 창에서 열기

07 · 시소 틸트 스위치 — 측정 누적면적 19.144% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-pressedrotatetransform-origintransition

판이 도는 축의 자리

판의 회전축은 transform-origin: 50% 84%, 곧 받침 반구에 닿는 지점이라 좌우 ±8도 회전이 선택 자체를 보여 줍니다.

.ss__plank {
  position: absolute; left: 0; right: 0; top: clamp(20px, 6.5vw, 26px); height: clamp(42px, 12vw, 52px);
  display: flex; align-items: center; justify-content: space-between; box-sizing: border-box;
  padding: 0 $sp-2;
  background: linear-gradient(180deg, rgba($subject-cream, .55), rgba($subject-cream, 0) 32%, rgba($stage-ink, .18)), $color;
  border: 1px solid rgba($stage-ink, .25); border-radius: $r-control;
  box-shadow: 0 10px 18px rgba($stage-ink, .26), inset 0 1px 0 rgba($subject-cream, .7);
  transform: rotate(-8deg);
  transform-origin: 50% 84%;
  transition: transform $duration $easing;
}

data-side 하나로 갈라지는 화면

각도·패드 눌림·글자는 data-side 속성 하나에서 전부 갈라져, JS 가 화면 글자를 직접 건드리지 않으며, 누적면적 19.144% 의 넉넉한 동작으로 A/B 옵션 선택기를 채웁니다.

function pick(side) {
  root.classList.remove('is-demo');
  root.setAttribute('data-side', side);
  padA.setAttribute('aria-pressed', side === 'a' ? 'true' : 'false');
  padB.setAttribute('aria-pressed', side === 'b' ? 'true' : 'false');
}
padA.addEventListener('click', function () { pick('a'); });
padB.addEventListener('click', function () { pick('b'); });
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });

08차단기 브레이커 스위치

배전반 모듈 전체가 하나의 role="switch" 버튼이라 손잡이를 젖히는 동작이 곧 클릭이고, 내리면 한 번 멈칫하다 무겁게 떨어지며 꺼짐 표시등이 켜집니다.

차단기 브레이커 스위치
새 창에서 열기

08 · 차단기 브레이커 스위치 — 측정 누적면적 7.954% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

role="switch"aria-checkedrepeating-linear-gradientsteps(1,end)

steps 한 단계로 짓는 무게

손잡이의 실제 전환은 transition 의 타이밍을 steps(1,end) 한 단계로 두어 떨어뜨리는데, 연속 이징으로 바꾸면 잠깐 버티다 뚝 떨어지는 기계의 무게가 스르륵 미끄러지는 움직임으로 풀려 버립니다.

.bk__lever {
  position: absolute; left: 50%; top: 50%; width: clamp(34px, 9vw, 40px); height: clamp(76px, 20vw, 88px);
  background: linear-gradient(90deg, rgba($stage-ink, .32), rgba($subject-cream, .5) 28%, rgba($subject-cream, .16) 55%, rgba($stage-ink, .38)), $color;
  border: 1px solid rgba($stage-ink, .45); border-radius: $r-sm;
  box-shadow: 0 8px 14px rgba($stage-ink, .55), inset 0 1px 0 rgba($subject-cream, .85);
  transform: translate(-50%, -50%) rotate(-26deg);
  transform-origin: 50% 50%;
  transition: transform $duration steps(1,end);
}

컷 지점을 가운데 둔 시간표

미리보기에서 손은 22%·64% 에 손잡이를 끝까지 밀고, 각도는 27%·70% 컷에서 ±26도를 뒤집으며, 판이 2픽셀 가라앉는 bkThud 가 무게를 더합니다(움직인 프레임 16/23).

@keyframes bkLever {
  0%, 25%   { transform: translate(-50%, -50%) rotate(-26deg); }
  27%, 68%  { transform: translate(-50%, -50%) rotate(26deg); }
  70%, 100% { transform: translate(-50%, -50%) rotate(-26deg); }
}
@keyframes bkHand {
  0%        { transform: translateY(-20px); }
  12%       { transform: translateY(0); }
  22%       { transform: translateY(16px); }
  30%, 52%  { transform: translateY(0); }
  64%       { transform: translateY(16px); }
  72%       { transform: translateY(0); }
  84%, 100% { transform: translateY(-20px); }
}

09카세트 이젝트 버튼

이젝트 키를 누르면 테이프가 튀어나와 한 번 흔들리며 멈추고, 다시 누르면 들어가는, 레트로 뮤직 플레이어의 넣고 빼기 인터랙션입니다.

카세트 이젝트 버튼
새 창에서 열기

09 · 카세트 이젝트 버튼 — 측정 누적면적 19.301% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-pressedcubic-bezier(.2,1.8,.3,1)translateYkeyframes

과돌 이징이 만드는 흔들림

튀어나오는 힘은 cubic-bezier(.2, 1.8, .3, 1) 의 과돌에서 나와, 첫 튕김이 목표 높이 --pop(-46px) 을 지나쳤다가 되돌아오며, 좁은 화면(340px 이하)에서는 --pop 이 -26px 로 낮아집니다.

.cs__tape {
  position: absolute; left: 50%; bottom: $sp-2;
  width: clamp(150px, 42vw, 174px); height: clamp(84px, 23vw, 96px); box-sizing: border-box;
  background: linear-gradient(180deg, rgba($subject-cream, .35), rgba($subject-cream, 0) 30%, rgba($stage-ink, .2)), $color;
  border: 1px solid rgba($stage-ink, .35); border-radius: $r-sm;
  box-shadow: 0 10px 16px rgba($stage-ink, .5), inset 0 1px 0 rgba($subject-cream, .9);
  transform: translateX(-50%);
  // 들어감(0) 에서 뽑힘(var(--pop)) — 과돌 이징이 흔들림을 만든다
  transition: transform $duration cubic-bezier(.2,1.8,.3,1);
}

꺼냄과 넣음의 시간표

미리보기에서 테이프는 20%·28%·36% 세 번의 흔들림을 거쳐 42% 에 멈추고 86% 에 완전히 들어가는데, 강도 평균 113.7 은 아홉 개 중 가장 센 값입니다.

@keyframes csPop {
  0%, 6%    { transform: translateX(-50%) translateY(0); }
  20%       { transform: translateX(-50%) translateY(calc(var(--pop) - 12px)); }
  28%       { transform: translateX(-50%) translateY(calc(var(--pop) + 6px)); }
  36%       { transform: translateX(-50%) translateY(calc(var(--pop) - 4px)); }
  42%, 72%  { transform: translateX(-50%) translateY(var(--pop)); }
  86%, 100% { transform: translateX(-50%) translateY(0); }
}

어디서 깨지는가 — 코드를 옮기면 죽는 움직임

원근은 직계 부모에 걸려야 산다

05 의 눌림을 만드는 선언은 transform: translateZ(-120px) 한 줄인데, 이 줄은 관찰자까지의 거리를 정하는 perspective 가 없으면 화면에서 아무 일도 하지 않습니다. 소스에서 perspective: 600px 는 버튼을 직접 담은 .bb__well 에, 06 의 덮개가 도는 perspective: 520px 는 .pk__lidWrap 에 걸려 있습니다. CSS 의 perspective 는 직계 자식에게만 적용되므로, 코드를 베끼다 버튼을 한 겹 더 싸거나 원근을 더 위 조상으로 옮기는 순간 눌림이 통째로 사라집니다. 한 단계를 더 끼우려면 그 상자에 transform-style: preserve-3d 를 이어 주어야 하는데, 실제로 .is-demo 를 뗀 05 를 눌러 확인한 값도 이 길을 그대로 밟습니다 — 버튼의 변환행렬이 matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -120, 1) 로 Z 축 -120 을 잡았다가 다시 누르면 none 으로 돌아옵니다(run/013/_probe/interact_04_06.json). 근거는 MDN 의 perspective 문서입니다.

드래그가 스크롤과 싸우는 자리

03·04·06 처럼 손가락이 따라다니는 조작 요소에는 touch-action: none 이 한 줄씩 들어 있는데, 이 줄이 없으면 터치 드래그를 시작하는 순간 브라우저가 스크롤을 차지하려 들어 pointermove 가 몇 프레임 만에 끊기고 포인터 캡처도 이 싸움을 대신 끝내 주지는 않습니다(MDN Pointer events). 토글 스위치 css 예제를 남의 레이아웃에 옮겨 심을 때는 이렇게 딸린 한 줄까지 함께 가져가야 움직임이 살고, 06 을 실제로 당겨 보면 노브가 44픽셀 내려오며 세로로 1.2배 늘어난 뒤 제자리로 돌아가는 것까지 같은 파일에 잡혀 있습니다. 대비도 .is-demo 를 뗀 상태에서 글자 36자리를 전부 다시 잰 결과 한 자리도 AA(4.5:1) 밑이 없습니다(측정 스크립트 run/013/_probe_contrast/contrast.py).

잰 곳 글자 배경 대비 판정
01 상태 단어 꺼짐 #ded7c9 #252226 11.03:1 AA
03 눈금 숫자 0 #17141a #ff4d1f 5.5:1 AA
04 켜진 눈금 숫자 5 #17141a #ffffff 18.24:1 AA
05 경고등 글자 #17141a #fff7e6 17.11:1 AA
07 패드 아래 글자 #58565a #ffffff 7.27:1 AA

가장 낮은 자리는 03·07 의 주황 무대(#ff4d1f) 위 먹(#17141a) 글자 5.5:1 로, 두 판의 글자를 옅은 색 없이 온전히 먹으로 찍은 덕분에 기준을 넘었습니다. 아홉 개의 SCSS 원본과 컴파일된 CSS, 항목마다 붙인 React 판까지 담은 압축 파일의 비밀번호는 8a45qw2n 입니다.

변형 세 가지와 접근성

SCSS 맨 위 변수 세 줄로 톤 바꾸기

아홉 개의 style.scss 는 머리에 같은 변수 세 줄($duration·$easing·$color)을 두고 아래 선언이 전부 그 셋만 참조합니다. 01 은 이징 칸에 $ease-pop 의 값 cubic-bezier(.2, 1.4, .4, 1) 을 직접 적어 두었고, 05·06 은 셋째 칸을 각각 $error·$subject-blue 로 씁니다.

이름 바꾸는 값 느낌
duration $dur-quick 220ms → 120ms 레버·로커가 기계 부품처럼 딱 끊기게 떨어진다
easing $ease-spring → $ease-pop 이젝트·뱅 버튼이 튀어나오는 힘이 한 단계 세진다
color $stage-paper → $stage-yellow 플라스틱 몸체가 크림에서 산업용 옐로로 바뀐다

움직임을 줄였을 때 남는 한 장

prefers-reduced-motion: reduce 에서는 아홉 루프가 모두 꺼지고 각자 다른 정지 장면을 남깁니다. 01 은 왼쪽으로 기운 레버와 꺼진 LED, 02 는 윗면이 떠 있는 기본 로커, 03 은 0% 바늘과 실제 값 글자, 04 는 가운데 값 5 의 캡, 05 는 올라온 버튼, 06 은 닫힌 덮개, 07 은 왼쪽으로 기운 판, 08 은 켜짐(손잡이 위), 09 는 들어간 테이프입니다. reduce 블록의 선언마다 !important 를 단 까닭은 .is-demo 가 붙은 선택자의 특이도가 더 높아서이고, 기준은 WCAG 2.2 상호작용 애니메이션 이해 문서입니다.

색 없이도 읽히는 장치

아홉 개 모두 색을 뺀 화면에서 상태가 읽힙니다. 01·02 는 LED 점과 ON·OFF 글자에 상태 단어를 role="status" 로 덧붙였고, 03·04 는 값을 숫자와 aria-valuenow 로 함께 내보내고, 03 은 aria-valuetext 가 '0퍼센트' 꼴로 소리 내어 읽힙니다. 05 는 대기·가동 글자에 aria-pressed, 06 은 잠김·열림 글자에 aria-expanded, 07 은 기울기·눌림·A/B 글자에 aria-pressed, 08·09 는 손잡이 각도와 테이프 위치에 상태 문장을 곁들였습니다. 스위치와 슬라이더의 역할 정의는 MDN switch role과 MDN slider role을 그대로 따랐습니다.

FAQ

css 버튼 디자인 예제를 그대로 복사해 써도 되나요

그래도 됩니다. 압축 파일에는 항목마다 SCSS 원본, 컴파일된 CSS, HTML, React 판(컴포넌트와 module.scss)이 들어 있어 그대로 붙여 넣으면 돌아갑니다. SCSS 원본을 쓸 때는 파일 머리의 @use 경로만 프로젝트에 맞게 고치면 되고, 아이콘만 있는 자리를 채우는 짝은 아이콘·플로팅 버튼 모음입니다.

폼 제출에는 어떻게 연결하나요

이 컨트롤의 상태는 그림을 가르는 속성 하나에만 담깁니다. 01·02·08 은 aria-checked, 03·04 는 aria-valuenow, 05·07·09 는 aria-pressed, 06 은 aria-expanded 이니 제출 시점에 이 값을 읽어 숨은 input 에 옮기면 되고, JS 없이 폼만 쓰겠다면 <input type="checkbox"> 를 라벨로 감싸고 체크 상태를 그림으로 옮기는 쪽으로 갈아 끼우면 됩니다. 서식 전체를 같은 결로 꾸밀 때는 입력창 디자인 9종이 이어집니다.

다이얼 회전 범위를 270도보다 넓게 쓰려면

세 곳이 함께 움직여야 합니다. JS 의 MIN·MAX 상수(-135·135)가 바늘과 값의 범위이고, 눈금 링의 repeating-conic-gradient(27도 간격, 눈금 11개)와 진행 호의 conic-gradient(270도 부채꼴)가 눈에 보이는 범위입니다. 이 설계는 아래쪽 90도 구간을 비워 두었으므로 360도에 가깝게 늘리면 노브가 아래쪽 숫자를 가리게 됩니다. 범위를 늘릴 때는 값 판 쪽으로 눈금 숫자를 옮기는 일까지 함께 계획하는 것이 안전합니다.

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

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