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

드래그 앤 드롭 UI 리스트 — 목표 인덱스 계산을 얹은 9종

포인터가 놓인 위치에서 목표 인덱스를 계산해 리스트·칸반·그리드가 자리를 바꾸고, 롱프레스·Alt 화살표·가장자리 자동 스크롤까지 — 드래그로 순서 바꾸는 리스트 9종, 코드는 비밀번호 zip으로 드립니다.

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

데모

여섯 항목(02·04·05·07·08·09)은 같은 pointerdown → pointermove → pointerup 세 단계에서 시작해 그 사이 포인터 좌표를 어떤 값으로 바꾸는지만 다르게 계산하고, 01·03·06 은 클릭 한 번이나 Alt+화살표 로 같은 계산을 단번에 실행합니다. 사람이 손대지 않을 때는 정해진 시나리오(어느 행을 들어 어디로 옮길지)가 자동으로 반복 재생되고, 02 칸반과 04 손잡이 전용은 한 시나리오가 끝난 뒤 다음 시나리오가 시작되기 전까지 잠깐 멈춰 있는 구간이 있어서, 다른 항목보다 측정값이 낮게 나오는 게 자연스럽습니다.

실측 한눈에

항목 초점 누적면적(%) 강도평균 움직인프레임(23 기준)
01 FLIP 리스트 좌표차를 역산해 되돌렸다가 풂 6.12 104.5 12
02 칸반 카드 y 중점 비교로 삽입 인덱스 역산 25.11 67.7 7
03 그리드 재배치 좌표를 트랙 폭·행 높이로 나눈 몫 21.30 65.0 12
04 손잡이 전용 목표 위치에 매 프레임 22%씩 수렴 34.74 42.0 7
05 롱프레스 판정 대기 시간+이동 임계값 판정 21.08 97.0 16
06 키보드 재정렬 Alt+화살표로 인덱스 swap 15.12 85.5 8
07 가장자리 자동 스크롤 남은 거리를 속도로 정규화 41.66 182.5 12
08 고스트 프리뷰 프레임 간 dx 를 각도로 변환 25.04 140.0 17
09 자리 표시자 행 높이를 --h 변수로 이전 25.85 114.3 13

정지 구간이 있는 두 항목은 결함이 아니다

02 와 04 는 움직인프레임이 각 7 로 아홉 개 중 가장 적은데, 이는 한 번의 드래그 시나리오(카드 하나를 다른 컬럼으로, 행 하나를 다른 자리로)가 끝나고 다음 반복까지 기다리는 구간이 있기 때문입니다. 03 처럼 460ms 마다 계속 자리를 바꾸는 항목과 같은 잣대로 "덜 움직인다"고 읽으면 안 되는 이유가 여기 있습니다 — 강도평균은 07 가장자리 자동 스크롤이 182.5 로 가장 세고 04 손잡이 전용이 42.0 으로 가장 낮지만, 두 값 모두 화면이 정지해 보이지 않는다는 기준선은 함께 통과합니다.

드래그 재정렬의 해부 — pointerdown·move·up 세 단계와 setPointerCapture

아홉 항목이 공유하는 계산의 뼈대는 동일합니다 — 무엇을 들지 정하고, 목표 인덱스를 계속 다시 구하고, 그 인덱스를 배열에 확정하는 세 단계입니다. 02·04·05·07·08·09 는 이 세 단계를 포인터가 내려가는·움직이는·떼는 순간에 그대로 걸고, 01·03·06 은 클릭 한 번이나 Alt+화살표 로 같은 세 단계를 압축해 실행합니다.

pointerdown 이 잡는 것은 행 전체가 아니라 시작 좌표다

pointerdown 핸들러는 클릭된 요소가 어느 행(또는 카드)인지 찾고, 그 순간의 포인터 좌표와 행의 getBoundingClientRect().top(또는 left) 차이를 오프셋으로 저장합니다. 04·05·07·08·09 는 이 오프셋을 계속 들고 있다가 이후 포인터가 움직일 때마다 "포인터 위치 − 오프셋"으로 행의 목표 위치를 구합니다.

pointermove 는 매 프레임 목표 인덱스를 다시 계산한다

