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

리사이즈 핸들 9종 — 오브젝트 자유 변형 캔버스 툴킷

리사이즈 핸들 9종 가운데 하나는 회전 핸들을 원으로 끌면 선택 박스 중심을 축으로 오브젝트가 함께 돌고, 하나는 흩어진 아이콘이 버튼 한 번에 격자 칸으로 동시에 미끄러져 붙고, 하나는 방향키 넛지를 계속 붙잡을수록 이동 폭이 점점 커집니다.

캔버스 위 오브젝트 하나를 골라 옮기고 돌리고 키우는 조작을 한데 모은 툴킷으로, 항목마다 pointerdown·pointermove·pointerup·pointercancel 조합이나 click·keydown·keyup 하나로 시작합니다. 자동으로 보이는 시연 동작은 .is-demo 가 붙어 있는 동안만 돌고, 실제로 핸들을 건드리는 순간 human() 이 그 클래스를 영구히 떼어내 타이머를 전부 지웁니다.

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

아홉 개의 해부 — 계산 한 겹이 어디서 도는가

누구나 오브젝트 하나를 골라 옮기고 돌리고 키우는 동작을 기대하지만, 그 값이 어디서 나오는지는 아홉 가지가 저마다 다릅니다. Math.random 은 아홉 개 전부 쓰지 않는 결정적 계산이라 같은 조작에는 항상 같은 결과가 나옵니다. 01~07·09 는 자동 시연 루프를 requestAnimationFrame 으로 돌리지만 08 은 rAF 를 전혀 쓰지 않고 keydown·keyup 이 올 때마다 그 자리에서 좌표를 즉시 계산합니다.

실제 사용 경로 — applyPosition·applyScale·applyRotate·toggleArrange·applyZ·moveBy·generatePreview — 는 reduce 값을 아예 읽지 않아 자동 시연을 껐다 켜도 손으로 하는 조작에는 아무 영향이 없습니다.

항목 누적면적 강도_평균 움직인프레임
01 핸들 없이 통째로 드래그 이동 11.417% 126.0 14/23
02 네 모서리 핸들로 하는 비율 유지 크기 조절 14.389% 75.6 16/23
03 선택 박스 위 꼭짓점에서 뻗어나온 회전 핸들 9.46% 86.4 19/23
04 두 손가락으로 선택 오브젝트만 돌리고 키우기 9.606% 82.8 23/23
05 이동 중 배경 격자에 달라붙는 스냅 6.406% 136.0 10/23
06 흩어진 오브젝트를 한 번에 격자로 자동 정렬 14.908% 85.8 8/23
07 겹친 오브젝트를 한 칸씩 앞뒤로 보내는 순서 조정 6.113% 88.4 9/23
08 방향키로 한 픽셀씩 미세 이동 1.344% 158.8 15/23
09 선택 영역만 잘라 PNG로 내보내는 패널 10.889% 47.5 23/23

누적면적이 가장 큰 항목은 06 으로 14.908% 에 이르는데, 아홉 개 칩 전부가 동시에 격자 칸까지 이동하는 전환이라 화면 전체가 함께 움직이기 때문입니다. 가장 작은 항목은 08 로, 방향키 한 번이 옮기는 거리가 짧아 1.344% 에 그칩니다. 강도_평균이 가장 높은 항목도 08 로 158.8 까지 오르는데, 움직이는 면적 자체는 작아도 키 입력마다 픽셀이 곧바로 큰 폭으로 갱신되기 때문입니다. 가장 낮은 항목은 09 로 47.5 인데, PNG 미리보기가 옅은 페이드 한 번으로만 나타나기 때문입니다. 23 개 델타 구간 가운데 움직인프레임이 가장 촘촘한 항목은 04·09 로 나란히 23/23 이고, 가장 뜸한 항목은 06 으로 8/23 입니다 — 큰 도약 한 번과 잦은 잔움직임은 서로 다른 성질입니다.

포인터 델타를 그대로 이동 좌표에 대입한다 — 01

onPointerMove 는 pointerdown 시점의 원점(originTx·originTy)에 지금 포인터와 시작점의 차(dx·dy)를 더해 새 좌표를 만들고, applyTransform 이 그 값을 그대로 matrix(1,0,0,1,tx,ty) 에 넣어 오브젝트를 이동시킵니다.

반대쪽 모서리를 기준점 삼아 비율을 지킨다 — 02

