Recently, I came across a problem where I need to make moving blocks that resemble menus. I needed the functionality of dragging buttons in any direction and side by side.
After researching the libraries, I chose DnD Kit. There we are greeted by cool documentation and lots of examples. The most important thing we need are areas where we can drag and drop items and customize the functionality very effectively. The main thing here is using hooks: when we move, this is the useDraggable hook and the area where we insert useDroppable. Well, and all sorts of additions in the form of animations and sorting in a row.
UI and preparation
Download the libraries
yarn add @dnd-kit/core @dnd-kit/sortable
We are not wrapping the entire application, but a specific component where DnD will occur.
We put the sensors in context. We will use them to determine how to handle dragging blocks with touch or mouse.
After experimenting with collision algorithms, I chose collisionDetection=pointerWithin
. The most optimal for our case. The algorithm is guided by the coordinates of the click and the intersecting blocks with it.
Let’s create state of the active block that we are dragging. We will write its id on the handleDragStart handler. At the end of dragging, we will call handleDragEnd and reset the state. And the handleDragOver handler will be called when our block is above the possible insertion area.
import {
pointerWithin,
useSensors,
} from "@dnd-kit/core";
const sensorSettings = {
distance: 2,
};
export default function DndRoot() {
const [activeDndItemId, setActiveDndItemId] = useState<null | number>(null);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: sensorSettings,
}),
useSensor(PointerSensor, {
activationConstraint: sensorSettings,
}),
);
const handleDragStart = ({active}: DragStartEvent) => {
setActiveDndItemId(active.id as number);
};
const handleDragEnd = ({over}: DragOverEvent) => {
setActiveDndItemId(null);
};
const handleDragOver = ({active, over}: DragOverEvent) => {
// Обработаем позже
}
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}>
{/* Cюда будем добавлять элементы */}
</DndContext>
);
}
Next, I suggest adding the state of our buttons to the DnDRoot component or higher. The order field will be responsible for the order of the buttons within the row. RowNumber for the location of the row itself. We start indexing for both fields from 1. This way we can easily store our button state in the database. You can also immediately add the initial state for the buttons (at the very bottom there will be a link to codesandbox, you can take it from there).
interface IButton {
id: number;
text: string;
color: string;
order: number;
rowNumber: number;
}
const [buttons, setButtons] = useState<IButton[]>(initialButtons);
Go to the Button component. An array of such buttons will be rendered in each row. Here, when isDragging, we hide it so that later we can make a beautiful animation using a special reference point and visually look like the same button in another place. Css Transform and transition need to ensure the correct location when sorting in a row. Ids play a big role here and in the rows, so they must be unique. useSortable will monitor the user’s ref and events, for example, pressing and holding a button on this block. We’ll throw the necessary props from there into the button.
export interface IDndBtnProps {
btn: IButton;
rowLength: number;
}
export const DndBtn = ({ btn, rowLength }: IDndBtnProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: btn.id,
});
const btnStyle = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1,
};
return (
<Button
style={{
...btnStyle,
background: btn.color,
width: `${100 / rowLength}%`,
}}
className={cn(styles.btn, styles.btnHover)}
ref={setNodeRef}
{...listeners}
{...attributes}
>
{btn.text}
</Button>
);
};
Component of the row. We will add sorting and rows to the array from the outside in DndRoot, and here we map the array of buttons of this row from the row: iButton[]
.
SortableContext here performs the obvious role of sorting according to a given strategy, in this case, it sorts only horizontally. It will temporarily swap the blocks in the row. But we will change our order property only in handleDragEnd, when the user has already decided on the choice and releases the click.
interface IDndRowProps {
row: IButton[];
rowId: string;
}
export const DndRow = ({ row, rowId }: IDndRowProps) => {
const { setNodeRef } = useDroppable({
id: rowId,
});
return (
<SortableContext
id={rowId}
items={row}
strategy={horizontalListSortingStrategy}
>
<div className={styles.row} ref={setNodeRef}>
{row.map((btn) => (
<DndBtn rowLength={row.length} key={btn.id} btn={btn} />
))}
</div>
</SortableContext>
);
};
Here we go through all our buttons and arrange them in rows based on the rowNumber property. We also sort in a row by the order property. Now we have the entire UI ready, and the hooks can handle dnd.
export default function DndRoot() {
const getButtons = () => {
if (!buttons || !buttons.length) return null;
let res: IButton[][] = [];
buttons
?.sort((a, b) => a.order - b.order)
.forEach((btn) => {
if (!res[btn.rowNumber]) res[btn.rowNumber] = [];
res[btn.rowNumber] = [...res[btn.rowNumber], btn];
});
res = res.filter((el) => el);
return res.map((row, i) => <DndRow key={i} rowId={`row${i}`} row={row} />);
};
return (
<DndContext
...
>
{getButtons()}
</DndContext>
);
}
DND logic
Let’s move on to our 3 handlers from the root. We already store ids of the dragged block in activeDndItemId. The useSortable hook in the button itself tracks the user’s events in this block, so in the received parameter active.id handleDragStart = ({active}: DragStartEvent)
will have our button id.
In handleDragOver = ({active, over}: DragOverEvent)
, we can check where our movable block (over
) is currently located, and which block is active. As you remember, we used a special string id (`row${i}`) in the row, so we won’t process it here. We compare that the over
block id differs from the active block id and they exist. Then we will change the rowNumber field at our button.
In handleDragEnd = ({ over }: DragOverEvent)
, we change the order. Our sorting hooks visually change the horizontal position of the buttons in a row. But when we release the block, the order will not change. Therefore, at the end we fix it in state.
And who will recalculate the row Number AND order if we want to add new rows, or move all the buttons of one row to another row, or move the last button before the first in the same row?
After each row change, we need to recalculate their order in state. To avoid such discrepancies:
[
{ ... id: 1, rowNumber: 1 },
{ ... id: 2, rowNumber: 5 }
]
The rowNumber has changed with gap after dragging the buttons from the original rows to others and disappearing the first ones. The same should be done for order. For the sake of experiment, we make a prompt in chatGPT and generate our algorithms:
const updateButtonsRowOrder = (
array: IButton[],
id: number,
newOrder: number
) => {
const item = array.find((el) => el.id === id);
if (!item) return array; // If the id is not found, we return the array unchanged.
// Removing an element from the array and sorting the remaining elements by order
const filteredArray = array
.filter((el) => el.id !== id)
.sort((a, b) => a.order - b.order);
// Inserting the element with the new order in the desired position
filteredArray.splice(newOrder - 1, 0, { ...item, order: newOrder });
// We just inserted the necessary element into the array and so the array is sorted, we will replace all the order with index+1.
return filteredArray.map((el, index) => ({ ...el, order: index + 1 }));
};
const updateButtonsRowNumber = (
array: IButton[],
id: number,
newRowNumber: number
) => {
const item = array.find((el) => el.id === id);
if (!item) return array; // If the id is not found, we return the array unchanged.
// Updating the row Number of the specified element
item.rowNumber = newRowNumber;
// Sorting the array by RowNumber
const sortedArray = array.sort((a, b) => a.rowNumber - b.rowNumber);
// Recalculating the RowNumber so that there are no gaps
const uniqueRowNumbers = [
...new Set(sortedArray.map((el) => el.rowNumber)),
].sort((a, b) => a - b);
const mapping = new Map(
uniqueRowNumbers.map((num, index) => [num, index + 1])
);
// We find the RowNumber in mapping and take the index+1 from mapping
sortedArray.forEach((el) => {
el.rowNumber = mapping.get(el.rowNumber) ?? el.rowNumber;
});
return sortedArray;
};
Now we insert the algorithms into the handlers:
const handleDragEnd = ({ over }: DragOverEvent) => {
if (!activeDndItemId) return;
const overIndex = over?.data.current?.sortable.index || 0;
setButtons((prev) => {
const res = prev.map((btn) => {
if (btn.id === activeDndItemId) {
return {
...btn,
order: overIndex + 1,
};
}
return btn;
});
return updateButtonsRowOrder(res, activeDndItemId, overIndex + 1);
});
setActiveDndItemId(null);
};
const handleDragOver = ({ active, over }: DragOverEvent) => {
const activeBtnId = active.id;
const overBtnId = over?.id;
const activeRowNumber = buttons?.find(
(btn) => btn.id === activeBtnId
)?.rowNumber;
const overRowNumber = buttons?.find(
(btn) => btn.id === overBtnId
)?.rowNumber;
if (
!activeDndItemId ||
!activeRowNumber ||
!overRowNumber ||
activeRowNumber === overRowNumber
) {
return;
}
setButtons((prev) => {
const res = prev.map((btn) => {
if (btn.id === activeDndItemId) {
return {
...btn,
rowNumber: overRowNumber,
};
}
return btn;
});
return updateButtonsRowNumber(res, activeDndItemId, overRowNumber);
});
};
Animation at DND
At the end, we make a beautiful animation when transferring. We will actually insert a copy of our button into a special render from the library, and it will render it. This approach is recommended for complex cases and smooth animations.
const overlayItem = useMemo(() => {
return buttons?.find((btn) => btn.id === activeDndItemId);
}, [activeDndItemId, buttons]);
<DndContext ...>
{getButtons()}
<DragOverlay dropAnimation={{...defaultDropAnimation}}>
{overlayItem ? (
<Button
style={{
zIndex: 999,
background: overlayItem.color,
width: `100%`,
}}
type="primary"
className={rootStyles.menuBtn}>
{getButtonText(overlayItem.text, MAX_BUTTONS_IN_ROW)}
</Button>
) : null}
</DragOverlay>
</DndContext>
What’s next?
It turned out to be a lot of code, provided that we did not add any complications: cropping the text, exceeding the size of the button, adjusting the maximum of buttons in a row, etc.
You can also add an insert above or below any row, using our special id row${i}
or just a regular button at the bottom (as in the video) with the addition of a row and a new block in it. All the functionality in addition to the base can be improved using our 3 handlers in the root.
I also provide a
to the sandbox with the full code from this article.
Good luck with DND!