포인터가 움직일 때마다 지금 든 요소를 뺀 나머지 요소들의 좌표를 다시 재서, 포인터가 어느 요소의 중점을 지났는지로 삽입 위치를 다시 정합니다. 01·03·06 처럼 클릭 한 번으로 끝나는 항목도 내부적으로는 같은 비교(중점 앞·뒤)를 한 번만 실행할 뿐, 계산 자체는 같습니다.

pointerup 이 최종 자리를 배열에 확정한다

손을 떼는 순간 그동안 미리보기로만 옮겨 뒀던 요소를 실제 DOM 순서(또는 순서 배열)에 반영하고, 임시로 걸어 둔 transform·클래스를 정리합니다. 02 칸반처럼 같은 컬럼 안에서는 미리보기만 옮기고 컬럼이 실제로 바뀌는 순간에만 배열을 스플라이스하는 구조도 이 확정 시점을 늦춰 불필요한 재계산을 줄입니다.

setPointerCapture 가 없으면 손끝이 원소를 벗어나는 순간 이벤트가 끊긴다

마우스는 버튼을 누른 채 창 밖으로 나가도 pointermove 가 계속 오지만, 터치는 손끝이 처음 닿은 요소의 경계를 벗어나면 그 요소가 더 이상 이벤트를 받지 못할 수 있습니다. setPointerCapture(e.pointerId) 를 pointerdown 시점에 걸어 두면 이후 모든 포인터 이벤트가 그 요소로 강제 전달되어, 손끝이 리스트 밖으로 잠깐 나가도 드래그가 끊기지 않습니다.

아홉 리스트 단계별 코드

01FLIP 리스트 — 이동 전·후 좌표 차이를 계산해 되돌린 뒤 풀어주는 재정렬

행을 클릭하면 순서 배열이 바뀌고, 바뀌기 전 모든 행의 getBoundingClientRect().top 을 재 뒀다가 DOM 을 새 순서로 다시 붙인 뒤 좌표 차이를 구해 그만큼 transform 으로 즉시 되돌립니다. 다음 프레임에 그 값을 0 으로 풀면 행들이 실제로 미끄러져 온 것처럼 보입니다.

getBoundingClientRect 두 번으로 이동 전후 차이를 구한다

function applyOrder(order) {
    var rs = rows();
    var first = rs.map(function (r) { return r.getBoundingClientRect().top; });
    for (var i = 0; i < order.length; i++) list.appendChild(rs[order[i]]);
    rs = rows();
    var maxAbs = 0;
    for (var j = 0; j < rs.length; j++) {
      var dy = first[order[j]] - rs[j].getBoundingClientRect().top; // invert
      if (Math.abs(dy) > Math.abs(maxAbs)) maxAbs = dy;
      if (dy !== 0) {
        rs[j].style.transition = 'none';
        rs[j].style.transform = 'translateY(' + dy + 'px)';
      }
    }
    deltaEl.textContent = 'dy ' + Math.round(maxAbs) + 'px';
    requestAnimationFrame(function () {
      rs.forEach(function (r) {
        r.style.transition = 'transform ' + DUR + 'ms cubic-bezier(.2,.8,.2,1)';
        r.style.transform = 'translateY(0)';
      });
      setTimeout(function () { rs.forEach(function (r) { r.style.transition = ''; }); }, DUR + 40);
    });
    return rs;
  }
FLIP 리스트 — 이동 전·후 좌표 차이를 계산해 되돌린 뒤 풀어주는 재정렬
새 창에서 열기

01 · FLIP 리스트 — 이동 전·후 좌표 차이를 계산해 되돌린 뒤 풀어주는 재정렬 — 측정 누적면적 6.12% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getBoundingClientRecttransformtransition

02칸반 카드 — 포인터 y 비율로 목표 컬럼·삽입 인덱스를 역산하는 계산

카드를 컬럼 사이로 옮기면, 화면에 있는 모든 카드의 좌표를 이동 전에 한 번 재고 DOM 을 옮긴 뒤 다시 재서 그 차이를 translate 로 되돌렸다가 풉니다. 이런 식으로 만든 것이 흔히 말하는 드래그 앤 드롭 구현입니다 — 라이브러리 없이 포인터 이벤트만으로 끝납니다.

포인터 y 가 카드 중점을 넘는 순간 인덱스가 바뀐다