cornerAnchorRect 는 끄는 핸들의 반대쪽 모서리 좌표를 고정 기준점으로 구하고, applyScale 이 그 기준점에서 잰 포인터 거리 비율을 scale 값으로 써서 MIN_SCALE(0.55)~MAX_SCALE(1.75) 사이로 clamp 합니다.

포인터 벡터의 절대 각도로 회전한다 — 03

angleFromPointer 는 회전하지 않는 프레임 중심에서 포인터까지의 벡터를 atan2 로 구해 절대 각도를 매 프레임 얻고, ROTATE_SNAP_DEG(15도) 배수로 반올림해 스냅한 뒤 applyRotate 에 넘깁니다.

두 포인터의 거리·각도 차이로 스케일과 회전을 함께 만든다 — 04

computeFromFingers 는 두 손가락 사이 거리(d)와 각도(a)를 처음 잡을 때의 값(baseDist·baseAngle)과 비교해 scale 은 거리비로, rotate 는 각도차로 각각 구하고 한 함수 안에서 두 값을 동시에 CSS transform 하나에 담습니다.

가장 가까운 격자값으로 반올림해 자석처럼 붙는다 — 05

nearestGrid 는 좌표를 CELL_PX 배수로 반올림해 가장 가까운 격자 교차점을 구하고, 그 거리가 CORNER_SNAP_RADIUS_PX(6px) 안이면 applyPosition 이 실제 포인터 좌표 대신 그 교차점 좌표를 그립니다.

인덱스를 격자 좌표식으로 바꿔 한 번에 애니메이션한다 — 06

gridTarget 은 인덱스를 열 수로 나눈 몫·나머지로 행·열을 구해 칸 크기를 곱한 좌표를 만들고, animateTo 가 아홉 개 칩 전부를 한 번의 rAF 루프 안에서 easeOutCubic 으로 함께 이동시킵니다.

배열 순서를 바꿔 z-index 로 그대로 반영한다 — 07

swapNeighbor·bringToFront 는 오브젝트 id 를 담은 order 배열 자체를 재정렬하고 applyZ 가 그 배열 인덱스를 z-index 로 그대로 매깁니다. 순서가 바뀐 카드는 popCard 가 POP_MS(360ms) 동안 살짝 들렸다 앉는 오버슛 트윈으로 표시합니다.

눌린 시간을 가속 배수로 바꾼다 — 08

computeStep 은 키를 누르고 있는 시간(elapsedMs)을 ACCEL_WINDOW_MS(450ms)로 나눠 가속 배수를 만들고 MAX_MULT 에서 멈춥니다. moveBy 가 KEYBOARD_NUDGE_PX(1px)에 그 배수를 곱한 만큼만 매번 좌표를 옮깁니다.

화면 변환을 캔버스 좌표계로 그대로 재현해 PNG로 굳힌다 — 09

generatePreview 는 화면의 오브젝트와 같은 각도·비율을 ctx.rotate·ctx.scale 로 캔버스 위에 그대로 재현한 뒤 roundedRectPath 로 둥근 사각형을 채우고 canvas.toDataURL 로 PNG 데이터 URL 을 만듭니다.

패턴별 코드

아홉 개 항목의 실제 계산부만 이어 붙입니다. 원본 줄 순서·들여쓰기 그대로입니다.

01핸들 없이 통째로 드래그 이동

선택된 오브젝트 몸통 아무 곳이나 눌러 끌면 테두리 선택 박스와 그 안의 오브젝트가 함께 포인터를 따라 이동하고, 놓는 순간 마지막 좌표에 그대로 멈춥니다. 캔버스 위 스티커·아이콘 배치를 옮길 때 맞습니다.

  function onPointerDown(e) {
    human();
    dragging = true;
    el.setPointerCapture(e.pointerId);
    startX = e.clientX; startY = e.clientY;
    originTx = tx; originTy = ty;
    el.classList.add('isDragging');
  }
  function onPointerMove(e) {
    if (!dragging) return;
    var dx = e.clientX - startX;
    var dy = e.clientY - startY;
    applyTransform(clamp(originTx + dx, -110, 110), clamp(originTy + dy, -55, 55));
  }
  function onPointerUp(e) {
    if (!dragging) return;
    dragging = false;
    el.classList.remove('isDragging');
    try { el.releasePointerCapture(e.pointerId); } catch (err) {}
  }
핸들 없이 통째로 드래그 이동
새 창에서 열기

