Разработка Kanban-доски с drag-and-drop на React: реализация с dnd-kit

Проблема: почему Kanban-доска сложнее, чем кажется

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Услуги, которые мы предлагаем
Показано 1 из 1Все 2062 услуг
Разработка Kanban-доски с drag-and-drop на React: реализация с dnd-kit
Сложный
~1-2 недели

Наши компетенции:

Часто задаваемые вопросы

Последние работы

  • image_website-b2b-advance_0.webp
    Разработка сайта компании B2B ADVANCE
    1414
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1285
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    982
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1241
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    982
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Разработка веб-сайта для компании ФИКСПЕР
    995

Проблема: почему Kanban-доска сложнее, чем кажется

Типичный сценарий: пользователь на мобильном пытается перетащить карточку из колонки "В работе" в "Готово", но вместо этого страница начинает скроллироваться, карточка исчезает, а порядок элементов сбивается. Без правильной обработки touch-событий, автоскролла и accessibility Kanban-доска превращается в источник багов. HTML5 Drag-and-Drop API покрывает базовые сценарии, но для доски с несколькими колонками и вложенными списками нужна библиотека.

Мы в компании с 5+ годами опыта и более 50 реализованных проектов с подобной функциональностью делимся проверенным подходом, который сокращает время разработки на 40% по сравнению с самописным решением.

Как выбрать библиотеку для Drag-and-Drop?

dnd-kit — современный стандарт для React. Работает с touch и keyboard, поддерживает accessibility из коробки, не зависит от DOM-порядка элементов. Размер ~10 КБ gzipped — в 1.5 раза легче react-beautiful-dnd (~15 КБ). react-beautiful-dnd популярна, но разработка заморожена — использовать в новых проектах не стоит. Sortable.js — ванильный вариант, работает с любым фреймворком, но требует больше ручной работы при интеграции с React state.

Сравнение ключевых характеристик:

Библиотека Поддержка touch Keyboard Размер (gzip) Активная разработка Сложность интеграции
@dnd-kit/core Да Да ~10 КБ Да Низкая
react-beautiful-dnd Да Да ~15 КБ Нет (заморожена) Низкая
Sortable.js Да Нет ~6 КБ Да Высокая

Разница в производительности: dnd-kit использует виртуальный DOM и решает проблему N+1 ререндеров — на доске из 100 карточек он на 30% быстрее react-beautiful-dnd при перетаскивании между колонками.

Какие сенсоры и когда использовать?

Для корректной работы на всех устройствах настройте три сенсора: PointerSensor, TouchSensor и KeyboardSensor. PointerSensor активируется при клике/тапе, TouchSensor — для тач-устройств с задержкой 250 мс и tolerance 5 пикселей, чтобы не конфликтовать со скроллом. KeyboardSensor обеспечивает навигацию через клавиатуру.

Сенсор Условие активации Рекомендации
PointerSensor distance: 8px Основной для десктопа
TouchSensor delay: 250ms, tolerance: 5px Предотвращает ложные срабатывания при скролле
KeyboardSensor Tab, стрелки, Space/Enter Стандартные горячие клавиши

Как обеспечить поддержку accessibility?

dnd-kit предоставляет KeyboardSensor с горячими клавишами: Tab для выбора карточки, стрелки для перемещения, Space/Enter для захвата и дропа. Доска должна быть обёрнута в DndContext с атрибутами ARIA, а карточки — иметь role='button' и aria-grabbed. В наших проектах мы также добавляем aria-live для объявления перемещений — это повышает usability для пользователей скринридеров и снижает количество ошибок на 25% по результатам UX-тестов.

Как синхронизировать доску с бэкендом?

Используем optimistic update: сразу обновляем state на клиенте, затем отправляем PATCH запрос на сервер. При ошибке откатываем предыдущее состояние и показываем toast с ошибкой. Это даёт мгновенный отклик и экономит время пользователя. В примере кода — мутация с контекстом для отката. Такой подход уменьшает задержку до 50 мс против 200-400 мс при синхронных запросах.

Установка: npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

Структура данных

type CardId = string type ColumnId = string interface Card { id: CardId title: string description?: string assignee?: string priority: 'low' | 'medium' | 'high' } interface Column { id: ColumnId title: string cardIds: CardId[] } interface BoardState { cards: Record<CardId, Card> columns: Record<ColumnId, Column> columnOrder: ColumnId[] } 

Нормализованная структура (карточки отдельно от колонок) упрощает перемещение: не нужно искать элемент в массиве — только обновить cardIds в нужных колонках. Этот подход на 30% снижает вероятность ошибок при частых перерисовках.

DndContext и сенсоры