function moveCard(card, fromList, toList, index) {
    var all = Array.prototype.slice.call(document.querySelectorAll('.kb__card'));
    var first = all.map(function (c) { return c.getBoundingClientRect(); });
    var target = toList.children[index] || null;
    toList.insertBefore(card, target);
    refreshCounts();
    all.forEach(function (c, i) {
      var r = c.getBoundingClientRect();
      var dx = first[i].left - r.left, dy = first[i].top - r.top;
      if (dx !== 0 || dy !== 0) {
        c.style.transition = 'none';
        c.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
        requestAnimationFrame(function () {
          c.style.transition = 'transform 260ms cubic-bezier(.2,.8,.2,1)';
          c.style.transform = 'translate(0,0)';
        });
      }
    });
  }
칸반 카드 — 포인터 y 비율로 목표 컬럼·삽입 인덱스를 역산하는 계산
새 창에서 열기

02 · 칸반 카드 — 포인터 y 비율로 목표 컬럼·삽입 인덱스를 역산하는 계산 — 측정 누적면적 25.115% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermovegetBoundingClientRecttransition

03그리드 재배치 — 포인터 좌표에서 행·열 인덱스를 역산하는 2차원 계산

3열 그리드에서는 포인터 x·y 를 각각 열 폭·행 높이로 나눈 몫이 곧 열·행 인덱스가 되고, 이 두 값을 row*열수+col 로 펴서 1차원 순서 배열을 스플라이스합니다. 클릭한 칸과 목표 칸이 같으면(from === to) 아무 것도 하지 않고 조용히 끝냅니다.

좌표를 트랙 폭으로 나눈 몫이 열과 행이 된다

function applyMove(from, to) {
    if (from === to || to < 0 || to >= order.length) return;
    var first = CELLS.map(function (c) {
      var r = c.getBoundingClientRect();
      return { x: r.left, y: r.top };
    });
    order.splice(to, 0, order.splice(from, 1)[0]);
    order.forEach(function (ci) { board.appendChild(CELLS[ci]); });
    CELLS.forEach(function (c) {
      var r = c.getBoundingClientRect();
      var me = first[CELLS.indexOf(c)];
      var dx = me.x - r.left, dy = me.y - r.top;
      if (dx !== 0 || dy !== 0) {
        c.style.transition = 'none';
        c.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
        requestAnimationFrame(function () {
          c.style.transition = 'transform 240ms cubic-bezier(.2,.8,.2,1)';
          c.style.transform = 'translate(0,0)';
        });
      }
    });
  }
그리드 재배치 — 포인터 좌표에서 행·열 인덱스를 역산하는 2차원 계산
새 창에서 열기

03 · 그리드 재배치 — 포인터 좌표에서 행·열 인덱스를 역산하는 2차원 계산 — 측정 누적면적 21.295% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CSS custom propertiesgetBoundingClientRecttransition

04손잡이 전용 — 작은 핸들에만 포인터캡처를 걸어 행 나머지를 살리는 분리

pointerdown 을 행 전체가 아니라 손잡이 아이콘에만 걸어 두면, 같은 행 안 버튼·텍스트는 그대로 클릭·선택할 수 있습니다. 든 행은 포인터 위치로 곧장 튀지 않고, 매 프레임 목표 위치의 22%씩 다가가는 값을 --ty 로 그려 관성처럼 보입니다.

매 프레임 22% 씩 목표에 다가가는 값이 관성이다

function beginDrag(row, clientY) {
    var r = row.getBoundingClientRect();
    row.classList.add('ho__row--drag');
    drag = { row: row, offsetInRow: clientY - r.top, pos: 0, target: 0 };
    tick();
  }

  function moveDrag(clientY) {
    if (!drag) return;
    var listRect = list.getBoundingClientRect();
    drag.target = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
    reflow(drag.row, clientY);
  }

  function tick() {
    if (!drag) return;
    drag.pos += (drag.target - drag.pos) * LERP;
    drag.row.style.setProperty('--ty', drag.pos.toFixed(2) + 'px');
    drag._raf = requestAnimationFrame(tick);
  }
손잡이 전용 — 작은 핸들에만 포인터캡처를 걸어 행 나머지를 살리는 분리
새 창에서 열기

04 · 손잡이 전용 — 작은 핸들에만 포인터캡처를 걸어 행 나머지를 살리는 분리 — 측정 누적면적 34.737% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