01 · 핸들 없이 통째로 드래그 이동 — 측정 누적면적 11.417% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

applyTransformonPointerMoveeaseOutCubic

02네 모서리 핸들로 하는 비율 유지 크기 조절

선택 박스 네 귀퉁이에 작은 정사각 핸들이 붙어 있고, 그중 하나를 끌면 마주보는 반대쪽 귀퉁이를 기준점 삼아 가로세로 비율을 지킨 채로만 커지거나 작아집니다. 250 의 panel-corner-resize-grip — 레이아웃 패널 자체의 폭·높이를 조절하는 그립 — 과 다른 시연으로, 여긴 패널이 아니라 캔버스 위 오브젝트 하나의 테두리 핸들입니다.

  function applyScale(corner, s) {
    scale = clamp(s, MIN_SCALE, MAX_SCALE);
    subject.style.transformOrigin = ORIGIN_BY_CORNER[corner];
    subject.style.transform = 'scale(' + scale.toFixed(3) + ')';
    rp.textContent = Math.round(scale * 100);
    handles.forEach(function (h) { h.setAttribute('aria-valuenow', String(Math.round(scale * 100))); });
  }

  function human() {
    if (scene.classList.contains('is-demo')) {
      scene.classList.remove('is-demo');
      timers.forEach(function (t) { clearTimeout(t); clearInterval(t); });
      timers = [];
    }
  }

  function cornerAnchorRect(corner) {
    var r = frame.getBoundingClientRect();
    switch (corner) {
      case 'tl': return { x: r.right, y: r.bottom };
      case 'tr': return { x: r.left, y: r.bottom };
      case 'bl': return { x: r.right, y: r.top };
      default: return { x: r.left, y: r.top };
    }
  }
네 모서리 핸들로 하는 비율 유지 크기 조절
새 창에서 열기

02 · 네 모서리 핸들로 하는 비율 유지 크기 조절 — 측정 누적면적 14.389% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

applyScalecornerAnchorRecthopScale

03선택 박스 위 꼭짓점에서 뻗어나온 회전 핸들

선택 박스 상단 중앙에서 짧은 선이 뻗어나와 끝에 동그란 회전 핸들이 달려 있고, 그 점을 원 궤적으로 끌면 오브젝트가 선택 박스 중심을 축으로 그 각도만큼 돌아갑니다. 스티커·도장 오브젝트를 비스듬히 배치할 때 맞습니다.

  function applyRotate(deg) {
    angle = mod360(deg);
    rotator.style.transform = 'rotate(' + angle.toFixed(1) + 'deg)';
    rd.textContent = Math.round(angle);
    handle.setAttribute('aria-valuenow', String(Math.round(angle)));
  }

  function human() {
    if (scene.classList.contains('is-demo')) {
      scene.classList.remove('is-demo');
      timers.forEach(function (t) { clearTimeout(t); clearInterval(t); });
      timers = [];
    }
  }

  // 회전 핸들 = atan2 각 → rotate. 프레임 중심(회전하지 않는 고정 요소)에서 포인터까지의 벡터로
  // 절대 각도를 매 프레임 구하고, ROTATE_SNAP_DEG 배수로 반올림해 스냅한다.
  function angleFromPointer(e) {
    var r = frame.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    var raw = Math.atan2(e.clientY - cy, e.clientX - cx) * 180 / Math.PI;
    return mod360(raw + 90);
  }
선택 박스 위 꼭짓점에서 뻗어나온 회전 핸들
새 창에서 열기

03 · 선택 박스 위 꼭짓점에서 뻗어나온 회전 핸들 — 측정 누적면적 9.46% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

applyRotateangleFromPointermod360

04두 손가락으로 선택 오브젝트만 돌리고 키우기