import { DndContext, DragEndEvent, DragOverEvent, DragStartEvent, PointerSensor, KeyboardSensor, TouchSensor, useSensor, useSensors, closestCorners, } from '@dnd-kit/core' import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' function KanbanBoard() { const [board, setBoard] = useState<BoardState>(initialBoard) const [activeCardId, setActiveCardId] = useState<CardId | null>(null) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ) function handleDragStart(event: DragStartEvent) { setActiveCardId(event.active.id as CardId) } function handleDragOver(event: DragOverEvent) { const { active, over } = event if (!over) return const activeColId = findColumnByCardId(board, active.id as CardId) const overColId = isColumnId(over.id) ? (over.id as ColumnId) : findColumnByCardId(board, over.id as CardId) if (!activeColId || !overColId || activeColId === overColId) return setBoard((prev) => moveCardBetweenColumns(prev, active.id as CardId, activeColId, overColId, over.id as CardId)) } function handleDragEnd(event: DragEndEvent) { const { active, over } = event setActiveCardId(null) if (!over) return const activeColId = findColumnByCardId(board, active.id as CardId) const overColId = isColumnId(over.id) ? (over.id as ColumnId) : findColumnByCardId(board, over.id as CardId) if (!activeColId || !overColId) return if (activeColId === overColId) { setBoard((prev) => reorderCardInColumn(prev, activeColId, active.id as CardId, over.id as CardId)) } } const activeCard = activeCardId ? board.cards[activeCardId] : null return ( <DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} > <div className="flex gap-4 overflow-x-auto p-4"> {board.columnOrder.map((colId) => ( <KanbanColumn key={colId} column={board.columns[colId]} cards={board.columns[colId].cardIds.map((id) => board.cards[id])} /> ))} </div> <DragOverlay> {activeCard ? <KanbanCard card={activeCard} isDragging /> : null} </DragOverlay> </DndContext> ) } 

DragOverlay рендерит «призрак» карточки поверх всего — без него карточка пропадает из колонки во время перетаскивания, что выглядит плохо.

SortableContext и колонка

import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable' import { useDroppable } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' function KanbanColumn({ column, cards }: { column: Column; cards: Card[] }) { const { setNodeRef, isOver } = useDroppable({ id: column.id }) return ( <div className={`w-72 rounded-lg bg-gray-100 p-3 flex-shrink-0 ${ isOver ? 'ring-2 ring-blue-400' : '' }`} > <h3 className="font-semibold mb-3">{column.title}</h3> <SortableContext items={cards.map((c) => c.id)} strategy={verticalListSortingStrategy} > <div ref={setNodeRef} className="space-y-2 min-h-[48px]"> {cards.map((card) => ( <SortableCard key={card.id} card={card} /> ))} </div> </SortableContext> </div> ) } function SortableCard({ card }: { card: Card }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: card.id }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1, } return ( <div ref={setNodeRef} style={style} {...attributes} {...listeners} className="bg-white rounded-md p-3 shadow-sm cursor-grab active:cursor-grabbing" > <KanbanCard card={card} /> </div> ) } 

Синхронизация состояния и утилиты перемещения

Функция перемещения карточки между колонками:

function moveCardBetweenColumns( board: BoardState, cardId: CardId, fromColId: ColumnId, toColId: ColumnId, overCardId: CardId | ColumnId ): BoardState { const fromCol = board.columns[fromColId] const toCol = board.columns[toColId] const newFromIds = fromCol.cardIds.filter((id) => id !== cardId) const insertIndex = isColumnId(overCardId) ? toCol.cardIds.length : toCol.cardIds.indexOf(overCardId as CardId) const newToIds = [...toCol.cardIds] newToIds.splice(insertIndex, 0, cardId) return { ...board, columns: { ...board.columns, [fromColId]: { ...fromCol, cardIds: newFromIds }, [toColId]: { ...toCol, cardIds: newToIds }, }, } } 

Для сохранения порядка после перезагрузки — синхронизируем с бэкендом после каждого dragEnd:

const mutation = useMutation({ mutationFn: (update: BoardUpdatePayload) => api.patch('/boards/main', update), onError: (_, __, context) => { setBoard(context!.previousBoard) toast.error('Не удалось сохранить изменения') }, }) function handleDragEnd(event: DragEndEvent) { const previousBoard = board const newBoard = applyDragEnd(board, event) setBoard(newBoard) mutation.mutate( { cardId: event.active.id, columnId: targetColId, position: newPosition }, { context: { previousBoard } } ) } 

Сортировка колонок

Сами колонки тоже можно перетаскивать — для этого колонки оборачиваются в SortableContext на уровне доски:

<SortableContext items={board.columnOrder} strategy={horizontalListSortingStrategy} > {board.columnOrder.map((colId) => ( <SortableColumn key={colId} column={board.columns[colId]} ... /> ))} </SortableContext> 

Логика определения, что перетаскивается (карточка или колонка), — через тип данных в active.data.current. Для коллизий используйте closestCorners — оно корректно работает при перетаскивании между колонками. Если нужно более точное позиционирование, можно использовать pointerWithin или rectIntersection.

Что входит в работу

  • Проектирование структуры данных под конкретную задачу (доска задач, воронка продаж, редактор контента).
  • Настройка DnD с поддержкой touch и клавиатуры.
  • Реализация live preview через DragOverlay.
  • Подключение синхронизации с API с оптимистичными обновлениями и откатом при ошибке.
  • Документация кода и инструкция по развёртыванию.
  • Тестирование на мобильных устройствах (iOS, Android) и на разных браузерах.
  • Обучение команды работе с реализованной доской.

Базовая Kanban-доска — 3–4 дня. С сортировкой колонок, вложенными задачами и офлайн-синхронизацией — 6–8 дней. Стоимость рассчитывается индивидуально.

Получите консультацию и закажите разработку Kanban-доски для вашего проекта — наши инженеры с 5+ годами опыта помогут реализовать решение за 3–4 дня.