setPointerCapturepointerdownCSS custom properties

05롱프레스 판정 — 대기 시간과 이동 임계값으로 스크롤과 드래그를 가르는 계산

pointerdown 에서 250ms 타이머를 걸어 두고, 그 안에 포인터가 8px 넘게 움직이면 스크롤 의도로 보고 타이머를 취소합니다. 타이머가 끝까지 살아남아야만 그 행이 드래그로 바뀝니다.

250ms 안에 8px 넘게 움직이면 타이머가 취소된다

function pressTick() {
    if (!press) return;
    var el = press.pressStart === null ? 0 : performance.now() - press.pressStart;
    stateEl.textContent = 'press ' + Math.min(HOLD_MS, Math.round(el)) + 'ms';
    press.raf = requestAnimationFrame(pressTick);
  }

  function startPress(row, x, y) {
    cancelPress();
    press = { row: row, startX: x, startY: y, pressStart: performance.now(), raf: null, timer: null };
    press.timer = setTimeout(function () { activate(row, y); }, HOLD_MS);
    pressTick();
  }

  function cancelPress() {
    if (!press) return;
    if (press.timer) clearTimeout(press.timer);
    if (press.raf) cancelAnimationFrame(press.raf);
    press = null;
  }
롱프레스 판정 — 대기 시간과 이동 임계값으로 스크롤과 드래그를 가르는 계산
새 창에서 열기

05 · 롱프레스 판정 — 대기 시간과 이동 임계값으로 스크롤과 드래그를 가르는 계산 — 측정 누적면적 21.082% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

setTimeoutpointermovepointercancel

06키보드 재정렬 — Alt+화살표로 배열 인덱스를 옮기고 aria-live 로 알리는 계산

포커스된 행에서 Alt+↑·Alt+↓ 를 누르면 배열의 현재 인덱스와 이웃 인덱스가 자리를 바꾸고, 그 결과가 aria-live 영역에 몇 번째로 옮겨졌는지로 읽힙니다. 마우스 없이도 01~05 와 같은 재배치를 전부 할 수 있어야 한다는 원칙을 채우는 항목입니다.

Alt 화살표가 배열의 이웃 인덱스와 자리를 바꾼다

function flipSwap(row, dir) {
    var arr = rows();
    var i = arr.indexOf(row);
    var j = i + dir;
    if (j < 0 || j >= arr.length) return i;
    var first = arr.map(function (r) { return r.getBoundingClientRect(); });
    if (dir > 0) list.insertBefore(arr[j], row);
    else list.insertBefore(row, arr[j]);
    arr.forEach(function (r, k) {
      var rr = r.getBoundingClientRect();
      var dy = first[k].top - rr.top;
      if (dy !== 0) {
        r.style.transition = 'none';
        r.style.transform = 'translateY(' + dy + 'px)';
        requestAnimationFrame(function () {
          r.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
          r.style.transform = 'translateY(0)';
        });
      }
    });
    return j;
  }
키보드 재정렬 — Alt+화살표로 배열 인덱스를 옮기고 aria-live 로 알리는 계산
새 창에서 열기

06 · 키보드 재정렬 — Alt+화살표로 배열 인덱스를 옮기고 aria-live 로 알리는 계산 — 측정 누적면적 15.117% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

keydownaria-liveCSS custom properties

07가장자리 자동 스크롤 — 거리를 속도로 매핑하는 rAF 루프

컨테이너 위·아래 40px 밴드 안에 포인터가 들어오면, 가장자리까지 남은 거리를 0~1 로 정규화해 초당 480px 최대 속도에 곱한 값을 매 프레임 scrollBy 로 넘깁니다. 밴드를 벗어나면 그 즉시 속도가 0 이 됩니다.

가장자리까지 남은 거리가 0에 가까울수록 속도가 붙는다

