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

토글 버튼 그룹 9종 — 나란히 붙은 버튼이 하나처럼 움직이는 아홉 가지

토글 버튼 그룹 9종을 값 기준으로 나누면, 아홉 가지 중 하나는 활성 버튼 값이 바뀌면 슬라이드 표시자의 위치 값이 실측 좌표를 따라 이동하고, 하나는 그룹 너비 값이 줄어들면 넘치는 버튼이 더보기 값 안으로 접히며, 또 하나는 방향키로 포커스 값이 옮겨질 때 포커스 링의 위치 값이

아홉 데모는 전부 requestAnimationFrame 루프 안에서 각자의 기준 값 하나를 계산해 다음 프레임의 모양을 정합니다. 항목마다 그 값이 무엇으로 시작해 무엇으로 끝나는지 표와 코드로 먼저 짚고, 실제 조작이 값을 어디서 깨뜨리는지는 뒤에서 따로 다룹니다.

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

아홉 개의 해부 — 어떤 값이 언제 바뀌는가

아래 표는 아홉 항목이 화면에서 실제로 얼마나 넓게, 얼마나 세게, 얼마나 자주 움직였는지를 담은 값입니다. 버튼 그룹 중 누적 면적이 가장 넓은 항목은 06 하나(방향이 바뀌며 버튼들이 새 좌표로 다시 줄을 서는 만큼 화면을 넓게 씁니다)이고, 가장 좁은 항목은 03 하나(구분선 하나의 투명도만 바뀌므로 면적 자체가 작습니다)이며, 강도가 가장 센 항목은 08 하나(포커스 링이 좌표를 이징으로 따라가며 순간 변화량이 큽니다)뿐입니다.

항목 누적면적% 강도_평균 움직인프레임
01 2.75% 47.5 11/23
02 4.88% 102.6 13/23
03 0.30% 24.9 13/23
04 9.53% 77.3 15/23
05 6.63% 121.3 22/23
06 15.29% 120.7 16/23
07 0.60% 76.4 9/23
08 8.26% 190.7 18/23
09 2.13% 45.7 16/23

계산 방식으로 보면 아홉 항목은 네 갈래로 묶입니다.

스프링으로 튕기는 값 — 02·04

02 는 활성 버튼이 바뀔 때 STIFF 210·DAMP 18 스프링으로 표시자의 위치 값을 다음 버튼 좌표까지 감쇠 진동시키고, 04 는 눌림 값이 바뀔 때 STIFF 320·DAMP 14 스프링으로 버튼의 눌림 깊이 값을 오버슛과 함께 되돌립니다. 두 항목 모두 목표 값과 현재 값의 차이를 힘으로 바꿔 매 프레임 속도와 위치를 함께 갱신합니다.

실측 좌표를 이징으로 따라가는 값 — 06·07·08

06 은 방향이 바뀔 때 각 버튼의 이전 좌표와 새 좌표를 getBoundingClientRect 로 재고 FLIP 기법으로 그 사이를 480ms 동안 잇고, 07 은 컨테이너 너비 값이 기준을 넘으면 라벨의 실제 글자 너비 값을 재서 0부터 그 값까지 easeOutCubic 으로 늘리며, 08 은 방향키를 누를 때마다 다음 버튼의 실측 좌표까지 포커스 링의 위치 값을 easeInOut 으로 옮깁니다. 셋 다 스프링이 아니라 시작 값과 끝 값 사이를 정해진 시간 동안 이징 함수 하나로 보간합니다.

매 프레임 직접 계산하는 값 — 01·03·05

01 은 클릭마다 경계선 근처에 번지는 리플 반경 값을 프레임마다 계산하고, 03 은 포인터와 구분선의 거리 값을 매 프레임 다시 재 smoothstep 으로 투명도 값을 구하며, 05 는 버튼 너비의 합과 그룹 너비 값을 매 프레임 비교해 넘치는 버튼 수를 그 자리에서 정합니다. 세 항목은 보간할 시작·끝 값이 따로 없고, 그 프레임의 조건만으로 값이 곧바로 정해집니다.

한 번의 입력이 감쇠하며 끝나는 값 — 09