터치스크린에서 오브젝트 하나가 선택된 상태로 두 손가락을 오브젝트 위에 얹고 벌리거나 오므리면 그 오브젝트만 커지거나 작아지고, 동시에 비틀면 같은 손짓 안에서 회전까지 겹쳐 일어납니다. 247 의 gesture-pinch-zoom-pan — 화면 전체를 확대·이동하는 손짓 — 과 다른 시연으로, 모바일에서 핀치 줌으로 콜라주 오브젝트 하나만 손끝으로 조정할 때 맞습니다.

  function placeFinger(el, pos) {
    var r = frame.getBoundingClientRect();
    el.style.left = (pos.x * r.width) + 'px';
    el.style.top = (pos.y * r.height) + 'px';
  }

  // 두 포인터 거리/각 → scale·rotate. 실제 두 손가락 대신 포인터 이벤트 2개(finger A·B)를
  // 각각 시뮬레이트해 그 사이 거리비·각도차를 subjectShape 에 그대로 반영한다.
  function computeFromFingers() {
    var r = frame.getBoundingClientRect();
    var ax = posA.x * r.width, ay = posA.y * r.height;
    var bx = posB.x * r.width, by = posB.y * r.height;
    var d = Math.hypot(bx - ax, by - ay);
    var a = Math.atan2(by - ay, bx - ax) * 180 / Math.PI;
    if (baseDist === null) { baseDist = d; baseAngle = a; }
    var scale = clamp(d / baseDist, MIN_SCALE, MAX_SCALE);
    var rotate = a - baseAngle;
    subject.style.transform = 'translate(-50%,-50%) rotate(' + rotate.toFixed(1) + 'deg) scale(' + scale.toFixed(3) + ')';
    rp.textContent = Math.round(scale * 100);
    rr.textContent = Math.round(rotate);
    placeFinger(fingerA, posA);
    placeFinger(fingerB, posB);
  }
두 손가락으로 선택 오브젝트만 돌리고 키우기
새 창에서 열기

04 · 두 손가락으로 선택 오브젝트만 돌리고 키우기 — 측정 누적면적 9.606% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

computeFromFingersplaceFingerbindFinger

05이동 중 배경 격자에 달라붙는 스냅

오브젝트를 드래그하는 동안 화면에 옅게 깔린 배경 격자의 교차점에 오브젝트 모서리가 일정 거리 안으로 들어오면 손을 놓지 않아도 그 교차점 위치로 살짝 끌려가 붙습니다. 250 의 panel-snap-guides — 다른 패널 가장자리에 맞춰 나타나는 동적 정렬선 — 과 다른 시연으로, 여긴 다른 오브젝트가 아니라 고정된 배경 격자 한 가지에만 붙습니다.

  // 오브젝트 모서리(좌상단)를 가장 가까운 격자 교차점(CELL_PX 배수)과 비교해, 그 거리가
  // CORNER_SNAP_RADIUS_PX 안이면 손을 놓기 전에도 교차점으로 스냅한다 — 다른 오브젝트가 아니라
  // 고정된 배경 격자 한 가지에만 붙는다(250 의 panel-snap-guides 와 다른 시연).
  function nearestGrid(v) { return Math.round(v / CELL_PX) * CELL_PX; }

  function applyPosition(rawX, rawY) {
    var nx = nearestGrid(rawX);
    var ny = nearestGrid(rawY);
    var d = Math.hypot(rawX - nx, rawY - ny);
    var snapped = d <= CORNER_SNAP_RADIUS_PX;
    tx = clamp(snapped ? nx : rawX, 0, frameW - 44);
    ty = clamp(snapped ? ny : rawY, 0, frameH - 32);
    draggable.style.transform = 'matrix(1,0,0,1,' + tx.toFixed(1) + ',' + ty.toFixed(1) + ')';
    if (snapped) {
      guideDot.style.transform = 'matrix(1,0,0,1,' + nx.toFixed(1) + ',' + ny.toFixed(1) + ')';
      guideDot.classList.add('isActive');
    } else {
      guideDot.classList.remove('isActive');
    }
    rs.textContent = snapped ? 'snap' : 'free';
    draggable.classList.toggle('isSnapped', snapped);
  }
이동 중 배경 격자에 달라붙는 스냅
새 창에서 열기

05 · 이동 중 배경 격자에 달라붙는 스냅 — 측정 누적면적 6.406% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

nearestGridapplyPositionhopTo

06흩어진 오브젝트를 한 번에 격자로 자동 정렬