function edgeFactor(pointerY, viewRect) {
    var topDist = pointerY - viewRect.top;
    var botDist = viewRect.bottom - pointerY;
    if (topDist >= 0 && topDist < EDGE_PX) return -(1 - topDist / EDGE_PX);
    if (botDist >= 0 && botDist < EDGE_PX) return (1 - botDist / EDGE_PX);
    return 0;
  }

  function tick(now) {
    if (!drag) { raf = null; stateEl.textContent = 'speed 0px/s'; return; }
    var dt = lastT ? (now - lastT) / 1000 : 0;
    lastT = now;
    var viewRect = view.getBoundingClientRect();
    var f = edgeFactor(drag.pointerY, viewRect);
    var speed = f * MAX_SPEED;
    if (speed !== 0 && dt > 0) view.scrollBy(0, speed * dt);
    stateEl.textContent = 'speed ' + Math.round(Math.abs(speed)) + 'px/s';
    raf = requestAnimationFrame(tick);
  }
가장자리 자동 스크롤 — 거리를 속도로 매핑하는 rAF 루프
새 창에서 열기

07 · 가장자리 자동 스크롤 — 거리를 속도로 매핑하는 rAF 루프 — 측정 누적면적 41.662% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramescrollBygetBoundingClientRect

08고스트 프리뷰 — 이동 속도를 기울기로 바꾸는 잔상 복제본

든 행이 포인터를 따라가는 동안, 이번 프레임과 이전 프레임의 x 좌표 차이를 각도로 바꿔 --rot 에 담아 살짝 기울입니다. 손이 멈추면 목표 각도가 매 프레임 0.82 배씩 줄어 자연히 수평으로 가라앉습니다.

프레임 간 x 이동량이 회전 각도로 바뀐다

function tick() {
    if (!drag) return;
    drag.angle += (drag.target - drag.angle) * LERP;
    drag.row.style.setProperty('--rot', drag.angle.toFixed(2) + 'deg');
    stateEl.textContent = 'angle ' + Math.round(drag.angle) + '°';
    drag.target *= 0.82; // 매 프레임 목표를 줄여 이동이 멈추면 0으로 가라앉는다
    drag.raf = requestAnimationFrame(tick);
  }
  function moveDrag(clientX, clientY) {
    if (!drag) return;
    var listRect = list.getBoundingClientRect();
    var ty = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
    drag.row.style.setProperty('--ty', ty.toFixed(1) + 'px');
    var dx = clientX - drag.lastX;
    drag.lastX = clientX;
    var t = dx * K;
    if (t > MAX_ANGLE) t = MAX_ANGLE;
    if (t < -MAX_ANGLE) t = -MAX_ANGLE;
    drag.target = t;
    reflow(drag.row, clientY);
  }
고스트 프리뷰 — 이동 속도를 기울기로 바꾸는 잔상 복제본
새 창에서 열기

08 · 고스트 프리뷰 — 이동 속도를 기울기로 바꾸는 잔상 복제본 — 측정 누적면적 25.037% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermovetransform: rotatetransition

09자리 표시자 — 들어올린 행의 높이를 옮겨 담아 부풀리는 계산

행을 들어올리면 그 행의 getBoundingClientRect().height 를 읽어 자리 표시자의 --h 커스텀 프로퍼티에 담아 두고, 표시자가 정확히 같은 크기의 빈 자리를 대신 차지합니다. 이렇게 완성한 아홉 개를 붙이면, 따로 드래그앤드롭 라이브러리를 새로 받아 올 필요가 없습니다.

든 행의 높이를 --h 변수에 담아 자리를 비워둔다

function beginDrag(row, clientY) {
    var r = row.getBoundingClientRect();
    var listRect = list.getBoundingClientRect();
    placeholder.style.setProperty('--h', r.height + 'px');
    list.insertBefore(placeholder, row);
    row.classList.add('dd__row--drag');
    row.style.setProperty('--rowTop', (r.top - listRect.top) + 'px');
    row.style.setProperty('--rowW', r.width + 'px');
    row.style.setProperty('--ty', '0px');
    drag = { row: row, offsetInRow: clientY - r.top, listRect: listRect };
    var idx = Array.prototype.indexOf.call(list.children, placeholder);
    stateEl.textContent = 'slot ' + (idx + 1);
  }
자리 표시자 — 들어올린 행의 높이를 옮겨 담아 부풀리는 계산
새 창에서 열기

09 · 자리 표시자 — 들어올린 행의 높이를 옮겨 담아 부풀리는 계산 — 측정 누적면적 25.853% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

transitionCSS custom propertiesgetBoundingClientRect