09 는 비활성 버튼을 누른 순간 파동 확산 값과 흔들림 값을 동시에 시작시켜, 파동은 절반에서 멈추고 흔들림은 sin 감쇠로 잦아들 때까지만 한 번 계산됩니다.

패턴별 코드

01공유 경계선 병합 — 나란히 붙은 두 버튼의 안쪽 경계선 값이 하나로 겹쳐 두 겹으로 보이지 않게 유지되는 방식

버튼과 버튼이 만나는 안쪽 경계선끼리 -1.5px 만큼 겹치게 잡아, 어느 버튼이 활성이어도 경계선이 두 겹으로 보이지 않게 만든 값입니다. 클릭은 그 위에서 번지는 리플 반경 값만 바꾸고, 겹침 값 자체는 버튼 배치가 바뀌지 않는 한 고정됩니다.

.bg__btn {
  position: relative;
  appearance: none;
  border: 1.5px solid rgba($subject-cream, .38);
  background: rgba($subject-cream, .06);
  color: $subject-cream;
  font-size: 13px;
  font-weight: 600;
  padding: $sp-2 $sp-4;
  cursor: pointer;
  overflow: hidden;
  transition: background-color $dur-quick $ease-out, color $dur-quick $ease-out, border-color $dur-quick $ease-out;

  // 공유 경계선 병합 — 이웃 버튼과 1.5px 만큼 겹쳐 안쪽 경계선이 항상 한 겹으로만 보인다
  & + & { margin-left: -1.5px; }
  &:first-child { border-radius: $r-control 0 0 $r-control; }
  &:last-child { border-radius: 0 $r-control $r-control 0; }
  &:hover, &:focus-visible { z-index: 1; }
  &:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
공유 경계선 병합 — 나란히 붙은 두 버튼의 안쪽 경계선 값이 하나로 겹쳐 두 겹으로 보이지 않게 유지되는 방식
새 창에서 열기

01 · 공유 경계선 병합 — 나란히 붙은 두 버튼의 안쪽 경계선 값이 하나로 겹쳐 두 겹으로 보이지 않게 유지되는 방식 — 측정 누적면적 2.754% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

margin-left: -1.5px seamclip-path bleedBLEED_PX 6

02활성 표시자 슬라이드 — 활성 버튼 값이 바뀌면 강조 배경의 위치 값이 실측 좌표를 따라 옆 버튼으로 미끄러지는 방식

활성 버튼이 바뀔 때마다 표시자의 목표 위치를 두 버튼의 실측 좌표 차이로 구하고, STIFF 210·DAMP 18 스프링으로 그 차이를 매 프레임 줄여 나가는 값입니다. 감쇠가 낮아 목표를 살짝 지나쳤다가 되돌아오는 오버슛이 자연스럽게 남습니다.

  function render() {
    var scale = maxW > 0 ? w / maxW : 1;
    indicator.style.transform = 'translateX(' + x.toFixed(2) + 'px) scaleX(' + scale.toFixed(4) + ')';
  }

  var cycleStart = 0, lastIdx = -1;
  function loop(ts) {
    if (!last) last = ts;
    var dt = Math.min(0.032, (ts - last) / 1000);
    last = ts;

    if (!cycleStart) cycleStart = ts;
    if (isDemo && !reduce) {
      var idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
      if (idx !== lastIdx) { lastIdx = idx; goTo(idx, ts); }
    }

    var ax = STIFF * (targetX - x) - DAMP * vx;
    var aw = STIFF * (targetW - w) - DAMP * vw;
    vx += ax * dt; x += vx * dt;
    vw += aw * dt; w += vw * dt;
    render();

    requestAnimationFrame(loop);
  }
활성 표시자 슬라이드 — 활성 버튼 값이 바뀌면 강조 배경의 위치 값이 실측 좌표를 따라 옆 버튼으로 미끄러지는 방식
새 창에서 열기

02 · 활성 표시자 슬라이드 — 활성 버튼 값이 바뀌면 강조 배경의 위치 값이 실측 좌표를 따라 옆 버튼으로 미끄러지는 방식 — 측정 누적면적 4.878% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STIFF 210 / DAMP 18translateX + scaleXspring integrate

03구분선 페이드 — 버튼 사이 구분선의 투명도 값이 인접 버튼에 마우스가 가까워질수록 옅어지는 방식

포인터가 구분선에 가까워질수록 거리 값이 FALLOFF_PX 46 안으로 들어오고, smoothstep 으로 구한 그 값을 lerp 계수 .22 로 매 프레임 투명도에 가깝게 붙이는 항목입니다. 포인터가 멀어지면 같은 계산이 반대 방향으로 진행돼 구분선이 다시 또렷해집니다.

  function loop(ts) {
    if (!demoStart) demoStart = ts;
    var rect = root.getBoundingClientRect();
    var px = isDemo && !reduce ? demoPointerX(ts, rect.width) : pointerX;
    var centers = seamCenters();

    for (var i = 0; i < seams.length; i++) {
      var target = px == null ? 1 : targetOpacity(Math.abs(px - centers[i]));
      opac[i] += (target - opac[i]) * (reduce ? 1 : SMOOTH);
      seams[i].style.opacity = opac[i].toFixed(3);
    }

    requestAnimationFrame(loop);
  }
구분선 페이드 — 버튼 사이 구분선의 투명도 값이 인접 버튼에 마우스가 가까워질수록 옅어지는 방식
새 창에서 열기

03 · 구분선 페이드 — 버튼 사이 구분선의 투명도 값이 인접 버튼에 마우스가 가까워질수록 옅어지는 방식 — 측정 누적면적 0.297% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FALLOFF_PX 46smoothsteplerp SMOOTH .22

04배타적 눌림 상태 — 한 버튼의 눌림 값이 켜지면 나머지 버튼의 눌림 값이 그 즉시 함께 꺼지는 방식

한 버튼을 클릭하거나 방향키로 옆 버튼에 초점을 옮기면 그 버튼의 눌림 값만 켜고 나머지는 그 즉시 함께 꺼서, 항상 하나의 값만 켜져 있게 만드는 항목입니다. 켜지는 쪽은 STIFF 320·DAMP 14 스프링으로 눌림 깊이를 오버슛과 함께 되돌립니다.

  function setChecked(idx) {
    checkedIdx = idx;
    btns.forEach(function (b, i) {
      var on = i === idx;
      b.setAttribute('aria-checked', String(on));
      b.setAttribute('tabindex', on ? '0' : '-1');
      target[i] = on ? 1 : 0;
      if (reduce) { depth[i] = target[i]; vel[i] = 0; }
    });
  }

  function human() {
    if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
  }

  btns.forEach(function (b, i) {
    b.addEventListener('click', function () { human(); setChecked(i); b.focus({ preventScroll: true }); });
  });
배타적 눌림 상태 — 한 버튼의 눌림 값이 켜지면 나머지 버튼의 눌림 값이 그 즉시 함께 꺼지는 방식
새 창에서 열기

04 · 배타적 눌림 상태 — 한 버튼의 눌림 값이 켜지면 나머지 버튼의 눌림 값이 그 즉시 함께 꺼지는 방식 — 측정 누적면적 9.53% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STIFF 320 / DAMP 14translateY depthspring overshoot

05넘침 접힘 더보기 — 그룹 너비 값이 좁아지면 넘치는 버튼들이 하나의 더보기 값 안으로 접히는 방식

그룹 너비 값과 버튼 너비의 합을 매 프레임 비교해, 합이 그룹보다 OVERFLOW_BUFFER 24px 이상 넘치면 뒤쪽 버튼부터 감춰 더보기 값 하나로 접는 항목입니다. 화면이 아무리 좁아져도 최소 한 개의 그룹 버튼은 항상 남습니다.

    if (sumAll > avail) {
      var sum = 0;
      cutoff = 0;
      for (var i = 0; i < widths.length; i++) {
        var add = widths[i] + (i > 0 ? gap : 0);
        if (sum + add + moreWidth > avail) break;
        sum += add;
        cutoff = i + 1;
      }
      cutoff = Math.max(1, cutoff);
    }

    btns.forEach(function (b, i) { b.hidden = i >= cutoff; });
    var overflowCount = widths.length - cutoff;
    more.hidden = overflowCount === 0;
    more.textContent = '더보기 ▾ (' + overflowCount + ')';
넘침 접힘 더보기 — 그룹 너비 값이 좁아지면 넘치는 버튼들이 하나의 더보기 값 안으로 접히는 방식
새 창에서 열기

05 · 넘침 접힘 더보기 — 그룹 너비 값이 좁아지면 넘치는 버튼들이 하나의 더보기 값 안으로 접히는 방식 — 측정 누적면적 6.63% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

OVERFLOW_BUFFER 24getBoundingClientRect cachewidths cutoff sum

06가로세로 방향 전환 — 그룹의 방향 값이 바뀌면 버튼들의 위치 값이 실측 좌표 사이를 이동해 줄이 다시 짜이는 방식

그룹의 방향 값이 가로에서 세로로 바뀌는 순간 각 버튼의 이전 좌표를 getBoundingClientRect 로 미리 재 두고, 강제 리플로우 뒤 새 좌표까지 FLIP 기법으로 480ms 동안 되돌리는 항목입니다. 실제 레이아웃은 바뀐 직후 이미 새 자리에 있고, 눈에 보이는 이동은 그 차이를 거꾸로 재생한 결과입니다.

    btns.forEach(function (b, i) {
      var dx = first[i].left - last[i].left;
      var dy = first[i].top - last[i].top;
      if (!dx && !dy) return;
      b.style.transition = 'none';
      b.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
      // 강제 리플로우로 초기 transform 을 화면에 반영시킨 뒤에만 되돌리기 transition 이 재생된다.
      b.getBoundingClientRect();
      requestAnimationFrame(function () {
        b.style.transition = 'transform ' + FLIP_MS + 'ms cubic-bezier(.22,.9,.32,1)';
        b.style.transform = 'translate(0,0)';
      });
    });
가로세로 방향 전환 — 그룹의 방향 값이 바뀌면 버튼들의 위치 값이 실측 좌표 사이를 이동해 줄이 다시 짜이는 방식
새 창에서 열기

06 · 가로세로 방향 전환 — 그룹의 방향 값이 바뀌면 버튼들의 위치 값이 실측 좌표 사이를 이동해 줄이 다시 짜이는 방식 — 측정 누적면적 15.29% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLIP_MS 480FLIP Invert Playforced reflow

07아이콘 라벨 조합 노출 — 컨테이너 너비 값이 넓어지면 아이콘 버튼의 글자 라벨 너비 값이 0에서 늘어나며 드러나는 방식

컨테이너 너비 값이 기준을 넘으면 아이콘 옆 라벨의 실제 글자 너비 값을 재서, 0에서 그 값까지 easeOutCubic 으로 늘리며 clip-path 로 드러내는 항목입니다. 포인터가 버튼 위에 머물거나 키보드 초점이 오면 같은 계산이 시작되고, 벗어나면 반대로 줄어듭니다.

  function render(cell) {
    var pct = (1 - cell.progress) * 100;
    cell.reveal.style.clipPath = 'inset(0 ' + pct.toFixed(2) + '% 0 0)';
    cell.label.style.opacity = String(cell.progress);
  }

  function animate(cell, to) {
    cell.target = to;
    var from = cell.progress;
    if (reduce) { cell.progress = to; render(cell); return; }
    if (cell.raf) cancelAnimationFrame(cell.raf);
    var dur = 400, t0 = 0;
    function step(ts) {
      if (!t0) t0 = ts;
      var t = Math.min(1, (ts - t0) / dur);
      cell.progress = from + (to - from) * easeOutCubic(t);
      render(cell);
      if (t < 1) cell.raf = requestAnimationFrame(step);
      else cell.raf = 0;
    }
    cell.raf = requestAnimationFrame(step);
  }
아이콘 라벨 조합 노출 — 컨테이너 너비 값이 넓어지면 아이콘 버튼의 글자 라벨 너비 값이 0에서 늘어나며 드러나는 방식
새 창에서 열기

07 · 아이콘 라벨 조합 노출 — 컨테이너 너비 값이 넓어지면 아이콘 버튼의 글자 라벨 너비 값이 0에서 늘어나며 드러나는 방식 — 측정 누적면적 0.602% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

dur 400 easeOutCubicclip-path revealmeasured text width

08키보드 방향키 포커스 슬라이드 — 방향키로 포커스 값이 옮겨질 때 포커스 테두리의 위치 값이 버튼 사이 실측 좌표를 따라 이동하는 방식

방향키를 누르면 다음 버튼의 실측 좌표를 구해 포커스 테두리의 위치·크기 값을 FOCUS_EASE easeInOut 으로 그 좌표까지 옮기는 항목입니다. role=radiogroup·role=radio 로 각 버튼의 aria-checked 값을 함께 세우고, 이동이 끝나면 focus({preventScroll:true}) 로 스크롤 없이 포커스를 맞춥니다.

  function moveRing(toIdx) {
    var to = measure(toIdx);
    if (reduce || !from) { render(to); from = to; return; }
    var start = from, dur = 260, t0 = 0;
    var ease = EASES[FOCUS_EASE] || EASES.linear;
    if (raf) cancelAnimationFrame(raf);
    function step(ts) {
      if (!t0) t0 = ts;
      var t = Math.min(1, (ts - t0) / dur);
      var e = ease(t);
      render({
        x: start.x + (to.x - start.x) * e,
        y: start.y + (to.y - start.y) * e,
        w: start.w + (to.w - start.w) * e,
        h: start.h + (to.h - start.h) * e
      });
      if (t < 1) raf = requestAnimationFrame(step);
      else { raf = 0; from = to; }
    }
    raf = requestAnimationFrame(step);
  }
키보드 방향키 포커스 슬라이드 — 방향키로 포커스 값이 옮겨질 때 포커스 테두리의 위치 값이 버튼 사이 실측 좌표를 따라 이동하는 방식
새 창에서 열기

08 · 키보드 방향키 포커스 슬라이드 — 방향키로 포커스 값이 옮겨질 때 포커스 테두리의 위치 값이 버튼 사이 실측 좌표를 따라 이동하는 방식 — 측정 누적면적 8.261% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOCUS_EASE easeInOuttranslate + resize ringmeasured coord tween

09비활성 파동 차단 — 비활성 값이 켜진 버튼을 누르면 파동 확산 값이 중간에서 멈추고 흔들림 값으로 대신 반응하는 방식

비활성 값이 켜진 버튼을 눌러도 파동 확산 값은 HALT_RATIO .5 지점에서 멈추고, 대신 버튼 전체가 SHAKE_FREQ 26·DECAY 6.5 로 감쇠하는 sin 진동만큼 짧게 흔들리는 항목입니다. 두 계산은 클릭 시점에 함께 시작해 각자의 시간에 따로 멈춥니다.

    function step(ts) {
      if (!t0) t0 = ts;
      var t = (ts - t0) / 1000;
      var tt = Math.min(1, (ts - t0) / dur);

      // 리플: 0 → haltR 까지 감속 확산 후 멈춤(그 이상 안 커짐), 페이드는 확산과 별도로 진행
      var spread = haltR * (1 - Math.pow(1 - Math.min(1, tt * 1.6), 2));
      cell.ripple.style.transform = 'translate(-50%,-50%) scale(' + Math.max(0.001, spread / 4) + ')';
      cell.ripple.style.opacity = String(Math.max(0, 1 - tt));

      // 흔들림: 감쇠 진동자 — 캔버스 키프레임이 아니라 매 프레임 계산
      var shakeT = Math.min(t, SHAKE_MS / 1000);
      var dx = SHAKE_AMPLITUDE * Math.sin(SHAKE_FREQ * shakeT) * Math.exp(-SHAKE_DECAY * shakeT);
      cell.btn.style.transform = 'translate(' + dx.toFixed(2) + 'px,0)';

      if (tt < 1) cell.raf = requestAnimationFrame(step);
      else { cell.raf = 0; cell.btn.style.transform = 'translate(0,0)'; }
    }
    cell.raf = requestAnimationFrame(step);
비활성 파동 차단 — 비활성 값이 켜진 버튼을 누르면 파동 확산 값이 중간에서 멈추고 흔들림 값으로 대신 반응하는 방식
새 창에서 열기

09 · 비활성 파동 차단 — 비활성 값이 켜진 버튼을 누르면 파동 확산 값이 중간에서 멈추고 흔들림 값으로 대신 반응하는 방식 — 측정 누적면적 2.133% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SHAKE_FREQ 26 / DECAY 6.5sin decay oscillatorHALT_RATIO .5 ripple

값이 위치와 크기를 바꾸는 곳 — 활성 값·너비 값·포커스 값 기준

아홉 항목을 다시 세 축으로 겹쳐 보면 각자 다른 값이 위치와 크기를 정합니다. 활성 값이 기준인 쪽은 02(표시자 위치)·04(눌림 깊이)·09(비활성 상태에서도 반응은 남는 값)처럼, 어떤 버튼이 선택·눌림·비활성인지가 바뀔 때마다 위치나 크기 값이 다시 계산됩니다. 너비 값이 기준인 쪽은 05(그룹 전체 너비)·07(컨테이너와 라벨 너비)처럼 그릇의 크기가 버튼 수나 라벨 노출 여부를 정하고, 포커스 값이 기준인 쪽은 08 하나로 키보드 초점의 위치가 곧 테두리의 좌표가 됩니다.

나머지 01·03·06은 이 세 기준과 무관하게 자기만의 값을 씁니다. 01은 경계선의 겹침 좌표, 03은 포인터와 구분선의 거리, 06은 그룹의 방향 상태가 각자 위치·크기를 정하는 기준 값입니다.

어디서 깨지는가 — 빠른 연속 클릭, 좁은 화면, 키보드 포커스

09는 비활성 버튼을 빠르게 여러 번 누르면 매번 흔들림의 감쇠 계산이 처음부터 다시 시작돼, 앞선 흔들림이 채 잦아들기도 전에 새 흔들림이 덮어씁니다. 파동과 흔들림이 겹쳐 보이는 건 버그가 아니라 클릭마다 두 계산을 새로 시작하도록 짜인 그대로의 동작입니다.

05는 화면이 아무리 좁아져도 Math.max(1, cutoff) 때문에 최소 한 개의 버튼은 항상 보이도록 잡혀 있어서, 좁은 화면에서도 그룹 자체가 통째로 사라지지는 않습니다. 대신 남은 버튼 하나가 그룹 너비보다 실제로 더 넓게 삐져나올 수 있는 지점이기도 합니다.

08은 방향키를 아주 빠르게 눌러 이전 이동이 끝나기 전에 다음 이동이 시작되면, 포커스 테두리가 중간 지점이 아니라 이전 이동의 시작 좌표로 되돌아갔다가 다시 새 목표로 출발하는 것처럼 보일 수 있습니다. moveRing 이 진행 중인 애니메이션을 취소할 때 시작 좌표 값을 그 순간의 위치로 갱신하지 않고 이전 시작 값을 그대로 재사용하기 때문입니다.

같은 값 계산을 CSS 버튼 애니메이션 9종 — 복붙하면 되는 SCSS + React의 리플·눌림 계산과 겹쳐 보면, 그 편은 버튼 하나의 클릭 반응만 계산하고 이 편은 그 반응이 그룹 안 옆 버튼에 어떤 값을 전달하는지까지 계산한다는 차이가 있습니다. css 버튼 디자인 9종 — 캔디 그라데이션·눌림·로딩 버튼 코드와 비교하면 그 편은 그레이디언트·그림자 같은 정적인 색·깊이 값을 다루고, 이 편은 시간에 따라 바뀌는 위치·너비·포커스 값을 다룬다는 점에서 계산 축이 다릅니다. 플로팅 버튼 아이콘 버튼 패턴 9종 — 누르고 끌고 펼쳐지는 역시 버튼 하나가 스스로 펼쳐지는 값을 계산하는 쪽이라, 옆 버튼과의 관계 값을 계산하는 이 편의 09개 항목과는 기준이 갈립니다.

파일을 열어 값을 직접 바꿔 보고 싶다면, 압축 비번 he9ea98k 으로 아홉 항목의 html·scss·js 전체를 내려받을 수 있습니다.

변형 세 가지와 접근성

변형 값 느낌
슬라이드 표시자 이동 시간 늘리기 02 DAMP 18 → 9 감쇠를 18→9 로 낮추면 표시자가 목표 버튼에 바로 멈추지 않고 살짝 지나쳤다가 되돌아오는 오버슛이 남아, 이동이 더 튕기듯 보인다.
더보기 접힘 여유폭 줄이기 05 OVERFLOW_BUFFER 24 → 8 여유폭을 24→8px 로 줄이면 버튼이 더 늦게까지 펼쳐진 채 남아 있어 더보기로 접히는 시점이 화면 끝에 훨씬 가까워진다.
포커스 링 이동을 등속으로 바꾸기 08 FOCUS_EASE easeInOut → linear 이징을 easeInOut 에서 linear 로 바꾸면 포커스 링이 출발과 도착에서 늦춰지지 않고 같은 속도로 곧장 옆 버튼에 닿아, 방향키 이동이 기계적이고 또박또박한 느낌으로 바뀐다.

모션 감소 설정에 대한 대응은 항목마다 계산이 다릅니다. 01은 리플이 사라지는 시간을 420ms에서 1ms로 줄여 같은 계산 경로를 유지한 채 사실상 즉시 사라지게 하고, 02·04·07·08은 스프링·이징 보간 자체를 건너뛰어 목표 값을 즉시 대입해 순간 이동시킵니다. 03은 lerp 계수를 SMOOTH 대신 1로 바꿔 같은 프레임 안에서 목표 투명도로 바로 붙고, 05는 자동으로 좁아지던 너비를 최대값에 고정해 넘침 계산 자체가 일어나지 않게 하며, 06은 FLIP 의 되돌리기 transform 단계만 건너뛰어 레이아웃은 이미 바뀐 자리에 그대로 있으며, 09는 흔들림 계산 자체를 생략하고 파동이 멈춘 최종 위치로 바로 그립니다.

접근성 값은 04와 08 두 항목에서만 실제 role="radiogroup" · role="radio" 로 구현됩니다. ARIA group 역할 문서가 설명하듯 이 두 항목은 버튼마다 aria-checked 값을 클릭·방향키 시점에 다시 세우고, ArrowLeft·ArrowRight 로 포커스 값을 옆 버튼에 옮긴 뒤 focus({preventScroll:true}) 로 스크롤 위치를 흔들지 않고 포커스만 맞춥니다. 나머지 항목은 같은 role·aria 조합을 쓰지 않으며, 04·08의 실측 좌표 계산은 :focus-visible 로 마우스 클릭과 키보드 포커스의 테두리 표시를 구분하는 값과도 맞물립니다.

FAQ

방향키만으로 버튼 그룹을 전부 조작할 수 있나요?

04와 08 두 항목은 ArrowLeft·ArrowRight 로 눌림 값이나 포커스 값을 옆 버튼으로 옮기도록 짜여 있어, 마우스 클릭 없이 방향키만으로 값을 바꿀 수 있습니다. 나머지 항목은 클릭이나 포인터 이동으로 값이 바뀝니다.

버튼에 실제 input[type=radio] 가 있어야 라디오처럼 동작하나요?

아닙니다. 04와 08은 <button> 에 role="radio"·role="radiogroup" 과 aria-checked 값만 얹어 실제 라디오 입력 없이도 같은 접근성을 냅니다. 값의 상태만 aria 속성으로 알려 주는 방식입니다.

zip 안 코드를 그대로 붙이면 css 토글 버튼 그룹이 바로 동작하나요?

네, 아홉 항목 모두 외부 라이브러리 없이 순수 html·scss·js 로만 값을 계산하도록 만들어졌습니다. zip 안 코드를 그대로 붙이면 각 항목의 계산 함수까지 포함해 그대로 동작합니다.

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

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