버튼 하나를 누르면 화면 여기저기 흩어져 있던 오브젝트들이 각자 있던 자리에서 동시에 출발해 행과 열이 맞는 격자 칸 위치로 미끄러져 이동하며 줄을 맞춥니다. 무드보드에 흩어둔 아이콘을 발표 직전 정리할 때 맞습니다.

  function animateTo(targets, onDone) {
    var from = current.map(function (p) { return { x: p.x, y: p.y }; });
    var start = performance.now();
    function step(now) {
      var p = Math.min(1, (now - start) / HOP_MS);
      var e = easeOutCubic(p);
      for (var i = 0; i < chips.length; i++) {
        current[i].x = from[i].x + (targets[i].x - from[i].x) * e;
        current[i].y = from[i].y + (targets[i].y - from[i].y) * e;
      }
      paint();
      if (p < 1) requestAnimationFrame(step);
      else if (onDone) onDone();
    }
    requestAnimationFrame(step);
  }

  function toggleArrange() {
    arranged = !arranged;
    var targets = arranged ? chips.map(function (_, i) { return gridTarget(i); }) : SCATTER;
    status.textContent = arranged ? '정렬됨' : '흩어짐';
    btn.setAttribute('aria-pressed', String(arranged));
    animateTo(targets);
  }
흩어진 오브젝트를 한 번에 격자로 자동 정렬
새 창에서 열기

06 · 흩어진 오브젝트를 한 번에 격자로 자동 정렬 — 측정 누적면적 14.908% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

gridTargetanimateTotoggleArrange

07겹친 오브젝트를 한 칸씩 앞뒤로 보내는 순서 조정

겹쳐 있는 오브젝트 중 뒤에 깔린 하나를 선택해 앞으로 보내기를 누르면 그 오브젝트가 가리고 있던 다른 오브젝트 위로 즉시 넘어와 겹침 레이어 순서가 화면에서 바로 뒤바뀝니다. 카드 뒷면에 깔린 스티커를 앞으로 꺼낼 때 맞습니다.

  function bringToFront(id) {
    var idx = order.indexOf(id);
    order.splice(idx, 1);
    order.push(id);
    selectedId = id;
    applyZ();
    popCard(cards[selectedId]);
  }

  // 앞으로 온 카드가 살짝 들렸다 앉는 팝: z 변화를 눈에 보이게 하는 산술 트윈(easeOutBack 오버슛).
  function popCard(card) {
    if (popRaf) cancelAnimationFrame(popRaf);
    var start = performance.now();
    function step(now) {
      var p = Math.min(1, (now - start) / POP_MS);
      var lift = (1 - Math.abs(2 * p - 1)) * 0.1;
      card.style.setProperty('--lift', lift.toFixed(3));
      if (p < 1) popRaf = requestAnimationFrame(step);
      else { card.style.setProperty('--lift', '0'); popRaf = 0; }
    }
    popRaf = requestAnimationFrame(step);
  }
겹친 오브젝트를 한 칸씩 앞뒤로 보내는 순서 조정
새 창에서 열기

07 · 겹친 오브젝트를 한 칸씩 앞뒤로 보내는 순서 조정 — 측정 누적면적 6.113% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

swapNeighborbringToFrontpopCard

08방향키로 한 픽셀씩 미세 이동

오브젝트를 선택한 채로 방향키를 한 번 누를 때마다 오브젝트가 그 방향으로 아주 작은 고정 거리만큼만 움직이고, 꾹 눌러 계속 붙잡고 있으면 처음 몇 번은 같은 간격으로 가다가 이동 폭이 점점 커집니다. 정렬이 어긋난 오브젝트를 픽셀 단위로 맞출 때 맞습니다.

  function computeStep(elapsedMs) {
    var mult = 1 + Math.min(Math.floor(elapsedMs / ACCEL_WINDOW_MS), MAX_MULT - 1);
    return { step: Math.max(1, Math.round(KEYBOARD_NUDGE_PX * mult)), mult: mult };
  }

  function moveBy(dx, dy, mult) {
    tx = clamp(tx + dx, 0, frameW - 44);
    ty = clamp(ty + dy, 0, frameH - 32);
    draggable.style.transform = 'matrix(1,0,0,1,' + tx.toFixed(0) + ',' + ty.toFixed(0) + ')';
    rm.textContent = String(mult);
  }
방향키로 한 픽셀씩 미세 이동
새 창에서 열기

08 · 방향키로 한 픽셀씩 미세 이동 — 측정 누적면적 1.344% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

computeStepmoveBysimulateHold

09선택 영역만 잘라 PNG로 내보내는 패널