자리는 계산이다 — FLIP 좌표 역산·자동 스크롤 속도·롱프레스 임계값 공식

FLIP 좌표 역산은 이동 전 좌표에서 이동 후 좌표를 빼는 것뿐이다

01·02·03·04·06·07·08·09 가 전부 쓰는 계산은 하나입니다 — DOM 을 옮기기 전 좌표를 재고, 옮긴 후 같은 요소의 좌표를 다시 재서 그 차이(invert)를 transform 으로 즉시 되돌린 다음, 다음 프레임에 transition 을 걸고 0 으로 풉니다(play). 되돌리는 시점에 transition: none 을 강제하지 않으면 브라우저가 이미 걸린 전환과 되돌림을 동시에 그려 순간적으로 튀는 장면이 생깁니다.

자동 스크롤 속도는 가장자리까지 남은 거리의 함수다

07 의 edgeFactor 는 포인터가 40px 밴드 안에 있을 때만 0 이 아닌 값을 돌려주고, 그 값에 최대 속도를 곱해 프레임 간 경과시간(dt)만큼 scrollBy 로 이동시킵니다. dt 를 곱하지 않고 고정 픽셀만 더하면 프레임이 늦게 오는 저사양 환경에서는 스크롤이 느려지고 프레임이 빨리 오는 환경에서는 반대로 빨라져 기기마다 속도가 달라지므로, 시간 기반 적분이 빠지면 안 됩니다.

롱프레스 임계값은 시간과 거리 두 조건의 AND 다

05 는 pointerdown 순간 시작한 250ms 타이머와, 그 사이 누적된 이동거리(√(dx²+dy²))가 8px 를 넘는지를 동시에 봅니다. 타이머가 먼저 끝나면 드래그로, 거리가 먼저 넘으면 스크롤로 갈리기 때문에 둘 중 하나만 검사하면 손이 느린 사람의 탭이 스크롤로 오인되거나, 손이 빠른 사람의 스크롤이 드래그로 오인됩니다.

어디서 깨지는가 — 터치 스크롤 충돌·키보드 접근성·긴 리스트 성능

터치 스크롤과 드래그가 같은 손가락을 다툰다

05 의 롱프레스 임계값 없이 리스트 전체에 pointerdown 만 걸면, 스크롤하려고 살짝 스친 손가락도 바로 드래그로 잡혀 목록을 내려보기가 불가능해집니다. 반대로 04 처럼 손잡이만 잡는 항목은 이 문제 자체가 없지만, 손잡이가 너무 작으면 터치 대상으로 누르기 어려워져 최소 40px 안팎의 타깃 크기가 필요합니다.

키보드만으로 모든 재정렬이 가능해야 한다

06 의 Alt+화살표 가 없다면 마우스·터치로만 조작하는 01~05·07~09 는 포인팅 장치가 없는 사용자에게 아예 닫힌 기능이 됩니다. aria-live 로 "3번째로 이동" 을 읽어주지 않으면 화면을 볼 수 없는 사용자는 순서가 바뀌었는지조차 알 방법이 없습니다.

긴 리스트에서 getBoundingClientRect 를 매 프레임 부르면 강제 동기 레이아웃이 쌓인다

FLIP 계산은 이동한 요소 수만큼 getBoundingClientRect 를 두 번씩(전·후) 부르는데, 행이 수백 개로 늘면 이 호출 하나하나가 강제 리플로우를 일으켜 손끝이 움직이는 동안 프레임이 밀릴 수 있습니다. 화면에 보이는 행만 다시 재는 식으로 범위를 좁히면, 긴 목록에서도 같은 계산을 그대로 쓸 수 있습니다.

순서를 바꾸는 아홉 종, 어느 화면에 놓나

할 일 목록의 우선순위 재배열, 칸반 보드의 카드 이동, 설정 메뉴의 항목 순서 편집 — 이 세 화면이 드래그 재정렬을 가장 자주 요청합니다. 01 FLIP 리스트는 할 일 목록처럼 순서 하나만 바뀌어도 전체가 자연스럽게 움직여야 하는 화면에 맞고, 02 칸반 카드는 컬럼을 넘나드는 이동까지 받아야 하는 보드에 맞습니다.

