Created
May 12, 2026 11:52
-
-
Save colmtuite/1dea505984ca04f0068e25a1d3692d4a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 'use client'; | |
| import * as React from 'react'; | |
| import clsx from 'clsx'; | |
| import { | |
| dropTarget, | |
| monitorForElements, | |
| useDraggable, | |
| useIsDragging, | |
| } from '@base-ui/plus/drag-engine'; | |
| import { useStableCallback } from '@base-ui/utils/useStableCallback'; | |
| import styles from './kanban-snap.module.css'; | |
| // Demonstrates a Trello-style "snap to closest column" pattern using only | |
| // `monitorForElements`. Columns are intentionally not registered as drop | |
| // targets: the monitor reads the pointer position on every drag event and | |
| // resolves the horizontally-closest column from a consumer-maintained map of | |
| // column elements. Drops land in whichever column is closest at release time, | |
| // even when the pointer is outside every column. | |
| type ColumnId = string; | |
| type CardId = string; | |
| interface Card { | |
| id: CardId; | |
| title: string; | |
| } | |
| interface Column { | |
| id: ColumnId; | |
| title: string; | |
| cardIds: CardId[]; | |
| } | |
| interface Board { | |
| columnOrder: ColumnId[]; | |
| columns: Record<ColumnId, Column>; | |
| cards: Record<CardId, Card>; | |
| } | |
| const CARD_KIND = 'kanbanSnap:card'; | |
| interface CardDragData { | |
| id: CardId; | |
| fromColumn: ColumnId; | |
| } | |
| function buildInitialBoard(): Board { | |
| const columns: Column[] = [ | |
| { id: 'todo', title: 'Todo', cardIds: ['c1', 'c2', 'c3'] }, | |
| { id: 'in-progress', title: 'In progress', cardIds: ['c4', 'c5'] }, | |
| { id: 'review', title: 'Review', cardIds: ['c6'] }, | |
| { id: 'done', title: 'Done', cardIds: ['c7', 'c8'] }, | |
| ]; | |
| const cards: Card[] = [ | |
| { id: 'c1', title: 'Write spec' }, | |
| { id: 'c2', title: 'Sketch UI' }, | |
| { id: 'c3', title: 'Set up project' }, | |
| { id: 'c4', title: 'Wire the API' }, | |
| { id: 'c5', title: 'Build the form' }, | |
| { id: 'c6', title: 'Self review' }, | |
| { id: 'c7', title: 'Ship v0' }, | |
| { id: 'c8', title: 'Tell the team' }, | |
| ]; | |
| return { | |
| columnOrder: columns.map((c) => c.id), | |
| columns: Object.fromEntries(columns.map((c) => [c.id, c])), | |
| cards: Object.fromEntries(cards.map((c) => [c.id, c])), | |
| }; | |
| } | |
| function findClosestColumn(clientX: number, elements: Map<ColumnId, HTMLElement>): ColumnId | null { | |
| let bestId: ColumnId | null = null; | |
| let bestDx = Infinity; | |
| for (const [id, el] of elements) { | |
| const rect = el.getBoundingClientRect(); | |
| const center = rect.left + rect.width / 2; | |
| const dx = Math.abs(clientX - center); | |
| if (dx < bestDx) { | |
| bestDx = dx; | |
| bestId = id; | |
| } | |
| } | |
| return bestId; | |
| } | |
| export default function KanbanSnap() { | |
| const [board, setBoard] = React.useState<Board>(buildInitialBoard); | |
| const [activeColumnId, setActiveColumnId] = React.useState<ColumnId | null>(null); | |
| const columnElementsRef = React.useRef<Map<ColumnId, HTMLElement>>(new Map()); | |
| const registerColumnElement = useStableCallback((id: ColumnId, el: HTMLElement | null) => { | |
| if (el) { | |
| columnElementsRef.current.set(id, el); | |
| } else { | |
| columnElementsRef.current.delete(id); | |
| } | |
| }); | |
| const moveCard = useStableCallback((cardId: CardId, fromColumn: ColumnId, toColumn: ColumnId) => { | |
| setBoard((prev) => { | |
| const from = prev.columns[fromColumn]; | |
| const to = prev.columns[toColumn]; | |
| if (!from || !to) { | |
| return prev; | |
| } | |
| if (fromColumn === toColumn) { | |
| return prev; | |
| } | |
| return { | |
| ...prev, | |
| columns: { | |
| ...prev.columns, | |
| [fromColumn]: { ...from, cardIds: from.cardIds.filter((id) => id !== cardId) }, | |
| [toColumn]: { ...to, cardIds: [...to.cardIds, cardId] }, | |
| }, | |
| }; | |
| }); | |
| }); | |
| // Catch-all drop target on the document body. Two reasons it lives on | |
| // `<body>` rather than the experiment root: | |
| // 1. Native drag-and-drop animates the drag image back to the source | |
| // ("snap-back") when releasing outside any registered drop target. | |
| // Putting the catch-all on `<body>` guarantees every release on the | |
| // page is a valid drop, suppressing the animation. | |
| // 2. It lets the monitor distinguish a real release from an Escape | |
| // cancel: the engine clears `dropTargets` on cancel, so a non-empty | |
| // stack at drop time means the user actually released. | |
| React.useEffect(() => { | |
| return dropTarget<CardDragData>({ | |
| element: document.body, | |
| acceptKinds: [CARD_KIND], | |
| }); | |
| }, []); | |
| React.useEffect(() => { | |
| return monitorForElements<CardDragData>({ | |
| acceptKinds: [CARD_KIND], | |
| onDragStart: ({ location }) => { | |
| setActiveColumnId( | |
| findClosestColumn(location.current.input.clientX, columnElementsRef.current), | |
| ); | |
| }, | |
| onDrag: ({ location }) => { | |
| setActiveColumnId( | |
| findClosestColumn(location.current.input.clientX, columnElementsRef.current), | |
| ); | |
| }, | |
| onDrop: ({ source, location }) => { | |
| const cancelled = location.current.dropTargets.length === 0; | |
| if (!cancelled) { | |
| const target = findClosestColumn( | |
| location.current.input.clientX, | |
| columnElementsRef.current, | |
| ); | |
| if (target) { | |
| moveCard(source.data.id, source.data.fromColumn, target); | |
| } | |
| } | |
| setActiveColumnId(null); | |
| }, | |
| }); | |
| }, [moveCard]); | |
| return ( | |
| <div className={styles.root}> | |
| <header> | |
| <h1 className={styles.title}>Snap-to-closest column</h1> | |
| <p className={styles.subtitle}> | |
| Drag a card anywhere on this page — including above, below, or to the side of the board. | |
| The card always drops into the horizontally-closest column. | |
| </p> | |
| </header> | |
| <div className={styles.board}> | |
| {board.columnOrder.map((id) => { | |
| const column = board.columns[id]; | |
| return ( | |
| <KanbanColumn | |
| key={id} | |
| column={column} | |
| cards={column.cardIds.map((cardId) => board.cards[cardId])} | |
| isActive={activeColumnId === id} | |
| registerElement={registerColumnElement} | |
| /> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function KanbanColumn({ | |
| column, | |
| cards, | |
| isActive, | |
| registerElement, | |
| }: { | |
| column: Column; | |
| cards: Card[]; | |
| isActive: boolean; | |
| registerElement: (id: ColumnId, el: HTMLElement | null) => void; | |
| }) { | |
| const setRef = React.useCallback( | |
| (el: HTMLDivElement | null) => { | |
| registerElement(column.id, el); | |
| }, | |
| [column.id, registerElement], | |
| ); | |
| return ( | |
| <div ref={setRef} className={clsx(styles.column, isActive && styles.columnActive)}> | |
| <div className={styles.columnHeader}>{column.title}</div> | |
| <div className={styles.columnBody}> | |
| {cards.map((card) => ( | |
| <DraggableCard key={card.id} card={card} columnId={column.id} /> | |
| ))} | |
| {cards.length === 0 && <div className={styles.emptyHint}>(empty)</div>} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function DraggableCard({ card, columnId }: { card: Card; columnId: ColumnId }) { | |
| const { ref, dragPreview } = useDraggable<CardDragData, HTMLDivElement>({ | |
| id: card.id, | |
| kind: CARD_KIND, | |
| getInitialData: () => ({ id: card.id, fromColumn: columnId }), | |
| renderDragPreview: () => <div className={styles.preview}>{card.title}</div>, | |
| }); | |
| const isDragging = useIsDragging({ id: card.id }); | |
| return ( | |
| <React.Fragment> | |
| <div ref={ref} className={clsx(styles.card, isDragging && styles.cardDragging)}> | |
| {card.title} | |
| </div> | |
| {dragPreview} | |
| </React.Fragment> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment