Реалізація Drag-and-Drop інтерфейсу (Kanban-дошка) на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

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

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Drag-and-Drop інтерфейсу (Kanban-дошка) на сайті
Складна
~1-2 тижні
Часті питання

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

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація Drag-and-Drop інтерфейсу (Kanban-доска) на сайті

Drag-and-drop на перший погляд здається простою задачею — поки не почнеш розбиратися з touch-пристроями, автоскролом при перетаскуванні до краю, вкладеними контейнерами та accessibility. HTML5 Drag-and-Drop API покриває базові сценарії, але для Kanban-дошки з кількома колонками та вкладеними списками потрібна бібліотека.

Вибір бібліотеки

@dnd-kit/core — сучасний стандарт для React. Працює з touch та keyboard, підтримує accessibility з коробки, не залежить від DOM-порядку елементів. Розмір ~10 КБ gzipped.

react-beautiful-dnd — популярна, але розробка заморожена. Використовувати в нових проектах не варто.

Sortable.js — ванільний варіант, працює з будь-яким фреймворком, але потребує більше ручної роботи при інтеграції з React state.

Встановлення dnd-kit

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 в потрібних колонках.

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, // пиксели до початку DnD — щоб не заважати кликам
      },
    }),
    useSensor(TouchSensor, {
      activationConstraint: {
        delay: 250,       // затримка на touch — уникаємо конфлікту зі scroll
        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

    // Перемішуємо карточку між колонками під час drag (live preview)
    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))
    }
    // Між колонками вже обробилося в handleDragOver
  }

  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[] }) {
  // Колонка як drop-target для порожніх зон
  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.

Що робимо

Проектуємо структуру даних під конкретну задачу (може бути дошка задач, воронка продажу, редактор контенту). Налаштовуємо DnD з підтримкою touch та клавіатури, реалізуємо live preview через DragOverlay, підключаємо синхронізацію з API з оптимістичними оновленнями та відкатом при помилці.

Термін: базова Kanban-дошка — 3–4 дні. З сортуванням колонок, вкладеними задачами та оффлайн-синхронізацією — 6–8 днів.