버튼을 누르면 선택 박스로 둘러싼 영역만큼만 캔버스에 다시 그려 PNG 데이터로 인코딩하고, 그 결과를 내보내기 패널의 미리보기 이미지로 바로 띄웁니다. 실제 파일 저장·다운로드·클립보드 기록은 하지 않는 미리보기 생성까지의 시뮬레이션입니다.

  // 오브젝트를 canvas.toDataURL 로 내보내기: 화면의 subjectShape 와 같은 회전·비율을 ctx 변환으로
  // 그대로 재현한 뒤(대칭 산술), roundRect 경로 + gradient 를 채우고 PNG 데이터 URL 을 만든다.
  // 실제 파일 저장·다운로드·클립보드 기록은 하지 않는다 — 미리보기 이미지 생성까지만 시뮬레이션한다.
  function generatePreview() {
    var w = canvas.width, h = canvas.height;
    ctx.clearRect(0, 0, w, h);
    ctx.save();
    ctx.translate(w / 2, h / 2);
    ctx.rotate(angle * Math.PI / 180);
    ctx.scale(scale, scale);
    var grad = ctx.createLinearGradient(-34, -34, 34, 34);
    grad.addColorStop(0, FILL_A);
    grad.addColorStop(1, FILL_B);
    ctx.fillStyle = grad;
    roundedRectPath(ctx, -34, -34, 68, 68, 14);
    ctx.fill();
    ctx.restore();
    img.src = canvas.toDataURL('image/png');
    status.textContent = '생성됨';
  }
선택 영역만 잘라 PNG로 내보내는 패널
새 창에서 열기

09 · 선택 영역만 잘라 PNG로 내보내는 패널 — 측정 누적면적 10.889% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

roundedRectPathgeneratePreviewautoPreviewLoop

값이 UI를 바꾸는 곳 — 스냅 반경·회전 각도 스냅·넛지 가속

상수 하나를 바꾸는 것만으로 핸들이 손에 붙는 느낌은 크게 갈립니다. 02 는 MIN_SCALE(0.55)·MAX_SCALE(1.75)가 모서리 핸들로 줄이고 키울 수 있는 배율의 하한·상한을 정하고, 04 는 같은 이름이지만 MAX_SCALE(1.9)로 핀치 줌 배율의 상한이 더 넓습니다. 03 은 ROTATE_SNAP_DEG(15도)가 회전 핸들을 끌 때 튀는 각도 단위를, 05 는 CELL_PX(20px)·CORNER_SNAP_RADIUS_PX(6px)가 격자 칸 크기와 스냅이 걸리는 자석 반경을 정합니다. 07 은 POP_MS(360ms)가 순서가 바뀐 카드가 들렸다 앉는 팝 애니메이션의 길이를, 08 은 KEYBOARD_NUDGE_PX(1px)·ACCEL_WINDOW_MS(450ms)·MAX_MULT 가 방향키 넛지의 기본 이동량과 가속 속도를 정합니다. 캔버스 오브젝트 변형 툴킷을 처음 붙이는 자리라면 01 의 이동 한계값이나 08 의 KEYBOARD_NUDGE_PX 처럼 상수 하나로 체감 속도를 조절하는 항목이 가장 손대기 쉽습니다.

이웃 가지

같은 캔버스 오브젝트 축에서도 초점은 갈립니다. 패널 자체의 폭·높이를 조절하는 CSS 화면 분할 패널 9종 — 드래그로 폭이 따라오는 과 달리, 이 편의 02 는 패널이 아니라 캔버스 위 오브젝트 하나의 테두리 핸들만 다룹니다. 업로드 없이 브라우저에서 잘라내는 이미지 크롭 기능 9종 — 업로드 없이 브라우저에서 와도 이어지지만, 이 편의 09 는 잘라낸 결과를 저장·다운로드하지 않고 미리보기 생성까지만 시뮬레이션합니다. 화면 전체를 확대·이동하는 모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI 와 비교하면, 이 편의 04 는 화면이 아니라 선택된 오브젝트 하나에만 두 손가락 조작이 적용됩니다.

어디서 깨지는가 — 접근성·reduced-motion·키보드 포커스

그리드에 지금 놓인 오브젝트 아홉 개 대신 실제 프로젝트에 옮겨 쓸 vanilla 코드를 확인하려면 zip 비밀번호 8eh278nq 를 입력해 열어보면 됩니다.

실제로 겪은 함정은 09 의 내보내기 캔버스 해상도입니다. <canvas id="canvas" width="96" height="96"> 처럼 폭·높이를 고정값으로만 주고 devicePixelRatio 를 반영하지 않아, generatePreview 가 그리는 PNG 가 고밀도 디스플레이에서는 96×96 픽셀 그대로 저장되어 미리보기 이미지를 확대하면 흐릿하게 보입니다. 실제 서비스에 붙일 때는 캔버스 폭을 96 * devicePixelRatio 로 늘리고 ctx.scale(devicePixelRatio, devicePixelRatio) 로 보정해야 이 문제가 사라집니다.

