dnd kit – a modern drag and drop toolkit for React
A modular, lightweight, performant, accessible and extensible drag & drop toolkit for React.
dndkit.com
1단계: 기본 구조 이해
1. DndContext의 역할
개념
DndContext는 드래그 앤 드롭을 가능하게 해주는 최상위 컨테이너입니다.
여기에 드래그/드롭 관련 이벤트 핸들러와 센서를 등록하면 자식 컴포넌트에서 드래그가 활성화됩니다.
예제
import { DndContext } from '@dnd-kit/core';
export default function App() {
return (
<DndContext onDragEnd={handleDragEnd}>
<MyDraggable />
<MyDroppable />
</DndContext>
);
}
핵심 역할
- 내부의 Draggable, Droppable을 연결해줌
- 전역 드래그 이벤트 관리 (시작, 이동, 종료, 취소 등)
- 충돌 판정 기준 설정 (collisionDetection)
- 드래그 센서 설정 (useSensors)
- DndContext는 컴포넌트 트리 상 최상위 1개만 있어야 함
DndContext 주요 props 목록
<DndContext
sensors={...}
collisionDetection={...}
modifiers={...}
measuring={...}
autoScroll={...}
onDragStart={...}
onDragMove={...}
onDragOver={...}
onDragEnd={...}
onDragCancel={...}
/>
| sensors | 드래그를 감지할 입력 장치 종류 설정 (예: 마우스, 터치, 키보드 등). useSensors(...)로 생성 |
| collisionDetection | 드롭 타겟 충돌 판정 알고리즘 설정 (예: closestCenter, pointerWithin, 커스텀 함수 등) |
| modifiers | 드래그 동작에 제약 조건 부여 (예: 수직 이동만, 그리드 스냅, 경계 제한 등) |
| measuring | 측정 전략 설정 (예: bounding box 계산 방식). 거의 기본값 사용 |
| autoScroll | 드래그 중 자동 스크롤 여부 설정 (고급 옵션, 기본 true) |
| onDragStart | 드래그 시작 시 실행되는 콜백 함수 |
| onDragMove | 드래그 중 이동할 때마다 실행되는 콜백 |
| onDragOver | 다른 droppable 위에 올라갔을 때 실행되는 콜백 |
| onDragEnd | 드래그 종료(마우스 놓을 때) 실행되는 콜백 |
| onDragCancel | 드래그 도중 취소되었을 때 실행되는 콜백 (예: ESC 키, 브라우저 리셋 등) |
<DndContext
sensors={useSensors(useSensor(PointerSensor))}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={({ active, over }) => {
if (over) {
console.log(`${active.id} dropped on ${over.id}`);
}
}}
/>
자주 쓰이는 조합
| 기본 마우스 드래그 | sensors={useSensors(useSensor(PointerSensor))} |
| 키보드 지원 추가 | KeyboardSensor + sortableKeyboardCoordinates |
| 수직 정렬만 허용 | modifiers={[restrictToVerticalAxis]} |
| 그리드 스냅 | modifiers={[snapToGrid(30)]} |
| 커서 기준 드롭 | collisionDetection={pointerWithin} |
2. useDraggable 훅
개념
useDraggable은 해당 요소가 드래그 가능한 상태가 되도록 설정하는 훅입니다.
예제
import { useDraggable } from '@dnd-kit/core';
function DraggableBox() {
const {attributes, listeners, setNodeRef, transform} = useDraggable({
id: 'box',
});
const style = {
transform: transform
? `translate(${transform.x}px, ${transform.y}px)`
: undefined,
};
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={{...style, width: 100, height: 100, background: 'skyblue'}}
>
Drag me
</div>
);
}
주의할 점
- 반드시 setNodeRef로 DOM 참조 설정해야 함
- listeners는 onMouseDown, onTouchStart 등 이벤트 바인딩 역할
- attributes는 role, aria-* 접근성 속성 바인딩
- useDraggable, useDroppable는 여러 개 사용 가능
useDraggable({ id })
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useDraggable({ id: 'item-1' });
구조 분해 항목 설명
| attributes | object | role, aria-* 등 접근성 관련 속성을 포함. ...attributes로 요소에 연결해야 키보드 등에서 인식됨 |
| listeners | object | onPointerDown, onTouchStart, onKeyDown 등 드래그 시작 이벤트 바인딩용. ...listeners로 핸들링 |
| setNodeRef | (element: HTMLElement) => void | 이 요소가 드래그 가능하다고 인식되게 하는 ref 연결 함수 |
| transform | { x: number; y: number } | null | 드래그 중인 요소의 이동 거리(px). translate() 스타일 적용에 사용 |
| transition | string | undefined | 드래그 후 애니메이션 제어용 CSS transition. 드롭 후 위치 복원 등에 사용 |
| isDragging | boolean | 현재 이 요소가 드래그 중인지 여부. 스타일 조건부 렌더링에 사용 |
1. attributes
"이 요소는 키보드로도 드래그할 수 있어요"라고 브라우저에게 알려주는 접근성 정보
<div {...attributes}>
- 내부적으로 role="button", aria-pressed, tabIndex=0 같은 속성이 들어 있어요.
- 키보드 사용자(예: 시각장애인 등)를 위한 필수 요소예요.
- ...attributes를 안 넣으면 키보드나 스크린 리더에서 작동 안 될 수도 있어요.
2. listeners
마우스로 "클릭하고 드래그"를 시작하는 이벤트를 연결하는 핸들러
<div {...listeners}>
- 이걸 넣어야 드래그가 시작됩니다.
- 내부적으로 onPointerDown, onMouseDown, onTouchStart 등이 들어있어요.
- 커스텀 드래그 핸들을 만들 때 특정 부분에만 바인딩할 수도 있어요.
3. setNodeRef
이 DOM 요소가 드래그 대상이라는 걸 dnd-kit에 알려주는 함수
<div ref={setNodeRef}>
- 이걸 연결하지 않으면 dnd-kit은 이 요소를 감지하지 못해요.
- 꼭! 드래그할 대상의 DOM에 붙여야 해요.
4. transform
드래그 중인 동안 얼마나 이동했는지 알려주는 값 (x, y 거리)
style={{
transform: transform
? `translate(${transform.x}px, ${transform.y}px)`
: undefined
}}
- 마우스로 끌면 이 값이 계속 변해요.
- 이걸 transform: translate(...)로 적용하면 아이템이 실제로 움직여요.
- 없으면 드래그 시 움직이지 않아요.
5. transition
드래그가 끝난 후, 자연스럽게 자리로 돌아가는 애니메이션 효과
style={{ transition }}
- 드롭했을 때 아이템이 부드럽게 원래 자리로 돌아오도록 애니메이션 적용됨
- 기본값은 부드러운 ease-in-out
6. isDragging
지금 이 아이템이 드래그 중인지 아닌지 알려주는 플래그
style={{ backgroundColor: isDragging ? 'yellow' : 'white' }}
- 현재 내가 끌고 있는 아이템이면 true
- 스타일 변경이나 DragOverlay 표시 등에 자주 사용됨
예시 코드
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
transition,
backgroundColor: isDragging ? 'yellow' : 'white',
}}
>
Drag Me
</div>
3. useDroppable 훅
개념
useDroppable은 드롭 가능한 영역을 정의합니다.
예제
import { useDroppable } from '@dnd-kit/core';
function DropZone() {
const {isOver, setNodeRef} = useDroppable({
id: 'zone',
});
return (
<div
ref={setNodeRef}
style={{
width: 200,
height: 200,
background: isOver ? 'lightgreen' : 'lightgray',
}}
>
Drop here
</div>
);
}
포인트
- isOver는 현재 드래그 대상이 이 영역 위에 있는지 여부
- id는 고유해야 하며 event.over.id로 접근됨
useDroppable({ id })
const {
isOver,
setNodeRef,
active,
over,
} = useDroppable({ id: 'dropzone-1' });
구조 분해 항목 설명
| isOver | boolean | 현재 드래그 중인 요소가 이 영역 위에 있는지 여부 |
| setNodeRef | (element: HTMLElement) => void | 이 요소를 드롭 대상으로 지정하는 ref 연결 함수 |
| active | { id: string; ... } | null | 현재 드래그 중인 요소의 정보 (active.id 등). 모든 droppable에서 접근 가능 |
| over | { id: string; ... } | null | 현재 이 droppable 위에 있는 드래그 항목 정보. over.id === this.id일 때 자신 위임 |
예제 적용
<div
ref={setNodeRef}
style={{
backgroundColor: isOver ? 'lightgreen' : 'lightgray',
padding: 20,
}}
>
Drop Here
</div>
4. 드래그 상태 추적 (onDragStart, onDragMove, onDragOver, onDragEnd, onDragCancel)
이벤트 설명
<DndContext
onDragStart={(event) => {
console.log('start', event.active.id);
}}
onDragMove={(event) => {
console.log('move', event.delta);
}}
onDragOver={(event) => {
console.log('over', event.over?.id);
}}
onDragEnd={(event) => {
console.log('end', event.active.id, '->', event.over?.id);
}}
onDragCancel={() => {
console.log('cancelled');
}}
>
...
</DndContext>
- event.active.id: 현재 드래그 중인 요소의 id
- event.over?.id: 현재 드롭 대상 위에 있을 때의 id
- event.delta: 마지막 위치 대비 현재 위치의 차이
5. transform, transition 적용
목적
드래그 시 부드럽게 움직이게 하거나, 리스트 재정렬 시 자연스럽게 이동시키기 위해 사용
기본 예제
const style = {
transform: transform
? `translate(${transform.x}px, ${transform.y}px)`
: undefined,
transition: 'transform 0.2s ease',
};
- transform: 드래그 중 위치 계산을 위해 자동으로 제공됨
- transition: 드롭 후 원래 자리로 돌아가는 애니메이션 설정
6. 드롭 성공 / 실패 구분
핵심 로직
onDragEnd={({ active, over }) => {
if (over) {
console.log('드롭 성공:', active.id, '→', over.id);
} else {
console.log('드롭 실패:', active.id);
}
}}
- event.over === null이면 아무 드롭도 되지 않은 상태 (실패)
- event.over.id가 존재하면 정상 드롭 (성공)
예제 전체 코드 (기초 완성형)
import {
DndContext,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
function DraggableBox() {
const {attributes, listeners, setNodeRef, transform} = useDraggable({ id: 'box' });
const style = {
transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
width: 100,
height: 100,
backgroundColor: 'skyblue',
transition: 'transform 0.2s ease',
};
return (
<div ref={setNodeRef} {...listeners} {...attributes} style={style}>
Drag Me
</div>
);
}
function DropZone() {
const {isOver, setNodeRef} = useDroppable({ id: 'zone' });
return (
<div
ref={setNodeRef}
style={{
width: 200,
height: 200,
background: isOver ? 'lightgreen' : 'lightgray',
marginTop: 20,
}}
>
Drop Here
</div>
);
}
export default function App() {
return (
<DndContext
onDragStart={(e) => console.log('Start:', e.active.id)}
onDragOver={(e) => console.log('Over:', e.over?.id)}
onDragEnd={(e) => {
if (e.over) {
console.log('Dropped on:', e.over.id);
} else {
console.log('Dropped nowhere');
}
}}
onDragCancel={() => console.log('Cancelled')}
>
<DraggableBox />
<DropZone />
</DndContext>
);
}
실습 목표
- 박스를 마우스로 드래그해서
- 드롭 가능한 영역에 올리면 배경색이 초록색으로 바뀌고
- 드롭을 하면 로그에 결과가 출력됨
코드 예제
// App.tsx
import React from 'react';
import {
DndContext,
useDraggable,
useDroppable,
DragEndEvent,
} from '@dnd-kit/core';
function DraggableBox() {
const {attributes, listeners, setNodeRef, transform} = useDraggable({
id: 'box',
});
const style = {
transform: transform
? `translate(${transform.x}px, ${transform.y}px)`
: undefined,
width: 100,
height: 100,
backgroundColor: 'skyblue',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
cursor: 'grab',
userSelect: 'none',
};
return (
<div ref={setNodeRef} {...listeners} {...attributes} style={style}>
BOX
</div>
);
}
function DropZone() {
const {isOver, setNodeRef} = useDroppable({
id: 'dropzone',
});
const style = {
width: 200,
height: 200,
backgroundColor: isOver ? 'lightgreen' : 'lightgray',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed gray',
borderRadius: 8,
marginTop: 20,
};
return (
<div ref={setNodeRef} style={style}>
Drop Here
</div>
);
}
export default function App() {
const handleDragEnd = (event: DragEndEvent) => {
const {active, over} = event;
if (over) {
console.log(`${active.id} dropped on ${over.id}`);
} else {
console.log(`${active.id} dropped outside any droppable`);
}
};
return (
<div style={{padding: 40}}>
<DndContext onDragEnd={handleDragEnd}>
<DraggableBox />
<DropZone />
</DndContext>
</div>
);
}
작동 방식 요약
- DraggableBox: useDraggable(id: 'box') 사용
- DropZone: useDroppable(id: 'dropzone') 사용
- DndContext: onDragEnd에서 event.active.id와 event.over?.id 확인
- isOver를 이용해 드롭 영역의 색상을 실시간으로 반응
설치가 필요한 경우
npm install @dnd-kit/core
yarn add @dnd-kit/core
체크포인트
- 박스가 마우스로 드래그 가능
- 드롭존 위에 올라가면 색상 바뀜
- 드롭하면 콘솔에 메시지 출력
- 아무 데나 놓으면 실패 메시지 출력
2단계: 정렬 가능한 리스트
설치
npm install @dnd-kit/core @dnd-kit/sortable
또는
yarn add @dnd-kit/core @dnd-kit/sortable
전체 예제 코드
// SortableList.tsx
import React, {useState} from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';
function SortableItem({id}: {id: string}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({id});
const style = {
transform: CSS.Transform.toString(transform), // translate(x, y)
transition,
padding: 12,
marginBottom: 8,
background: isDragging ? '#ffa' : '#f0f0f0',
borderRadius: 6,
cursor: 'grab',
boxShadow: isDragging ? '0 0 10px rgba(0,0,0,0.2)' : undefined,
};
return (
<div ref={setNodeRef} {...attributes} {...listeners} style={style}>
{id}
</div>
);
}
export default function SortableList() {
const [items, setItems] = useState(['Apple', 'Banana', 'Carrot', 'Durian']);
const sensors = useSensors(useSensor(PointerSensor)); // 마우스, 터치 기본 지원
const handleDragEnd = (event: any) => {
const {active, over} = event;
if (active.id !== over?.id) {
const oldIndex = items.indexOf(active.id);
const newIndex = items.indexOf(over.id);
setItems(arrayMove(items, oldIndex, newIndex)); // 리스트 순서 변경
}
};
return (
<div style={{padding: 40}}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items}
strategy={verticalListSortingStrategy} // 수직 정렬
>
{items.map((id) => (
<SortableItem key={id} id={id} />
))}
</SortableContext>
</DndContext>
</div>
);
}
1. SortableContext
- 리스트 정렬이 가능한 컨텍스트를 설정합니다.
- SortableContext는 중첩 가능하지만 반드시 적절한 items와 strategy가 있어야 함
- items는 string ID 배열이어야 함
- strategy로 정렬 방향을 지정:
verticalListSortingStrategy // 위아래 정렬
horizontalListSortingStrategy // 좌우 정렬
2. useSortable
- 각 아이템마다 부여
- id를 기준으로 정렬 대상 식별
- 반환값 주요 속성:
- setNodeRef: DOM에 ref 연결
- transform: 드래그 위치 계산
- transition: 부드러운 이동 효과
- attributes, listeners: 필수 이벤트 바인딩
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
} = useSortable({ id });
3. arrayMove
arrayMove(array, fromIndex, toIndex)
- 배열의 요소를 한 위치에서 다른 위치로 이동시키는 유틸
- 상태 업데이트 시 필수 사용
4. transform과 transition
transform: CSS.Transform.toString(transform),
transition,
- transform: 현재 요소의 이동 거리 정보 (x, y)
- transition: 드래그 후 자연스럽게 제자리로 이동하는 애니메이션
체크포인트
- 리스트 아이템을 드래그로 재정렬 가능
- 드래그 중인 아이템에 배경 효과 적용
- 콘솔 없이도 정렬 반영됨
- 수직/수평 정렬 전략 변경 가능
실습 목표
- 여러 개의 "리스트(Column)"와 각 "리스트" 안의 "카드" 구성
- 카드를 같은 리스트 내에서 정렬 가능
- 커서를 이동하면 카드가 부드럽게 움직이고 커서 모양이 변경됨
- (선택 확장 가능) 다른 리스트 간 카드 이동
예제 구조
- 2개의 리스트 (예: TODO, Done)
- 각 리스트는 고유한 카드 배열을 가짐
- @dnd-kit/sortable을 사용해 카드 정렬
구현 코드
// TrelloBoard.tsx
import React, {useState} from 'react';
import {
DndContext,
closestCorners,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';
// 카드 컴포넌트
function Card({id}: {id: string}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({id});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
backgroundColor: isDragging ? '#ffe08a' : '#fff',
padding: 12,
marginBottom: 8,
border: '1px solid #ddd',
borderRadius: 6,
cursor: 'grab',
boxShadow: isDragging ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
userSelect: 'none',
};
return (
<div ref={setNodeRef} {...attributes} {...listeners} style={style}>
{id}
</div>
);
}
// 리스트 컴포넌트
function CardList({
title,
items,
setItems,
}: {
title: string;
items: string[];
setItems: React.Dispatch<React.SetStateAction<string[]>>;
}) {
return (
<div
style={{
width: 250,
padding: 16,
backgroundColor: '#f5f5f5',
borderRadius: 8,
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
marginRight: 20,
}}
>
<h3 style={{marginBottom: 12}}>{title}</h3>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((id) => (
<Card key={id} id={id} />
))}
</SortableContext>
</div>
);
}
// 전체 보드
export default function TrelloBoard() {
const [todo, setTodo] = useState(['Card A', 'Card B', 'Card C']);
const [done, setDone] = useState(['Card D', 'Card E']);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = ({active, over}: any) => {
if (!over) return;
// TODO 리스트 처리
if (todo.includes(active.id) && todo.includes(over.id)) {
const oldIndex = todo.indexOf(active.id);
const newIndex = todo.indexOf(over.id);
setTodo(arrayMove(todo, oldIndex, newIndex));
}
// Done 리스트 처리
if (done.includes(active.id) && done.includes(over.id)) {
const oldIndex = done.indexOf(active.id);
const newIndex = done.indexOf(over.id);
setDone(arrayMove(done, oldIndex, newIndex));
}
// (선택) 리스트 간 이동은 다음 단계에서 다룸
};
return (
<div style={{padding: 32}}>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragEnd={handleDragEnd}
>
<div style={{display: 'flex', flexDirection: 'row'}}>
<CardList title="TODO" items={todo} setItems={setTodo} />
<CardList title="Done" items={done} setItems={setDone} />
</div>
</DndContext>
</div>
);
}
스타일 및 애니메이션 적용 요약
- 커서 변경: cursor: 'grab' (드래그 중은 자동으로 grabbing)
- 애니메이션: transition과 boxShadow로 드래그 중 강조
- 위치 이동: transform: CSS.Transform.toString(transform)로 자연스러운 이동 처리
확장 아이디어
- 다른 리스트 간 카드 이동
- 카드 추가/삭제 기능
- dragOverlay 사용해서 유령 카드 표시
- Zustand 같은 글로벌 상태관리 연동
설치 명령 정리
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
3단계: 센서와 충돌 감지 커스터마이징
1. useSensor & useSensors
개념
dnd-kit에서는 **센서(sensor)**를 통해 사용자의 입력 방식(마우스, 터치, 키보드 등)을 감지하고 드래그 동작을 시작합니다.
코드 예시
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor),
useSensor(TouchSensor)
);
<DndContext sensors={sensors}>
{/* ... */}
</DndContext>
차이점 요약
센서 종류 설명
| PointerSensor | 마우스 및 터치 (기본 센서) |
| TouchSensor | 순수 터치 전용 (모바일 환경 전용으로 더 민감함) |
| KeyboardSensor | 키보드로 리스트 이동 (접근성 용도) |
2. PointerSensor, TouchSensor, KeyboardSensor
PointerSensor (기본)
- 마우스 클릭, 드래그, 터치 이벤트 대응
- 드래그 시작 감지가 부드럽고 표준적
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 5px 이상 이동해야 드래그 시작
},
});
TouchSensor
- 모바일에서 더 빠르게 반응
- 진동 피드백 등을 커스터마이징 가능
- 사용 예:
useSensor(TouchSensor, {
activationConstraint: {
delay: 150,
tolerance: 8,
},
});
KeyboardSensor
- 키보드로 이동 가능
- 방향키 기반 정렬
- coordinateGetter를 설정해야 함
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
});
3. collisionDetection 알고리즘
기본 구조
<DndContext
collisionDetection={closestCenter} // 알고리즘 설정
>
{/* ... */}
</DndContext>
4. 주요 충돌 판정 알고리즘
1. closestCenter
드롭 가능한 모든 요소 중 중심점 간 거리가 가장 가까운 요소
- 기본적이며 가장 직관적인 방식
- 일반적인 정렬 UI에 적합
<DndContext collisionDetection={closestCenter}>
2. rectIntersection
드래그된 요소와 겹치는 사각형 영역의 면적이 가장 큰 요소
- 두 요소가 많이 겹칠수록 선택됨
- 중첩 리스트나 박스가 겹치는 UI에 유리
3. pointerWithin
마우스 커서의 위치가 들어있는 드롭 요소만 대상이 됨
- 정확한 위치가 중요할 때 사용
- 작은 요소나 포인트 기반 드래그에 적합
<DndContext collisionDetection={pointerWithin}>
5. 커스텀 충돌 판정 함수
예제: 가장 왼쪽에 있는 요소를 선택하는 커스텀 판정
import type {CollisionDetection} from '@dnd-kit/core';
const leftMostCollision: CollisionDetection = ({collisionRect, droppableRects}) => {
const entries = Object.entries(droppableRects);
if (entries.length === 0) return [];
const sorted = entries.sort(
([, a], [, b]) => a.left - b.left // 왼쪽에서 가장 가까운 순
);
const [id, rect] = sorted[0];
return [
{
id,
data: {
value: rect,
},
},
];
};
<DndContext collisionDetection={leftMostCollision}>
실습 아이디어
- 마우스와 키보드 둘 다 지원하는 드래그 시스템
- 드롭 대상 위에만 반응하는 pointerWithin 예제
- 중첩 영역일 때 rectIntersection 비교
- 가장 왼쪽/오른쪽에 있는 요소 선택 (custom)
| 입력 장치 감지 | useSensor(PointerSensor / TouchSensor / KeyboardSensor) |
| 다중 입력 지원 | useSensors(...sensors) |
| 충돌 판정 | `collisionDetection={closestCenter |
| 고급 위치 제어 | 커스텀 collision 함수 직접 정의 |
실습 목표
- 키보드로 정렬 가능한 리스트 조작
- pointerWithin vs closestCenter 충돌 방식 비교
- 정확한 드롭 타겟 감지를 위한 pointerWithin 적용
- KeyboardSensor로 키보드 기반 정렬 제어
- 커스텀 collisionDetection으로 드롭 우선순위 지정
전체 예제 구성 (기본 리스트로 시작)
import React, { useState } from 'react';
import {
DndContext,
closestCenter,
pointerWithin,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
function SortableItem({ id }: { id: string }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
padding: 12,
marginBottom: 8,
backgroundColor: isDragging ? '#ffe08a' : '#f5f5f5',
border: '1px solid #ddd',
borderRadius: 6,
cursor: 'grab',
userSelect: 'none',
};
return (
<div ref={setNodeRef} {...attributes} {...listeners} style={style}>
{id}
</div>
);
}
export default function KeyboardSortableExample() {
const [items, setItems] = useState(['Alpha', 'Bravo', 'Charlie', 'Delta']);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = items.indexOf(active.id);
const newIndex = items.indexOf(over.id);
setItems(arrayMove(items, oldIndex, newIndex));
}
};
return (
<div style={{ padding: 40 }}>
<h2>정렬 가능한 리스트 (마우스 + 키보드 지원)</h2>
<p style={{ fontSize: 14, color: '#666' }}>↑↓ 키로 이동 가능</p>
<DndContext
sensors={sensors}
collisionDetection={pointerWithin} // pointerWithin 또는 closestCenter
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((id) => (
<SortableItem key={id} id={id} />
))}
</SortableContext>
</DndContext>
</div>
);
}
결과 확인
- 마우스로도 드래그 가능하고,
- 키보드(↑↓)로도 항목을 이동 가능함
- 드롭 판정 기준은 pointerWithin이므로 커서가 실제 드롭 타겟 위에 있어야만 반응
collisionDetection 알고리즘 비교
// 가장 직관적인 기준: 아이템 중앙점 비교
collisionDetection={closestCenter}
// 포인터 위치만 기준 → 더 정밀
collisionDetection={pointerWithin}
- pointerWithin: 커서가 겹치지 않으면 드롭이 되지 않음 → 정확도 높음
- closestCenter: 눈에 보이는 위치가 중심점에 가까우면 드롭됨 → 직관적이지만 중첩 시 혼란 가능
커스텀 collision 함수 예제
const customCollision = ({ collisionRect, droppableRects }) => {
const collisions = Object.entries(droppableRects).map(([id, rect]) => {
const overlap =
Math.max(0, Math.min(rect.left + rect.width, collisionRect.left + collisionRect.width) -
Math.max(rect.left, collisionRect.left)) *
Math.max(0, Math.min(rect.top + rect.height, collisionRect.top + collisionRect.height) -
Math.max(rect.top, collisionRect.top));
return { id, data: { overlap } };
});
return collisions
.filter((c) => c.data.overlap > 0)
.sort((a, b) => b.data.overlap - a.data.overlap);
};
<DndContext collisionDetection={customCollision}>
- 겹치는 면적이 많은 드롭존 우선 선택
- 실제 위치 + 겹침 면적 기반으로 충돌을 더 정밀하게 감지
혼합해서 사용
const hybridCollision = (args) => {
const overlap = customCollision(args);
if (overlap.length > 0) return overlap;
return closestCorners(args); // fallback
};
정리 요약
| 키보드 정렬 | KeyboardSensor + sortableKeyboardCoordinates |
| 센서 다중 등록 | useSensors(useSensor(...)) |
| 포인터 기준 충돌 | collisionDetection={pointerWithin} |
| 중심 기준 충돌 | collisionDetection={closestCenter} |
| 커스텀 충돌 기준 | collisionDetection={yourFunc} |
4단계: 커스터마이징과 제약 조건
- 드래그 방향 제한 (수직/수평)
- 위치 스냅 (센터 정렬, 그리드)
- 브라우저 창 밖으로 이동 방지
- 특정 영역 내에서만 드래그 허용
- 드래그 핸들 구현
- 드래그/드롭 영역 분리
- 다중 컨테이너 리스트 간 아이템 이동
1. modifiers 활용 (위치 제한)
modifiers는 드래그 중 위치를 동적으로 제한할 수 있게 도와주는 옵션입니다.
예시: 수직 이동만 허용
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
<DndContext
modifiers={[restrictToVerticalAxis]}
...
>
주요 모듈
- restrictToVerticalAxis : Y축 이동만 허용
- restrictToHorizontalAxis : X축 이동만 허용
- restrictToWindowEdges : 화면 밖으로 이동 금지
- snapCenterToCursor : 드래그 아이템 중심을 커서에 맞춤
2. Snap to Grid 구현
import { Modifier } from '@dnd-kit/core';
const snapToGrid = (gridSize = 20): Modifier => ({ transform }) => {
return {
...transform,
x: Math.round(transform.x / gridSize) * gridSize,
y: Math.round(transform.y / gridSize) * gridSize,
};
};
// 사용
<DndContext modifiers={[snapToGrid(30)]}>
- snapToGrid(gridSize) : 일정 단위로 위치 스냅
3. 특정 영역 내에서만 드래그 허용
import { restrictToBoundingRect } from '@dnd-kit/modifiers';
// 특정 DOM 요소 참조
const containerRef = useRef<HTMLDivElement>(null);
<DndContext modifiers={[restrictToBoundingRect(containerRef)]}>
<div ref={containerRef} style={{ width: 300, height: 400, overflow: 'hidden' }}>
<DraggableBox />
</div>
</DndContext>
- restrictToBoundingRect(ref)는 해당 영역 안으로만 제한함
4. 드래그 핸들 만들기
"아이템 전체가 아니라 버튼을 눌러야만 드래그 가능"
function CardWithHandle({ id }) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
backgroundColor: '#fff',
padding: 12,
marginBottom: 8,
border: '1px solid #ccc',
borderRadius: 6,
};
return (
<div ref={setNodeRef} style={style}>
<button
{...listeners}
{...attributes}
style={{
cursor: 'grab',
background: 'lightgray',
border: 'none',
marginBottom: 8,
}}
>
↕ 드래그
</button>
{id}
</div>
);
}
5. 드래그/드롭 가능한 영역 분리
- useDraggable()로 만든 요소는 이동 가능
- useDroppable()로 만든 요소는 타겟
예: 카드만 드래그 가능하고, 특정 열에만 드롭 가능
useDroppable({ id: 'only-column-1' });
onDragEnd={({ active, over }) => {
if (!over || over.id !== 'only-column-1') return;
}
6. 그룹 간 이동 (Multi-Container)
리스트 간 아이템 이동 구현
const [columns, setColumns] = useState({
todo: ['A', 'B'],
done: ['C'],
});
const handleDragEnd = ({ active, over }) => {
const source = findColumnOf(active.id);
const target = findColumnOf(over?.id);
if (!target || source === target) return;
setColumns((prev) => {
const from = prev[source].filter((item) => item !== active.id);
const to = [...prev[target], active.id];
return {
...prev,
[source]: from,
[target]: to,
};
});
};
- SortableContext를 리스트마다 따로 설정
- items는 고유 ID를 써야 함
- collisionDetection은 closestCorners 추천
설치가 필요한 모듈
npm install @dnd-kit/modifiers
🔍 실습 추천 조합
시나리오 적용 요소
| 수직 카드 정렬 | restrictToVerticalAxis |
| 트렐로 스타일 이동 | multi-container + closestCorners |
| 커서로만 이동 감지 | pointerWithin |
| 커서 중심 스냅 | snapCenterToCursor |
| 그리드 정렬 UI | snapToGrid(20) |
| 드래그 핸들 버튼 | listeners만 특정 요소에 바인딩 |
실습 목표
- Y축(수직) 이동만 가능하도록 제한
- 특정 DOM 영역 안에서만 드래그 허용
- 드래그 핸들(Button)을 눌러야만 드래그 가능
- 드래그 위치가 30px 단위로 스냅되도록 설정
- 두 개의 리스트 간 카드 이동 구현 (TODO → DONE)
구현 코드 전체
import React, { useRef, useState } from 'react';
import {
DndContext,
closestCorners,
useSensor,
useSensors,
PointerSensor,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
const snapToGrid =
(gridSize = 20): Modifier =>
({ transform }) => {
return {
...transform,
x: Math.round(transform.x / gridSize) * gridSize,
y: Math.round(transform.y / gridSize) * gridSize,
};
};
// Snap to 30px grid
function DraggableCard({ id }: { id: string }) {
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
padding: 12,
marginBottom: 8,
backgroundColor: isDragging ? '#ffa' : '#fff',
border: '1px solid #ccc',
borderRadius: 6,
userSelect: 'none',
cursor: 'default',
};
return (
<div ref={setNodeRef} style={style}>
<button
{...attributes}
{...listeners} // ✅ 이게 중요
style={{
cursor: 'grab',
padding: 4,
fontSize: 12,
marginBottom: 6,
backgroundColor: '#eee',
}}>
↕ 드래그 핸들
</button>
{id}
</div>
);
}
function CardColumn({
title,
cards,
setCards,
}: {
title: string;
cards: string[];
setCards: React.Dispatch<React.SetStateAction<string[]>>;
}) {
return (
<div
style={{
width: 240,
padding: 16,
marginRight: 20,
backgroundColor: '#f9f9f9',
borderRadius: 8,
}}>
<h3>{title}</h3>
{cards.map((id) => (
<DraggableCard key={id} id={id} />
))}
</div>
);
}
export default function Test7Page() {
const [todo, setTodo] = useState(['Task A', 'Task B']);
const [done, setDone] = useState(['Task C']);
const containerRef = useRef(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
}),
);
const findListContaining = (id: string) =>
todo.includes(id) ? 'todo' : done.includes(id) ? 'done' : null;
const handleDragEnd = ({ active, over }: any) => {
if (!over) return;
const fromList = findListContaining(active.id);
const toList = findListContaining(over.id);
if (!fromList || !toList) return;
if (fromList === toList) {
const list = fromList === 'todo' ? todo : done;
const setList = fromList === 'todo' ? setTodo : setDone;
const oldIndex = list.indexOf(active.id);
const newIndex = list.indexOf(over.id);
const targetIndex = newIndex === -1 ? toList.length : newIndex;
const newList = [...list];
newList.splice(oldIndex, 1);
newList.splice(targetIndex, 0, active.id);
setList(newList);
} else {
const from = fromList === 'todo' ? todo : done;
const to = toList === 'todo' ? todo : done;
const setFrom = fromList === 'todo' ? setTodo : setDone;
const setTo = toList === 'todo' ? setTodo : setDone;
setFrom(from.filter((i) => i !== active.id));
setTo([...to, active.id]);
}
};
return (
<div style={{ padding: 40 }}>
<h2>드래그 제약 + 핸들 + 스냅 + 리스트 이동</h2>
<div
ref={containerRef}
style={{
display: 'flex',
flexDirection: 'row',
border: '2px dashed #aaa',
padding: 16,
borderRadius: 12,
overflow: 'hidden',
}}>
<DndContext
sensors={sensors}
// active를 걸어서 상하로만 이동 가능한 핸들 생성 가능
modifiers={[
restrictToVerticalAxis, // 상하로만 이동 가능하도록
restrictToParentElement, // 좌우로만 이동 가능하도록
snapToGrid(10), // 드르륵 거리는 느낌
]}
collisionDetection={closestCorners}
onDragEnd={handleDragEnd}>
<SortableContext
items={[...todo, ...done]} // 두 리스트를 합쳐 전역 context로 인식됨
strategy={verticalListSortingStrategy}>
<CardColumn title="TODO" cards={todo} setCards={setTodo} />
<CardColumn title="DONE" cards={done} setCards={setDone} />
</SortableContext>
</DndContext>
</div>
</div>
);
}
| 수직 드래그 제한 | restrictToVerticalAxis |
| 컨테이너 안으로 제한 | restrictToBoundingRect(ref) |
| 30px 단위 스냅 | snapToGrid(30) 커스텀 modifier |
| 핸들로만 드래그 | onPointerDown에서 id 저장 후 listener로 제한 |
| 리스트 간 이동 | TODO ↔ DONE 상태 배열 재정렬 |
필요한 설치
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/modifiers
5단계: 트리 구조 & 중첩 리스트
좋습니다! 이번 실습에서는 dnd-kit을 활용한 트리 구조(계층형, Nested Sortable) UI 구현을 다룹니다.
이 패턴은 폴더 구조, 태그 트리, 메뉴 구성 UI 등에 널리 쓰입니다.
실습 목표
- 중첩된 리스트 구조를 드래그 & 드롭으로 정렬
- SortableContext를 계층적으로 사용
- id를 path 구조 (folder/item)로 관리
- 들여쓰기(indent)로 트리 표현
- 드래그 중 dragOverlay로 유령 아이템 표시
데이터 구조 설계
type TreeItem = {
id: string; // 예: "root/child1"
parent: string | null; // 상위 ID (없으면 root)
depth: number; // 트리 깊이 (스타일용)
children?: string[]; // 자식 노드 ID 리스트
};
트리 정렬 전체 예제
// TreeSortable.tsx
import React, { useState } from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
type TreeItem = {
id: string;
parent: string | null;
depth: number;
};
const initialItems: TreeItem[] = [
{ id: 'folder1', parent: null, depth: 0 },
{ id: 'folder1/item1', parent: 'folder1', depth: 1 },
{ id: 'folder1/item2', parent: 'folder1', depth: 1 },
{ id: 'folder2', parent: null, depth: 0 },
{ id: 'folder2/item3', parent: 'folder2', depth: 1 },
];
function TreeRow({ id, depth }: { id: string; depth: number }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
padding: 8,
marginBottom: 4,
backgroundColor: isDragging ? '#e0f7fa' : '#fff',
border: '1px solid #ccc',
borderRadius: 4,
paddingLeft: 12 + depth * 20,
cursor: 'grab',
};
return (
<div ref={setNodeRef} {...attributes} {...listeners} style={style}>
{id.split('/').pop()}
</div>
);
}
export default function TreeSortable() {
const [items, setItems] = useState<TreeItem[]>(initialItems);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
const visibleItems = items.filter((item) => item.parent === null || items.find(i => i.id === item.parent));
const handleDragStart = ({ active }: any) => {
setActiveId(active.id);
};
const handleDragEnd = ({ active, over }: any) => {
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over.id);
const newItems = [...items];
const [moved] = newItems.splice(oldIndex, 1);
newItems.splice(newIndex, 0, moved);
setItems(newItems);
setActiveId(null);
};
return (
<div style={{ padding: 32 }}>
<h2>📁 트리 구조 정렬 (Nested Sortable)</h2>
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<SortableContext
items={visibleItems.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{visibleItems.map((item) => (
<TreeRow key={item.id} id={item.id} depth={item.depth} />
))}
</SortableContext>
<DragOverlay>
{activeId && (
<div
style={{
padding: 8,
backgroundColor: '#b2ebf2',
border: '1px solid #00796b',
borderRadius: 4,
}}
>
{activeId.split('/').pop()}
</div>
)}
</DragOverlay>
</DndContext>
</div>
);
}
구현된 기능
| 트리 계층 구조 | depth 값으로 들여쓰기 조절 |
| 중첩된 아이템 | id를 path (folder/item) 형태로 관리 |
| SortableContext | 단일 컨텍스트에서 visible item만 정렬 |
| 드래그 유령 표시 | DragOverlay로 현재 아이템 표시 |
| 정렬 | array.splice()로 위치 이동 |
설치 필요 모듈
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
실습 목표
- 폴더/아이템 트리 구조로 표시
- 각 요소는 들여쓰기(depth)로 시각적으로 표현
- 드래그 시 같은 부모 안에서만 위치 변경
- 계층(부모-자식 구조)은 유지하면서 위치만 바꾸기
- DragOverlay로 유령 아이템 표시
데이터 구조 설계
type TreeNode = {
id: string;
parentId: string | null;
title: string;
depth: number;
};
전체 코드 (기초 완료 버전)
// TreeFolderSortable.tsx
import React, { useState } from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
type TreeNode = {
id: string;
parentId: string | null;
title: string;
depth: number;
};
const initialData: TreeNode[] = [
{ id: 'folder-a', parentId: null, title: '📁 Folder A', depth: 0 },
{ id: 'file-a1', parentId: 'folder-a', title: '📄 File A1', depth: 1 },
{ id: 'file-a2', parentId: 'folder-a', title: '📄 File A2', depth: 1 },
{ id: 'folder-b', parentId: null, title: '📁 Folder B', depth: 0 },
{ id: 'file-b1', parentId: 'folder-b', title: '📄 File B1', depth: 1 },
];
function TreeItem({ node }: { node: TreeNode }) {
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: node.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
padding: 8,
marginBottom: 4,
paddingLeft: 20 * node.depth + 12,
backgroundColor: isDragging ? '#e0f7fa' : '#fff',
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'grab',
};
return (
<div ref={setNodeRef} {...attributes} {...listeners} style={style}>
{node.title}
</div>
);
}
export default function TreeFolderSortable() {
const [nodes, setNodes] = useState<TreeNode[]>(initialData);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
const activeNode = nodes.find((n) => n.id === activeId);
const handleDragStart = ({ active }: any) => {
setActiveId(active.id);
};
const handleDragEnd = ({ active, over }: any) => {
setActiveId(null);
if (!over || active.id === over.id) return;
const activeIndex = nodes.findIndex((n) => n.id === active.id);
const overIndex = nodes.findIndex((n) => n.id === over.id);
const movingNode = nodes[activeIndex];
const targetNode = nodes[overIndex];
// 부모가 다르면 이동 불가 (계층 유지)
if (movingNode.parentId !== targetNode.parentId) return;
const updated = [...nodes];
updated.splice(activeIndex, 1);
updated.splice(overIndex, 0, movingNode);
setNodes(updated);
};
return (
<div style={{ padding: 32 }}>
<h2>📂 트리 구조 정렬 (계층 유지)</h2>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={nodes.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
{nodes.map((node) => (
<TreeItem key={node.id} node={node} />
))}
</SortableContext>
<DragOverlay>
{activeNode && (
<div
style={{
padding: 8,
borderRadius: 4,
backgroundColor: '#b2ebf2',
border: '1px solid #0097a7',
}}
>
{activeNode.title}
</div>
)}
</DragOverlay>
</DndContext>
</div>
);
}
| 트리 구조 시각화 | depth를 기반으로 paddingLeft 설정 |
| 정렬 조건 | 같은 parentId를 갖는 항목끼리만 순서 변경 허용 |
| 유령 아이템 | DragOverlay로 드래그 중 표시 |
| 순서 변경 | array.splice() 사용한 재정렬 |
설치 필요한 모듈
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
6단계: 접근성과 사용자 경험 개선
실습 목표
- KeyboardSensor 커스터마이징 (접근성/키보드 정렬 지원)
- 드래그 중 DragOverlay 커스터마이징 (예쁜 유령 아이템)
- Hover 시 스타일 반응 추가
- 드래그/드롭 시 효과음 재생
- 렌더링 최적화: useMemo, React.memo 활용
1. KeyboardSensor 커스터마이징
import {
DndContext,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
<DndContext sensors={sensors}>
...
</DndContext>
기능:
- Tab, Shift+Tab으로 포커스 이동
- Enter로 드래그 시작 / Space로 드롭
- ↑, ↓로 항목 이동
2. DragOverlay 커스터마이징
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-out' }}>
{activeItem && (
<div
style={{
padding: 12,
backgroundColor: '#fffbe6',
border: '2px dashed #facc15',
borderRadius: 6,
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
fontWeight: 'bold',
}}
>
{activeItem.label}
</div>
)}
</DragOverlay>
기능:
- 유령 아이템을 시각적으로 강조
- 투명도, border, 그림자 등 설정 가능
3. Hover 시 스타일 변경
function DraggableItem({ id, label }) {
const [hover, setHover] = useState(false);
const style = {
padding: 10,
marginBottom: 6,
border: '1px solid #ccc',
borderRadius: 4,
backgroundColor: hover ? '#e6f7ff' : '#fff',
transition: 'background-color 0.2s ease',
};
return (
<div
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={style}
>
{label}
</div>
);
}
기능:
- Hover 시 부드러운 시각 피드백 적용
- group-hover, Tailwind도 활용 가능
4. 드래그/드롭 시 효과음 재생
import useSound from 'use-sound';
const [playDrag] = useSound('/sounds/drag-start.mp3');
const [playDrop] = useSound('/sounds/drag-end.mp3');
<DndContext
onDragStart={() => playDrag()}
onDragEnd={() => playDrop()}
>
- 오디오 효과는 use-sound 사용
- 깔끔한 UX: 드래그 시작/종료 이벤트에 효과음 연결
npm install use-sound
5. 렌더링 최적화
React.memo + useMemo
const MemoizedItem = React.memo(function MemoizedItem({ id, label }) {
console.log('Rendering:', id);
return <div>{label}</div>;
});
리스트 렌더 최적화 예시
const items = useMemo(() => computeVisibleItems(data), [data]);
목적:
- 대규모 리스트에서 불필요한 리렌더 방지
- transform, hover와 같이 자주 바뀌는 값만 최소한의 범위에서 업데이트
| 키보드 정렬 지원 | KeyboardSensor + sortableKeyboardCoordinates |
| 유령 아이템 커스터마이징 | DragOverlay |
| 시각 피드백 | onMouseEnter, hover, transition |
| 소리 피드백 | use-sound, onDragStart/onDragEnd |
| 성능 최적화 | React.memo, useMemo, useCallback |
필수 설치 모듈
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/accessibility use-sound
실습 목표
- 시각 피드백이 좋은 드래그 컴포넌트 만들기
- Hover 시 강조
- 드래그 중 음영과 컬러 변화
- DragOverlay로 유령 컴포넌트 표시
- 드래그 시작/끝에 부드러운 전환 애니메이션
- 키보드 기반 정렬 UI 컨트롤
- 방향키로 이동 (↑, ↓)
- Enter로 드래그 시작, Space로 드롭
- Tab/Shift+Tab으로 항목 선택 전환
실습 코드
// AccessibleSortableList.tsx
import React, { useState } from 'react';
import {
DndContext,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
DragOverlay,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
const initialItems = ['Apple', 'Banana', 'Carrot', 'Date'];
function SortableItem({ id }: { id: string }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isSorting,
over,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
padding: 12,
marginBottom: 8,
borderRadius: 6,
backgroundColor: isDragging
? '#ffe082'
: isSorting
? '#e0f7fa'
: over?.id === id
? '#c8e6c9'
: '#fff',
border: '1px solid #ccc',
boxShadow: isDragging ? '0 4px 12px rgba(0,0,0,0.2)' : 'none',
cursor: 'grab',
fontWeight: isDragging ? 'bold' : 'normal',
outline: 'none',
};
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={style}
tabIndex={0} // 키보드 탐색을 위해
>
{id}
</div>
);
}
export default function AccessibleSortableList() {
const [items, setItems] = useState(initialItems);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const activeItem = items.find((i) => i === activeId);
const handleDragStart = ({ active }: any) => {
setActiveId(active.id);
};
const handleDragEnd = ({ active, over }: any) => {
setActiveId(null);
if (!over || active.id === over.id) return;
const oldIndex = items.indexOf(active.id);
const newIndex = items.indexOf(over.id);
setItems(arrayMove(items, oldIndex, newIndex));
};
return (
<div style={{ padding: 32, maxWidth: 320 }}>
<h2 style={{ marginBottom: 12 }}>📦 정렬 가능한 키보드 접근 리스트</h2>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((id) => (
<SortableItem key={id} id={id} />
))}
</SortableContext>
<DragOverlay>
{activeItem && (
<div
style={{
padding: 12,
borderRadius: 6,
backgroundColor: '#fff3e0',
border: '2px dashed #ff9800',
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
fontWeight: 'bold',
}}
>
{activeItem}
</div>
)}
</DragOverlay>
</DndContext>
<p style={{ marginTop: 16, fontSize: 14, color: '#666' }}>
↑↓ 방향키로 항목을 이동하고<br />
Enter로 드래그 시작 / Space로 드롭
</p>
</div>
);
}
| 드래그 시 음영 및 강조 | isDragging, isSorting으로 배경색 변경 |
| 드래그 오버 시 시각적 표시 | over?.id === id일 때 색상 변화 |
| 키보드 조작 | KeyboardSensor + sortableKeyboardCoordinates |
| 포커스 가능 항목 | tabIndex={0} 설정 필수 |
| 유령 아이템 오버레이 | DragOverlay에 커스텀 스타일 적용 |
설치 필요 모듈
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/accessibility
7단계: 성능 최적화 및 실전 적용
실습 목표
- React.memo로 불필요한 렌더링 방지
- useCallback, useMemo로 참조 안정성 유지
- react-virtual로 대규모 리스트 가상화 + drag 연동
- Zustand 등 상태관리와 drag-drop 연동
- 실제 CMS에서 사용되는 태그 정렬/편집 UI 구성
1. React.memo + useCallback + useMemo
const SortableItem = React.memo(function SortableItem({ id, label }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
backgroundColor: isDragging ? '#ffe082' : '#fff',
padding: 12,
marginBottom: 8,
}),
[transform, isDragging]
);
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{label}
</div>
);
});
- React.memo: props가 바뀌지 않으면 재렌더링 방지
- useMemo: style 객체 재생성 방지
- useCallback: 이벤트 핸들러 참조 안정화
2. react-virtual로 리스트 가상화 + dnd-kit 연동
dnd-kit은 리스트를 직접 렌더링하지 않기 때문에 react-virtual과 호환됩니다.
핵심 구성
npm install @tanstack/react-virtual
예시 (스크롤 최적화만 포함)
import { useVirtualizer } from '@tanstack/react-virtual';
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
});
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index];
return (
<div
key={item.id}
style={{
position: 'absolute',
top: virtualRow.start,
left: 0,
width: '100%',
}}
>
<SortableItem id={item.id} label={item.label} />
</div>
);
})}
</div>
</div>
);
이와 함께 SortableContext는 항상 전체 아이템을 인식하고 있어야 하므로 items.map(i => i.id)는 유지되어야 함
3. Zustand 등 상태관리와 연동
npm install zustand
예시: 태그 리스트 상태 저장소
// useTagStore.ts
import { create } from 'zustand';
type TagState = {
tags: string[];
setTags: (t: string[]) => void;
};
export const useTagStore = create<TagState>((set) => ({
tags: [],
setTags: (tags) => set({ tags }),
}));
드래그 이벤트에서 상태 반영
const tags = useTagStore((state) => state.tags);
const setTags = useTagStore((state) => state.setTags);
onDragEnd={({ active, over }) => {
const oldIndex = tags.indexOf(active.id);
const newIndex = tags.indexOf(over.id);
setTags(arrayMove(tags, oldIndex, newIndex));
});
4. 실제 CMS 예제: 태그 정렬 UI
목표
- 태그를 드래그로 순서 변경
- 순서 변경 후 서버로 PUT /tags/reorder 요청
- Zustand로 상태 저장
UI 예제
export default function TagManager() {
const tags = useTagStore((s) => s.tags);
const setTags = useTagStore((s) => s.setTags);
const handleDragEnd = ({ active, over }) => {
if (!over) return;
const oldIndex = tags.findIndex((t) => t === active.id);
const newIndex = tags.findIndex((t) => t === over.id);
const newOrder = arrayMove(tags, oldIndex, newIndex);
setTags(newOrder);
fetch('/api/tags/reorder', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: newOrder }),
});
};
return (
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={tags} strategy={verticalListSortingStrategy}>
{tags.map((tag) => (
<SortableItem key={tag} id={tag} label={tag} />
))}
</SortableContext>
</DndContext>
);
}
| 대규모 성능 최적화 | react-virtual, React.memo |
| 재계산 최적화 | useMemo, useCallback |
| 상태 연동 | Zustand, Redux, useForm 등 |
| 실전 통합 | CMS 태그 정렬, 섹션/페이지 순서 지정 등 |
목표
- 1000개 아이템 리스트를 dnd-kit으로 정렬 가능하게 구현
- **React.memo, useMemo, useCallback**을 이용한 렌더링 최적화
- Zustand를 통한 전역 상태 관리
- (선택 확장) react-virtual을 이용한 가상 스크롤링 연동
1단계: 초기 상태 구성
Zustand 전역 상태 선언
// useItemStore.ts
import { create } from 'zustand';
import { arrayMove } from '@dnd-kit/sortable';
type State = {
items: string[];
moveItem: (fromIndex: number, toIndex: number) => void;
};
export const useItemStore = create<State>((set) => ({
items: Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`),
moveItem: (from, to) =>
set((state) => ({
items: arrayMove(state.items, from, to),
})),
}));
2단계: React.memo로 렌더링 최적화
Sortable Item (렌더링 최적화된 컴포넌트)
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React from 'react';
export const SortableItem = React.memo(function SortableItem({ id }: { id: string }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = React.useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
padding: '8px 12px',
marginBottom: 4,
backgroundColor: isDragging ? '#fff9c4' : '#fafafa',
border: '1px solid #ccc',
borderRadius: 4,
}), [transform, isDragging]);
return (
<div ref={setNodeRef} {...attributes} {...listeners} style={style}>
{id}
</div>
);
});
3단계: 메인 정렬 UI 구성
// App.tsx
import React from 'react';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useItemStore } from './useItemStore';
import { SortableItem } from './SortableItem';
export default function App() {
const items = useItemStore((state) => state.items);
const moveItem = useItemStore((state) => state.moveItem);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = ({ active, over }: any) => {
if (!over || active.id === over.id) return;
const oldIndex = items.indexOf(active.id);
const newIndex = items.indexOf(over.id);
moveItem(oldIndex, newIndex);
};
return (
<div style={{ padding: 32, maxWidth: 400, height: '100vh', overflowY: 'scroll' }}>
<h2>📦 1000개 아이템 정렬 (Zustand + 최적화)</h2>
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((id) => (
<SortableItem key={id} id={id} />
))}
</SortableContext>
</DndContext>
</div>
);
}
성능 팁
| React.memo | SortableItem 컴포넌트 |
| useMemo | style 계산 캐싱 |
| Zustand selector | useItemStore((s) => s.items) 로 리렌더 최소화 |
| arrayMove | 순서 재정렬 유틸 사용 |
성능 확장 (선택)
react-virtual 연동시 주의사항
- items.map(...) 대신 virtualItems.map(...)으로 출력
- 드래그 가능한 요소는 여전히 전체 리스트에서 참조돼야 하므로 SortableContext는 전체 id 리스트를 사용해야 함
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities zustand
'JavaScript > React' 카테고리의 다른 글
| wouter (0) | 2025.06.09 |
|---|---|
| React-Spring (3) | 2025.05.23 |
| React Hook Form (1) | 2025.05.15 |
| React Query (1) | 2025.05.15 |
| Zustand(persist, Zukeeper, Redux DevTools 등) (0) | 2025.04.02 |