마우스를 쓰지 않는 사용자가 설정 메뉴 순서를 바꿔야 하는 화면에서는 06 키보드 재정렬이 Alt+화살표만으로 같은 결과를 냅니다. 세 화면 모두 포인터 조작과 키보드 조작이 같은 배열을 가리켜야 한다는 점이 같아서, 둘을 같은 상태로 묶는 이 구조가 그대로 들어맞습니다.

판정 시간과 속도를 조절하면 접근성까지 같이 좋아진다

값 하나만 바꾼 세 변형

05 의 롱프레스 대기 시간을 250ms 에서 400ms 로 늘리면 스크롤을 드래그로 오인하는 일이 줄지만 그만큼 드래그가 시작되는 순간이 한 박 늦어지고, 07 의 자동 스크롤 최대 속도를 초당 480px 에서 240px 로 낮추면 목표 행을 지나치기 쉬운 대신 통제감이 커집니다. 01 의 FLIP 정리 시간을 180ms 에서 320ms 로 늘리면 여러 행이 동시에 밀리는 움직임을 눈으로 따라가기 쉬워지는데, 세 변형은 값 하나만 바꿔 느낌이 달라짐을 보여주는 확인용 코드고, 아홉 항목 전체 코드와 나란히 묶어 둔 zip 을 열려면 암호 hyvft5vm 을 입력하면 됩니다.

이름 바꾼 값 느낌
롱프레스 대기 시간 05 250ms → 400ms 스크롤 오인식이 줄지만 드래그 시작이 한 박 느려진다
자동 스크롤 최대 속도 07 초당 480px → 240px 통제감은 커지지만 아주 긴 목록에서는 느리게 느껴진다
FLIP 정리 시간 01 180ms → 320ms 여러 행이 동시에 밀리는 움직임이 눈으로 따라가기 쉬워진다

reduced-motion 은 결과를 지우지 않고 과정만 지운다

01~06 은 prefers-reduced-motion: reduce 를 감지하면 자동 시연이 좌표 계산만 한 번 실행하고 transition 없이 바로 최종 자리에 놓습니다. 목표 인덱스를 구하는 계산·배열 스플라이스·aria-live 안내는 모션 여부와 무관하게 그대로 실행되고, 화면에서 사라지는 것은 중간에 미끄러지는 장면뿐입니다. 07 은 계산 없이 스크롤 위치만 한 번 옮겨 두고, 08·09 는 자동 시연 자체를 건너뛰어 화면이 처음 배치 그대로 멈춰 있습니다.

재정렬을 붙이고 자주 물어보는 것들

리스트 순서 변경 UI 를 만들 때 HTML5 draggable 속성을 써야 하나요?

아닙니다. 이 아홉 항목은 전부 draggable 속성이나 dragstart·dragover 같은 HTML5 Drag and Drop API 를 쓰지 않습니다. 02·04·05·07·08·09 는 pointerdown·pointermove·pointerup 세 이벤트와 setPointerCapture 로 직접 구현했고, 01·03·06 은 클릭이나 Alt+화살표 만으로 같은 재배치 계산을 실행합니다. 포인터 이벤트를 쓰면 마우스·터치·펜을 같은 코드로 다루면서도 컨테이너 밖 스크롤 같은 브라우저 기본 동작을 세밀하게 켜고 끌 수 있습니다.

컬럼이나 그리드처럼 2차원으로 놓인 항목도 같은 계산으로 되나요?

네. 02 칸반은 어느 컬럼의 getBoundingClientRect 안에 포인터가 있는지부터 찾고 그 컬럼 안에서만 y 중점 비교를 하고, 03 그리드는 x·y 를 각각 트랙 폭·행 높이로 나눈 몫으로 열·행을 구합니다. 두 방식 모두 "포인터 좌표를 인덱스로 바꾼다"는 같은 아이디어를 차원에 맞게 나눠 적용한 것입니다.

롱프레스 250ms·자동 스크롤 40px 같은 숫자를 실제 서비스에 그대로 써도 되나요?

숫자 자체는 이 데모의 측정값이라 화면 크기·리스트 길이·사용자층에 따라 다시 재는 편이 안전합니다. 다만 05 의 "시간+거리 AND 판정", 07 의 "거리를 0~1 로 정규화해 속도에 곱하는" 구조는 숫자를 바꿔도 그대로 재사용할 수 있습니다.

더 읽을거리

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

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