reduce 상태에서 각자 무엇을 멈추는가

01~07·09 는 prefers-reduced-motion: reduce 가 걸리면 자동 시연의 requestAnimationFrame 루프가 아예 시작되지 않아 오브젝트가 처음 자리에서 멈춰 있습니다. 08 은 애초에 자동 시연에도 rAF 가 없어 reduce 값과 무관하게 항상 정지 상태입니다. 아홉 항목 전부 pointerdown·click·keydown 같은 실제 입력이 들어오면 reduce 여부와 상관없이 applyPosition·applyScale·applyRotate·toggleArrange·applyZ·moveBy·generatePreview 가 즉시 값을 반영합니다.

키보드·스크린리더로 확인할 것

02 는 네 모서리 핸들마다 aria-valuemin·aria-valuemax·aria-valuenow 로 확대율을, 03 은 회전 핸들 하나에 회전각을 같은 속성으로 읽어줍니다. 04 는 손가락마다 슬라이더 값을, 07 은 aria-live="polite" 영역과 카드별 라벨로 어떤 카드가 앞으로 왔는지 알리고, 08 은 aria-label 로 가속 동작 자체를 설명합니다. 포인터 이벤트 표준은 MDN Pointer events 문서를, 키보드 입력 처리는 MDN keydown 이벤트 문서를 함께 참고하면 됩니다.

변형 세 가지와 접근성

변형 값 체감
모서리 스냅 자석 반경 넓히기 05 CORNER_SNAP_RADIUS_PX 6 → 16 자석 반경이 6px 에서 16px 로 넓어지면 오브젝트가 격자 교차점에서 더 멀리 떨어진 상태에서도 스냅이 걸려, 자유롭게 움직이는 구간이 좁아지고 더 쉽게 달라붙는다.
회전 핸들 각도 스냅 간격 좁히기 03 ROTATE_SNAP_DEG 15 → 5 각도 스냅 간격이 15도에서 5도로 좁아지면 핸들을 돌릴 때 한 번에 튀는 각도가 작아져 더 세밀하고 촘촘한 회전 조절이 가능해진다.
키보드 넛지 이동 단위 키우기 08 KEYBOARD_NUDGE_PX 1 → 8 키 한 번당 기본 이동량이 1px 에서 8px 로 커지면 처음 눌렀을 때부터 더 크게 움직여 정밀한 한 픽셀 맞추기보다는 빠른 큰 폭 이동에 맞는 방식이 된다.

세 변형 모두 CSS 값 하나만 바뀔 뿐, pointerdown·keydown 리스너 구성이나 aria-valuemin·aria-valuemax·aria-valuenow 갱신 흐름은 그대로입니다. 스크린리더가 읽는 role·label 도 수정 전후로 달라지지 않으므로 어떤 조합을 골라도 접근성 지원 범위는 동일합니다.

FAQ

05의 그리드 스냅 반경은 코드에서 어떻게 조절하나요?

nearestGrid 가 참조하는 CORNER_SNAP_RADIUS_PX 값을 늘리면 오브젝트가 격자 교차점에서 더 멀리 있어도 달라붙고, 줄이면 자석이 걸리는 범위가 좁아져 손을 놓기 직전까지 자유롭게 움직입니다. 이 글의 변형 1 이 6px 를 16px 로 넓힌 예시입니다.

09에서 만든 PNG를 실제로 저장하거나 다운로드할 수 있나요?

지금 데모의 generatePreview 는 canvas.toDataURL 로 미리보기 이미지를 만들어 내보내기 패널에 띄우는 시뮬레이션까지만 하고, 실제 파일 저장이나 다운로드 트리거는 붙어 있지 않습니다. 다운로드 버튼으로 이어 붙이려면 그 데이터 URL 을 a 태그의 download 속성에 연결하면 됩니다.

reduced-motion을 켠 화면에서도 아홉 가지 조작이 전부 되나요?

네, 됩니다. reduce 상태가 막는 건 .is-demo 가 붙어 있는 동안의 자동 시연 루프뿐이고, pointerdown·keydown 같은 실제 입력 경로는 reduce 값을 아예 읽지 않아 아홉 가지 조작 모두 그대로 동작합니다.

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

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