Overview
A guide to building drag-and-drop interfaces with Base UI.
Base UI includes headless drag-and-drop for making elements draggable and building drop zones. It works across mouse, touch, and pen, with pointer-appropriate defaults for starting a drag (see Activation). Draggable.Root also supports keyboard dragging by default (see Keyboard navigation). The components are unstyled, including the preview that follows the pointer.
'use client';
import * as React from 'react';
import { Draggable } from '@base-ui/react/draggable';
import { DropTarget } from '@base-ui/react/drop-target';
import styles from './hero.module.css';
const circleKind = Draggable.createKind('overview/shape-circle');
const squareKind = Draggable.createKind('overview/shape-square');
const triangleKind = Draggable.createKind('overview/shape-triangle');
const SHAPES = [
{ id: 'circle', label: 'Circle', kind: circleKind },
{ id: 'square', label: 'Square', kind: squareKind },
{ id: 'triangle', label: 'Triangle', kind: triangleKind },
] as const;
type Shape = (typeof SHAPES)[number];
type ShapeId = Shape['id'];
function ShapePiece({ shape }: { shape: Shape }) {
return (
<Draggable.Root
className={styles.Piece}
data-shape={shape.id}
kind={shape.kind}
label={shape.label}
aria-label={shape.label}
role="button"
tabIndex={0}
/>
);
}
export default function ShapeSorter() {
const [placed, setPlaced] = React.useState<ShapeId[]>([]);
function placeShape(shape: ShapeId) {
setPlaced((current) => (current.includes(shape) ? current : [...current, shape]));
}
return (
<div className={styles.Root}>
<div className={styles.Actions}>
{placed.length > 0 && (
<button type="button" className={styles.Reset} onClick={() => setPlaced([])}>
Reset
</button>
)}
</div>
<div className={styles.Tray}>
{SHAPES.map((shape) => (
<div key={shape.id} className={styles.TraySlot}>
{!placed.includes(shape.id) && <ShapePiece shape={shape} />}
</div>
))}
</div>
<div className={styles.Board}>
{SHAPES.map((shape) => {
const isPlaced = placed.includes(shape.id);
return (
<DropTarget.Root
key={shape.id}
className={styles.Target}
label={`${shape.label} cutout`}
accept={shape.kind}
onDrop={() => placeShape(shape.id)}
>
<span className={styles.Cutout} data-shape={shape.id} aria-hidden="true" />
{isPlaced && <ShapePiece shape={shape} />}
</DropTarget.Root>
);
})}
</div>
</div>
);
}
The two main components have their own pages. Draggable makes an element a drag source, and DropTarget marks where a drag can be released. The other guides cover styling, accessibility, collections, and testing.
Concepts
Kinds say what can be dragged where. Every draggable is of one kind, declared once with Draggable.createKind. A drop target lists the kinds it takes in accept, and the payload type the kind was created with is what types source.payload on every event:
import { Draggable } from '@base-ui/react/draggable';
import { DropTarget } from '@base-ui/react/drop-target';
const card = Draggable.createKind<Card>('card');
<Draggable.Root kind={card} label={cardData.title} payload={cardData} />;
<DropTarget.Root accept={card} label="Done" onDrop={({ source }) => move(source.payload.id)} />A createKind call has a unique identity. Declare it once and share the returned value with every draggable and target in the interaction. Two separate createKind('card') calls do not match. The name is only a debugging aid. Use label to set the accessible name of a draggable or drop target.
If independently evaluated bundles cannot share the same value, use Draggable.createGlobalKind<Card>('myapp/card'). Global keys are interned across bundles and hot reloads, so namespace them to your app or package. Reusing one key with incompatible payload types makes unrelated integrations match and bypasses TypeScript’s payload safety; prefer createKind everywhere else.
One manager, no provider. Every source and target on the page uses the same drag manager. The required accept prop prevents unrelated interactions from matching. Use DropTarget.anyKind to accept every drag. Draggable.PreviewProvider does not scope a drag. It provides the React tree where custom previews render.
Drag and drop is synthetic. Base UI tracks pointer and keyboard input itself rather than using the browser’s HTML5 drag-and-drop, so there is no dataTransfer, nothing crosses into other applications, and the preview is an ordinary element you style. An OS file drop is handled by passing native handlers through render.
Drop targets stack. A drag can be over several nested targets at once. They arrive innermost-first in location.current.dropTargets, and only the innermost receives onDrop; ancestors see the drag pass through via onDragEnter, onDrag and onDragLeave. Returning false from canDrop pops a target off the stack so an ancestor can claim the drop; returning 'reject' refuses the position outright, and no target resolves there at all.
A drop can resolve to a value inside a target. Every record in the stack has a getLocalPoint() method. It returns the pointer position inside the target as a fraction of its bounding box. Use it to resolve values such as a time in a day column or a position on a track without measuring the element in the handler.
Every event carries the same location history. location.initial is where the drag began, location.current where it is now, and location.previous where it was at the prior event, each a pointer position plus the target stack at that moment. Comparing current against previous is how hover work tells that something changed. On the first event of a drag, previous holds the pickup input and an empty stack, so that comparison reads as no movement rather than a jump.
Setup
You do not need a provider for the default clone or Draggable.ClonedPreview. When you render custom content with Draggable.Preview, wrap that part of your app in a Draggable.PreviewProvider. Put it inside the context providers the preview needs:
import { Draggable } from '@base-ui/react/draggable';
function App() {
return (
<ThemeProvider>
<Draggable.PreviewProvider>
<Board />
</Draggable.PreviewProvider>
</ThemeProvider>
);
}
Custom preview content renders beside the provider’s children, so it receives context only from providers above the nearest Draggable.PreviewProvider. It does not inherit a theme, direction, or store provider placed between that preview provider and an individual draggable. Put another Draggable.PreviewProvider inside any local context boundary the preview must retain. The DOM container a preview is injected into does not change this React-context boundary.
Examples
Sortable lists
Do not merge: a List Box example will replace this one once that component is available.
To reorder a list, pass a DropTarget.Root to each item’s render prop, so both roles land on one element.
'use client';
import * as React from 'react';
import { Draggable } from '@base-ui/react/draggable';
import { DropTarget } from '@base-ui/react/drop-target';
import styles from './sortable.module.css';
interface Item {
id: string;
label: string;
}
const itemKind = Draggable.createKind<string>('sortable-item');
const INITIAL_ITEMS: Item[] = [
{ id: 'proposal', label: 'Draft the proposal' },
{ id: 'budget', label: 'Review the budget' },
{ id: 'client', label: 'Email the client' },
{ id: 'slides', label: 'Prepare the slides' },
{ id: 'room', label: 'Book the room' },
];
function Grip() {
return (
<svg className={styles.Grip} width="8" height="14" viewBox="0 0 8 14" aria-hidden="true">
<g fill="currentColor">
<circle cx="2" cy="2" r="1.2" />
<circle cx="6" cy="2" r="1.2" />
<circle cx="2" cy="7" r="1.2" />
<circle cx="6" cy="7" r="1.2" />
<circle cx="2" cy="12" r="1.2" />
<circle cx="6" cy="12" r="1.2" />
</g>
</svg>
);
}
// Each item is both a drag source and a drop target: `render` puts both roles on
// the same element. The drop target reports when a drag is over *this* item,
// which it knows from its own props.
function SortableItem({
item,
listRef,
onDragOverItem,
onDragStart,
onDrop,
onDragEnd,
}: {
item: Item;
listRef: React.RefObject<HTMLDivElement | null>;
onDragOverItem: (draggedId: string, overId: string, movingDown: boolean) => void;
onDragStart: () => void;
onDrop: () => void;
onDragEnd: () => void;
}) {
return (
<Draggable.Root
label={item.label}
kind={itemKind}
payload={item.id}
// Arrow keys only move between items; a press past either end does nothing.
keyboardMovement={Draggable.targetsOnlyKeyboardMovement}
// Lock the drag to the vertical axis and keep it inside the list, for
// pointer and keyboard alike.
modifiers={[Draggable.restrictToVerticalAxis, Draggable.restrictToElement(listRef)]}
onDragStart={onDragStart}
onDrop={onDrop}
onDragEnd={onDragEnd}
render={
<DropTarget.Root
label={item.label}
accept={itemKind}
trackDragOver={false}
onDrag={({ source, location }) => {
// Travel direction, from where the pointer was on the previous event.
// On the first event of a drag `previous.input` is the pickup point,
// so a keyboard drag's first arrow press already reads a direction.
const { clientY } = location.current.input;
const previousY = location.previous.input.clientY;
if (clientY !== previousY) {
onDragOverItem(source.payload, item.id, clientY > previousY);
}
}}
/>
}
role="button"
className={styles.Item}
>
{/* The opt-in part measures how far each reorder pushes this item and
publishes `data-displacing` and the displacement variables. */}
<Draggable.Displacement />
<Grip />
{item.label}
</Draggable.Root>
);
}
export default function SortableList() {
const [items, setItems] = React.useState<Item[]>(INITIAL_ITEMS);
const listRef = React.useRef<HTMLDivElement>(null);
// The order captured at drag start, restored if the drag is canceled or dropped
// outside the list so live reordering never sticks on an aborted drag.
const orderBeforeDrag = React.useRef<Item[] | null>(null);
// Snapshot the current order so an aborted drag can restore it.
const handleDragStart = React.useCallback(() => {
orderBeforeDrag.current = items;
}, [items]);
// A real drop commits whatever the live reorder already applied, so it only has
// to drop the undo snapshot. `onDrop` runs before `onDragEnd`, so a drag that
// ends any other way still finds the snapshot below and reverts.
const handleDrop = React.useCallback(() => {
orderBeforeDrag.current = null;
}, []);
// Escape, or a release outside any item: put the list back the way it was. The
// revert flows through the same displacement transition.
const handleDragEnd = React.useCallback(() => {
const snapshot = orderBeforeDrag.current;
orderBeforeDrag.current = null;
if (snapshot) {
setItems(snapshot);
}
}, []);
// Reorders the list live as each item reports a drag passing over it. The dragged
// item goes above or below the hovered one based on the pointer's travel
// direction — moving up drops it above, moving down drops it below. Using the
// direction (not a fixed or live index) is what makes it correct on the way up,
// the way down, and when you reverse mid-drag.
const handleDragOverItem = React.useCallback(
(draggedId: string, overId: string, movingDown: boolean) => {
if (overId === draggedId) {
return;
}
setItems((prev) => {
const moved = prev.find((item) => item.id === draggedId);
const without = prev.filter((item) => item.id !== draggedId);
const targetPos = without.findIndex((item) => item.id === overId);
if (!moved || targetPos === -1) {
return prev;
}
const next = [...without];
next.splice(targetPos + (movingDown ? 1 : 0), 0, moved);
// Bail if the order didn't change, so a still-hovered item doesn't churn.
if (next.every((item, index) => item.id === prev[index].id)) {
return prev;
}
return next;
});
},
[],
);
return (
<div className={styles.List} ref={listRef}>
{items.map((item) => (
<SortableItem
key={item.id}
item={item}
listRef={listRef}
onDragOverItem={handleDragOverItem}
onDragStart={handleDragStart}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
</div>
);
}
Draggable tabs
Reordering leaves the standard Tabs keyboard behavior in place. The arrow keys still move focus and select, Alt+←/→ reorders the focused tab, and Delete closes it.
A clear view of the project
Keep notes, decisions, and next steps together in one shared place.
'use client';
import * as React from 'react';
import { Tabs } from '@base-ui/react/tabs';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import {
Draggable,
type BeforeDragStartEventDetails,
type DragStartContext,
} from '@base-ui/react/draggable';
import { DropTarget, type DropTargetEvent } from '@base-ui/react/drop-target';
import { DragAutoScroll } from '@base-ui/react/drag-auto-scroll';
import styles from './tabs.module.css';
interface TabItem {
id: string;
label: string;
eyebrow: string;
title: string;
description: string;
}
const tabKind = Draggable.createKind<string>('overview/draggable-tab');
const INITIAL_TABS: TabItem[] = [
{
id: 'overview',
label: 'Overview',
eyebrow: 'Workspace',
title: 'A clear view of the project',
description: 'Keep notes, decisions, and next steps together in one shared place.',
},
{
id: 'activity',
label: 'Activity',
eyebrow: 'Latest updates',
title: 'Everything is moving',
description: 'The team completed 18 tasks and shared 6 new files this week.',
},
{
id: 'reports',
label: 'Reports',
eyebrow: 'Weekly summary',
title: 'Progress is on track',
description: 'Milestones are healthy, with the next review scheduled for Friday.',
},
{
id: 'notes',
label: 'Notes',
eyebrow: 'Team notes',
title: 'Ideas worth returning to',
description: 'Capture loose thoughts here before turning them into planned work.',
},
];
function reorderTabs(items: TabItem[], draggedId: string, overId: string, movingRight: boolean) {
if (draggedId === overId) {
return items;
}
const moved = items.find((item) => item.id === draggedId);
const remaining = items.filter((item) => item.id !== draggedId);
const targetIndex = remaining.findIndex((item) => item.id === overId);
if (!moved || targetIndex === -1) {
return items;
}
const nextItems = [...remaining];
nextItems.splice(targetIndex + (movingRight ? 1 : 0), 0, moved);
return nextItems.every((item, index) => item.id === items[index].id) ? items : nextItems;
}
function CloseIcon() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path d="m3 3 6 6M9 3 3 9" fill="none" stroke="currentColor" strokeWidth="1.25" />
</svg>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M7 1.5v11M1.5 7h11" fill="none" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}
interface DraggableTabProps {
item: TabItem;
listRef: React.RefObject<HTMLDivElement | null>;
onDragOverTab: (draggedId: string, overId: string, movingRight: boolean) => void;
onDragStart: () => void;
onDrop: () => void;
onDragEnd: () => void;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onKeyboardMove: (id: string, offset: -1 | 1) => void;
}
function DraggableTab(props: DraggableTabProps) {
const {
item,
listRef,
onDragOverTab,
onDragStart,
onDrop,
onDragEnd,
onSelect,
onClose,
onKeyboardMove,
} = props;
const handleBeforeDragStart = useStableCallback(
(_context: DragStartContext, eventDetails: BeforeDragStartEventDetails) => {
if (eventDetails.trigger?.closest('[data-close-tab]')) {
eventDetails.cancel();
return;
}
onSelect(item.id);
},
);
const handleDrag = useStableCallback((event: DropTargetEvent<'onDrag', string>) => {
// Compare against the tab's midpoint rather than the pointer's direction of
// travel: while the list auto-scrolls, tabs slide under a stationary pointer.
onDragOverTab(event.source.payload, item.id, event.self.getLocalPoint().x > 0.5);
});
const handleClosePointerDown = useStableCallback((event: React.PointerEvent) => {
event.preventDefault();
event.stopPropagation();
});
const handleCloseClick = useStableCallback((event: React.MouseEvent) => {
event.stopPropagation();
onClose(item.id);
});
const handleKeyDown = useStableCallback((event: React.KeyboardEvent) => {
if (event.key === 'Delete') {
event.preventDefault();
onClose(item.id);
} else if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
event.preventDefault();
event.stopPropagation();
onKeyboardMove(item.id, event.key === 'ArrowLeft' ? -1 : 1);
}
});
return (
<Tabs.Tab
className={styles.Tab}
value={item.id}
render={
<Draggable.Root
label={`${item.label} tab`}
kind={tabKind}
payload={item.id}
// Enter and Space stay with Tabs for selection; reordering is
// Alt+Arrow through the button's onKeyDown below.
keyboardActivation="off"
pointerActivation={{ mouse: { type: 'distance', distance: 5 } }}
modifiers={Draggable.restrictToHorizontalAxis}
onBeforeDragStart={handleBeforeDragStart}
onDragStart={onDragStart}
onDrop={onDrop}
onDragEnd={onDragEnd}
render={
<DropTarget.Root
label={`${item.label} tab`}
accept={tabKind}
trackDragOver={false}
onDrag={handleDrag}
render={
<button
type="button"
aria-label={item.label}
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight Delete"
onKeyDown={handleKeyDown}
/>
}
/>
}
/>
}
>
<span className={styles.TabLabel}>{item.label}</span>
<span
className={styles.Close}
data-close-tab=""
title={`Close ${item.label}`}
onPointerDown={handleClosePointerDown}
onClick={handleCloseClick}
aria-hidden="true"
>
<CloseIcon />
</span>
{/* Keep the clone in the list without clamping the pointer used to resolve insertion slots. */}
<Draggable.ClonedPreview modifiers={Draggable.restrictToElement(listRef)} />
</Tabs.Tab>
);
}
export default function DraggableTabs() {
const [items, setItems] = React.useState(INITIAL_TABS);
const [selectedValue, setSelectedValue] = React.useState<string | null>('overview');
const nextTabNumber = React.useRef(1);
const listRef = React.useRef<HTMLDivElement>(null);
const orderBeforeDrag = React.useRef<TabItem[] | null>(null);
const handleValueChange = useStableCallback((value: Tabs.Tab.Value) => {
if (typeof value === 'string' || value === null) {
setSelectedValue(value);
}
});
const handleDragStart = useStableCallback(() => {
orderBeforeDrag.current = items;
});
const handleDrop = useStableCallback(() => {
orderBeforeDrag.current = null;
});
const handleDragEnd = useStableCallback(() => {
const previousOrder = orderBeforeDrag.current;
orderBeforeDrag.current = null;
if (previousOrder) {
setItems(previousOrder);
}
});
const handleDragOverTab = useStableCallback(
(draggedId: string, overId: string, movingRight: boolean) => {
setItems((currentItems) => reorderTabs(currentItems, draggedId, overId, movingRight));
},
);
const handleClose = useStableCallback((id: string) => {
const closingIndex = items.findIndex((item) => item.id === id);
const nextItems = items.filter((item) => item.id !== id);
setItems(nextItems);
if (selectedValue === id) {
setSelectedValue(nextItems[Math.min(closingIndex, nextItems.length - 1)]?.id ?? null);
}
});
const handleKeyboardMove = useStableCallback((id: string, offset: -1 | 1) => {
setItems((currentItems) => {
const index = currentItems.findIndex((item) => item.id === id);
const nextIndex = index + offset;
if (index === -1 || nextIndex < 0 || nextIndex >= currentItems.length) {
return currentItems;
}
const nextItems = [...currentItems];
const [moved] = nextItems.splice(index, 1);
nextItems.splice(nextIndex, 0, moved);
return nextItems;
});
});
const handleAdd = useStableCallback(() => {
const number = nextTabNumber.current;
nextTabNumber.current += 1;
const newItem = {
id: `untitled-${number}`,
label: `Untitled ${number}`,
eyebrow: 'New document',
title: 'Start with a blank page',
description: 'This tab is ready for a new idea, plan, or collection of notes.',
};
setItems((currentItems) => [...currentItems, newItem]);
setSelectedValue(newItem.id);
});
return (
<Tabs.Root className={styles.Workspace} value={selectedValue} onValueChange={handleValueChange}>
<div className={styles.TabBar}>
<Tabs.List
ref={listRef}
className={styles.TabList}
activateOnFocus
render={
<DropTarget.Root
label="Open documents"
accept={tabKind}
trackDragOver={false}
render={<DragAutoScroll.Root allowedAxis="horizontal" />}
/>
}
>
{items.map((item) => (
<DraggableTab
key={item.id}
item={item}
listRef={listRef}
onDragOverTab={handleDragOverTab}
onDragStart={handleDragStart}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onSelect={setSelectedValue}
onClose={handleClose}
onKeyboardMove={handleKeyboardMove}
/>
))}
</Tabs.List>
<button className={styles.AddButton} type="button" onClick={handleAdd} aria-label="Add tab">
<PlusIcon />
</button>
</div>
<div className={styles.PanelViewport}>
{items.length === 0 ? (
<div className={styles.Empty}>
<p>No documents are open.</p>
<button type="button" onClick={handleAdd}>
Add a tab
</button>
</div>
) : (
items.map((item) => (
<Tabs.Panel key={item.id} className={styles.Panel} value={item.id}>
<span className={styles.Eyebrow}>{item.eyebrow}</span>
<h3>{item.title}</h3>
<p>{item.description}</p>
<div className={styles.Placeholder} aria-hidden="true">
<span />
<span />
<span />
</div>
</Tabs.Panel>
))
)}
</div>
</Tabs.Root>
);
}
Calendar
A custom modifier snaps the drag to the nearest day column and 15-minute slot, so the preview shows exactly where the event will land. Each column declares snap, so the drop commits with no geometry in the handler.
'use client';
import * as React from 'react';
import { Draggable, type DragModifier, type DragLocationHistory } from '@base-ui/react/draggable';
import { DropTarget } from '@base-ui/react/drop-target';
import styles from './scheduler.module.css';
const eventKind = Draggable.createKind('calendar-event');
const dayColumnKind = Draggable.createKind<number>('calendar-day');
const DAYS = ['Monday', 'Tuesday', 'Wednesday'];
const START_HOUR = 9;
const TOTAL_MINUTES = 240; // the grid shows 9:00 – 13:00
const SLOT_MINUTES = 15;
const SLOT_HEIGHT = 16; // pixels per 15-minute slot; hour lines in the CSS are 4 slots (64px) apart
const EVENT_MINUTES = 60;
const EVENT_HEIGHT = (EVENT_MINUTES / SLOT_MINUTES) * SLOT_HEIGHT;
const GRID_HEIGHT = (TOTAL_MINUTES / SLOT_MINUTES) * SLOT_HEIGHT;
// The event's `inset-inline` within its day column, in pixels (0.25rem).
const EVENT_INSET_X = 4;
interface CalendarEvent {
day: number;
/** Start time, in minutes from the top of the grid. */
minute: number;
}
function formatTime(minute: number): string {
const hour = START_HOUR + Math.floor(minute / 60);
return `${hour}:${String(minute % 60).padStart(2, '0')}`;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export default function KeyboardMovementCalendar() {
const [event, setEvent] = React.useState<CalendarEvent>({ day: 1, minute: 60 });
const gridRef = React.useRef<HTMLDivElement>(null);
// The day column under `clientX`, or the closest one when the cursor is over
// the gutter or past the grid's edge.
const nearestDayColumn = (clientX: number): { element: HTMLElement; rect: DOMRect } | null => {
let best: { element: HTMLElement; rect: DOMRect } | null = null;
let bestDistance = Infinity;
gridRef.current?.querySelectorAll<HTMLElement>('[data-day-column]').forEach((element) => {
const rect = element.getBoundingClientRect();
const distance = Math.max(rect.left - clientX, clientX - rect.right, 0);
if (distance < bestDistance) {
bestDistance = distance;
best = { element, rect };
}
});
return best;
};
// The event position a drag would commit, read off the day column the engine
// resolved: its payload names the day, and its snapped local point the slot.
// `anchor: 'source'` shifts by the grab offset, so the event's top edge
// decides, wherever on it the user grabbed. Shared by the drop commit and
// the keyboard announcement so both agree.
const eventAfterDrag = (location: DragLocationHistory): CalendarEvent => {
const column = location.current.dropTargets[0];
if (!column || !dayColumnKind.matches(column)) {
return event;
}
return {
day: column.payload,
minute: clamp(
column.getSnappedLocalPoint({ anchor: 'source' }).y * TOTAL_MINUTES,
0,
TOTAL_MINUTES - EVENT_MINUTES,
),
};
};
// Snap the whole drag onto the slot the drop will commit: the preview lands
// on it, and the hit-test and reported input quantize with it, so what you
// see is exactly what lands. The preview sits at `point − previewOffset`, so
// the returned point is the slot origin — measured from the column's padding
// box (`clientLeft`/`clientTop` skip its border) where the dropped event is
// absolutely positioned — shifted back by the offset.
const snapEventToGrid: DragModifier = ({ point, input, previewOffset }) => {
const column = nearestDayColumn(input.x);
if (!column) {
return point;
}
const { element, rect } = column;
const originX = rect.left + element.clientLeft;
const originY = rect.top + element.clientTop;
const slot = clamp(
Math.round((point.y - previewOffset.y - originY) / SLOT_HEIGHT),
0,
(TOTAL_MINUTES - EVENT_MINUTES) / SLOT_MINUTES,
);
return {
x: originX + EVENT_INSET_X + previewOffset.x,
y: originY + slot * SLOT_HEIGHT + previewOffset.y,
};
};
return (
<div className={styles.Root}>
<div className={styles.Calendar}>
<div />
{DAYS.map((day) => (
<div key={day} className={styles.DayHeader}>
{day}
</div>
))}
<div className={styles.TimeGutter} style={{ height: GRID_HEIGHT }}>
{Array.from({ length: TOTAL_MINUTES / 60 + 1 }, (_, hour) => (
<span key={hour} className={styles.TimeLabel} style={{ top: hour * 4 * SLOT_HEIGHT }}>
{formatTime(hour * 60)}
</span>
))}
</div>
<div className={styles.Days} ref={gridRef} style={{ height: GRID_HEIGHT }}>
{DAYS.map((day, index) => (
<DropTarget.Root
key={day}
label={day}
kind={dayColumnKind}
payload={index}
accept={eventKind}
// One slot per 15 minutes: `getSnappedLocalPoint` reports the
// landed slot as a fraction, whatever the column's height.
snap={{ y: TOTAL_MINUTES / SLOT_MINUTES }}
className={styles.DayColumn}
data-day-column
>
{event.day === index && (
<Draggable.Root
label="Design review"
kind={eventKind}
role="button"
className={styles.Event}
style={{ top: (event.minute / SLOT_MINUTES) * SLOT_HEIGHT, height: EVENT_HEIGHT }}
modifiers={snapEventToGrid}
// The engine can't know this grid's geometry: one 15-minute
// slot vertically, the same time in the day column ahead
// horizontally. No bounds checks: the modifier clamps at the
// grid's edges, and a press that moves nothing announces the
// edge on its own.
keyboardMovement={({ position, direction, findTarget }) => {
if (direction.y !== 0) {
return { x: position.x, y: position.y + direction.y * SLOT_HEIGHT };
}
const next = findTarget();
if (!next) {
return false; // already on the first/last day
}
const rect = next.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: position.y };
}}
keyboardAnnouncements={{
moved: ({ location }) => {
const next = eventAfterDrag(location);
return `${DAYS[next.day]}, ${formatTime(next.minute)}`;
},
reachedEdge: () => 'Edge of the calendar',
}}
// Only a drop over an accepting slot moves the event; a cancel
// or a release off the grid never reaches `onDrop`.
onDrop={({ location }) => setEvent(eventAfterDrag(location))}
>
<span className={styles.EventTitle}>Design review</span>
<span className={styles.EventTime}>
{formatTime(event.minute)} – {formatTime(event.minute + EVENT_MINUTES)}
</span>
{/* `container` injects the clone into the grid element. */}
<Draggable.ClonedPreview container={gridRef} />
</Draggable.Root>
)}
</DropTarget.Root>
))}
</div>
</div>
</div>
);
}
Free dragging
Cards sit at absolute coordinates and drop anywhere. restrictToElement keeps the drag inside the canvas, and the drop commits the exact position where you release the preview.
'use client';
import * as React from 'react';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { ownerWindow } from '@base-ui/utils/owner';
import { Draggable } from '@base-ui/react/draggable';
import { DropTarget } from '@base-ui/react/drop-target';
import styles from './figma.module.css';
const cardKind = Draggable.createKind<string>('figma-card');
const CARD_WIDTH = 180;
const CARD_HEIGHT = 42;
interface Card {
id: string;
x: number;
y: number;
label: string;
}
// Positions are fractions of the placeable area (the surface minus one card),
// resolved against the surface's measured size on mount. Fixed pixel
// coordinates were authored for a wide canvas, so on a narrow (mobile) surface
// cards fell off the right edge and — with `overflow: hidden` — out of reach.
const INITIAL_LAYOUT: { id: string; fx: number; fy: number; label: string }[] = [
{ id: 'mercury', fx: 0.04, fy: 0.08, label: 'Mercury' },
{ id: 'venus', fx: 0.5, fy: 0.62, label: 'Venus' },
{ id: 'earth', fx: 0.95, fy: 0.28, label: 'Earth' },
];
function BoardCard({
card,
surfaceRef,
}: {
card: Card;
surfaceRef: React.RefObject<HTMLDivElement | null>;
}) {
return (
<Draggable.Root
label={card.label}
kind={cardKind}
payload={card.id}
// Spell out the default mouse activation for clarity: a drag starts only
// after a 5px move, so a plain click isn't swallowed. (This matches the
// engine default, so it can be omitted.)
pointerActivation={{ mouse: { type: 'distance', distance: 5 } }}
modifiers={Draggable.restrictToElement(surfaceRef)}
role="button"
className={styles.Card}
style={{ left: card.x, top: card.y, width: CARD_WIDTH, height: CARD_HEIGHT }}
>
{card.label}
<Draggable.ClonedPreview />
</Draggable.Root>
);
}
export default function FigmaBoard() {
const [cards, setCards] = React.useState<Card[]>([]);
const surfaceRef = React.useRef<HTMLDivElement | null>(null);
// Resolve the fractional layout against the surface's measured size before
// paint, then keep the cards inside it as it resizes (a phone rotating, the
// docs column reflowing) so none can end up off-screen and unreachable.
useIsoLayoutEffect(() => {
const surface = surfaceRef.current;
if (!surface) {
return undefined;
}
const layout = () => {
const { width, height } = surface.getBoundingClientRect();
const maxX = Math.max(width - CARD_WIDTH, 0);
const maxY = Math.max(height - CARD_HEIGHT, 0);
setCards((prev) =>
prev.length === 0
? INITIAL_LAYOUT.map(({ id, fx, fy, label }) => ({
id,
label,
x: Math.round(fx * maxX),
y: Math.round(fy * maxY),
}))
: // Pull already-placed cards (including ones the user dragged) back
// inside a surface that has since shrunk.
prev.map((card) => ({
...card,
x: Math.min(card.x, maxX),
y: Math.min(card.y, maxY),
})),
);
};
layout();
const win = ownerWindow(surface);
const observer = new win.ResizeObserver(layout);
observer.observe(surface);
return () => observer.disconnect();
}, []);
// The cards are absolutely positioned with no `z-index`, so DOM order is
// stacking order: moving the dropped card to the end of the list paints it over
// the ones it overlaps.
const moveCard = useStableCallback((id: string, x: number, y: number) => {
setCards((prev) => {
const moved = prev.find((card) => card.id === id);
if (!moved) {
return prev;
}
return [...prev.filter((card) => card.id !== id), { ...moved, x, y }];
});
});
return (
<div className={styles.Root}>
{/* The whole surface is a drop target, so a release on it counts as a real
drop rather than a cancel. */}
<DropTarget.Root
className={styles.Surface}
ref={surfaceRef}
label="Canvas"
accept={cardKind}
trackDragOver={false}
onDrop={({ self, source }) => {
const surface = surfaceRef.current;
if (!surface) {
return;
}
// No snap steps are declared, so this is the exact source-anchored point.
const point = self.getSnappedLocalPoint({ anchor: 'source' });
const surfaceRect = surface.getBoundingClientRect();
moveCard(
source.payload,
point.x * surfaceRect.width - surface.clientLeft,
point.y * surfaceRect.height - surface.clientTop,
);
}}
>
{cards.map((card) => (
<BoardCard key={card.id} card={card} surfaceRef={surfaceRef} />
))}
</DropTarget.Root>
</div>
);
}
Kanban board
useDragMonitor resolves the closest column and insertion slot on every drag event, and an empty placeholder card marks where the drop will land.
'use client';
import * as React from 'react';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { Draggable } from '@base-ui/react/draggable';
import { DropTarget } from '@base-ui/react/drop-target';
import { useDragMonitor } from '@base-ui/react/use-drag-monitor';
import styles from './kanban.module.css';
// A "snap to closest position" Kanban board built with `useDragMonitor`.
// The monitor reads the pointer on every drag event and resolves the
// horizontally-closest column and the vertically-closest insertion slot within
// it. An empty placeholder card renders in that slot, so the cards part to make
// room and drops land precisely there — even when the pointer is between
// columns.
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 cardKind = Draggable.createKind<CardDragData>('kanban-card');
interface CardDragData {
id: CardId;
fromColumn: ColumnId;
}
interface DropPlaceholder {
columnId: ColumnId;
insertIndex: number;
/** Height of the dragged card, so the placeholder occupies the same space. */
height: number;
}
function buildInitialBoard(): Board {
const columns: Column[] = [
{ id: 'todo', title: 'Todo', cardIds: ['c1', 'c2', 'c3'] },
{ id: 'in-progress', title: 'In progress', cardIds: ['c4', 'c5'] },
{ id: 'done', title: 'Done', cardIds: ['c6'] },
];
const cards: Card[] = [
{ id: 'c1', title: 'Write the spec' },
{ id: 'c2', title: 'Sketch the UI' },
{ id: 'c3', title: 'Set up the repo' },
{ id: 'c4', title: 'Wire the API' },
{ id: 'c5', title: 'Build the form' },
{ id: 'c6', title: 'Ship v0' },
];
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;
}
// Within a column, the candidate insertion slots are:
// index 0 — above the first card
// index 1..n-1 — between consecutive cards (midpoint of the gap)
// index n — below the last card
// Returns the slot whose Y is closest to the pointer. The rendered placeholder
// carries no `data-card`, so the gap it widens keeps resolving to the same slot
// and the result is stable while the pointer rests over the placeholder.
function findClosestSlot(columnEl: HTMLElement, clientY: number): number {
const body = columnEl.querySelector('[data-column-body]') as HTMLElement | null;
const scope = body ?? columnEl;
// The dragged card's preview is a clone injected next to it, and it carries the
// same `data-card`. Skip it: it follows the pointer and is not a real slot.
const cardEls = Array.from(
scope.querySelectorAll('[data-card]:not([data-drag-preview])'),
) as HTMLElement[];
if (cardEls.length === 0) {
return 0;
}
const slotYs: number[] = [cardEls[0].getBoundingClientRect().top];
for (let i = 1; i < cardEls.length; i += 1) {
const prev = cardEls[i - 1].getBoundingClientRect();
const curr = cardEls[i].getBoundingClientRect();
slotYs.push((prev.bottom + curr.top) / 2);
}
slotYs.push(cardEls[cardEls.length - 1].getBoundingClientRect().bottom);
let bestIndex = 0;
let bestDy = Infinity;
for (let i = 0; i < slotYs.length; i += 1) {
const dy = Math.abs(clientY - slotYs[i]);
if (dy < bestDy) {
bestDy = dy;
bestIndex = i;
}
}
return bestIndex;
}
function computeSlot(
clientX: number,
clientY: number,
columnElements: Map<ColumnId, HTMLElement>,
): Omit<DropPlaceholder, 'height'> | null {
const columnId = findClosestColumn(clientX, columnElements);
if (!columnId) {
return null;
}
const columnEl = columnElements.get(columnId);
if (!columnEl) {
return null;
}
return { columnId, insertIndex: findClosestSlot(columnEl, clientY) };
}
export default function KanbanBoard() {
const [board, setBoard] = React.useState<Board>(buildInitialBoard);
const [placeholder, setPlaceholder] = React.useState<DropPlaceholder | 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, insertIndex: number) => {
setBoard((prev) => {
const from = prev.columns[fromColumn];
const to = prev.columns[toColumn];
if (!from || !to) {
return prev;
}
if (fromColumn === toColumn) {
const sourceIndex = from.cardIds.indexOf(cardId);
// Dropping immediately before or after the source position is a no-op.
if (
sourceIndex === -1 ||
insertIndex === sourceIndex ||
insertIndex === sourceIndex + 1
) {
return prev;
}
const without = from.cardIds.filter((id) => id !== cardId);
// The removal shifts indices above the source down by one.
const adjusted = sourceIndex < insertIndex ? insertIndex - 1 : insertIndex;
const newIds = [...without.slice(0, adjusted), cardId, ...without.slice(adjusted)];
return {
...prev,
columns: { ...prev.columns, [fromColumn]: { ...from, cardIds: newIds } },
};
}
const newFromIds = from.cardIds.filter((id) => id !== cardId);
const newToIds = [
...to.cardIds.slice(0, insertIndex),
cardId,
...to.cardIds.slice(insertIndex),
];
return {
...prev,
columns: {
...prev.columns,
[fromColumn]: { ...from, cardIds: newFromIds },
[toColumn]: { ...to, cardIds: newToIds },
},
};
});
},
);
useDragMonitor({
accept: cardKind,
onDragStart: ({ source, location }) => {
const { clientX, clientY } = location.current.input;
const slot = computeSlot(clientX, clientY, columnElementsRef.current);
setPlaceholder(
slot ? { ...slot, height: source.element.getBoundingClientRect().height } : null,
);
},
onDrag: ({ source, location }) => {
const { clientX, clientY } = location.current.input;
const slot = computeSlot(clientX, clientY, columnElementsRef.current);
setPlaceholder(
slot ? { ...slot, height: source.element.getBoundingClientRect().height } : null,
);
},
// The placeholder always shows the nearest slot, even when the pointer is
// between columns or just outside the board. Commit that same slot on a real
// release; an Escape/blur cancellation only clears the placeholder.
onDragEnd: ({ source, location, canceled }) => {
if (!canceled) {
const { clientX, clientY } = location.current.input;
const drop = computeSlot(clientX, clientY, columnElementsRef.current);
if (drop) {
moveCard(source.payload.id, source.payload.fromColumn, drop.columnId, drop.insertIndex);
}
}
setPlaceholder(null);
},
});
return (
// Catch-all drop target on the demo root, so a release anywhere inside the
// demo lands on a registered target rather than falling outside every one.
<DropTarget.Root className={styles.Root} label="Board" accept={cardKind} trackDragOver={false}>
<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])}
placeholder={placeholder?.columnId === id ? placeholder : null}
registerElement={registerColumnElement}
/>
);
})}
</div>
</DropTarget.Root>
);
}
function KanbanColumn({
column,
cards,
placeholder,
registerElement,
}: {
column: Column;
cards: Card[];
placeholder: DropPlaceholder | null;
registerElement: (id: ColumnId, el: HTMLElement | null) => void;
}) {
const setRef = React.useCallback(
(el: HTMLDivElement | null) => {
registerElement(column.id, el);
},
[column.id, registerElement],
);
const ghost = placeholder && (
<div className={styles.Placeholder} style={{ height: placeholder.height }} aria-hidden="true" />
);
return (
<div ref={setRef} className={styles.Column} data-active={placeholder ? '' : undefined}>
<div className={styles.ColumnHeader}>{column.title}</div>
<div className={styles.ColumnBody} data-column-body>
{placeholder?.insertIndex === 0 && ghost}
{cards.map((card, index) => (
<React.Fragment key={card.id}>
<DraggableCard card={card} columnId={column.id} />
{placeholder?.insertIndex === index + 1 && ghost}
</React.Fragment>
))}
{cards.length === 0 && !placeholder && <div className={styles.Empty}>Drop a card here</div>}
</div>
</div>
);
}
function DraggableCard({ card, columnId }: { card: Card; columnId: ColumnId }) {
return (
<Draggable.Root
label={card.title}
kind={cardKind}
payload={{ id: card.id, fromColumn: columnId }}
data-card
role="button"
className={styles.Card}
>
{card.title}
<Draggable.ClonedPreview />
</Draggable.Root>
);
}
File explorer
Drop a node on a folder, on the open grid, or on an ancestor in the breadcrumb to move it there. With a tile focused, press Alt+Enter to start a keyboard drag; plain Space or Enter opens a folder. canDrop refuses a folder dropped into its own subtree.
'use client';
import * as React from 'react';
import { Draggable } from '@base-ui/react/draggable';
import { DropTarget } from '@base-ui/react/drop-target';
import { DragAutoScroll } from '@base-ui/react/drag-auto-scroll';
import { useDragDropManager } from '@base-ui/react/use-drag-drop-manager';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { INITIAL_NODES, type FileNode, type FileSystem } from './data';
import styles from './file-explorer.module.css';
const nodeKind = Draggable.createKind<string>('file-explorer-node');
// Whether `folderId` is `nodeId` itself or sits anywhere inside it.
function isSelfOrInside(nodes: FileSystem, nodeId: string, folderId: string): boolean {
for (let current: string | null = folderId; current !== null; current = nodes[current].parentId) {
if (current === nodeId) {
return true;
}
}
return false;
}
// The file-system rules, shared by the folder tiles and the breadcrumb segments.
// 'reject' blocks the position outright and turns on `data-rejected`, while
// `false` quietly withdraws the target, so releasing there is a no-op.
function canDropInto(nodes: FileSystem, folderId: string, sourceId: string): boolean | 'reject' {
if (isSelfOrInside(nodes, sourceId, folderId)) {
return 'reject';
}
if (nodes[sourceId].parentId === folderId) {
return false;
}
return true;
}
function getChildren(nodes: FileSystem, folderId: string): FileNode[] {
return Object.values(nodes)
.filter((node) => node.parentId === folderId)
.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
}
function getPath(nodes: FileSystem, folderId: string): FileNode[] {
const path: FileNode[] = [];
for (let current: string | null = folderId; current !== null; current = nodes[current].parentId) {
path.unshift(nodes[current]);
}
return path;
}
function useKeyboardControls(onOpen?: () => void) {
const manager = useDragDropManager();
return useStableCallback((event: React.KeyboardEvent<HTMLElement>) => {
const hasOtherModifier = event.ctrlKey || event.metaKey || event.shiftKey;
const isSpace = event.key === ' ' || event.key === 'Space' || event.key === 'Spacebar';
const isActivationKey = isSpace || event.code === 'Space' || event.key === 'Enter';
if (event.altKey && !hasOtherModifier && event.key === 'Enter') {
event.preventDefault();
manager.startKeyboardDrag(event.currentTarget);
return;
}
if (!event.altKey && !hasOtherModifier && isActivationKey && onOpen) {
event.preventDefault();
onOpen();
}
});
}
function FolderIcon({ className }: { className: string }) {
return (
<svg
className={className}
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3.75 5.25h4.9c.2 0 .39.08.53.22l1.81 1.81c.14.14.33.22.53.22h8.73c.41 0 .75.34.75.75v9.75c0 .41-.34.75-.75.75H3.75a.75.75 0 0 1-.75-.75V6a.75.75 0 0 1 .75-.75Z" />
</svg>
);
}
function FileIcon({ className }: { className: string }) {
return (
<svg
className={className}
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7.25 3.75h6L17.5 8v11.5a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1-.75-.75V4.5a.75.75 0 0 1 .75-.75Z" />
<path d="M13.25 3.75V8h4.25" />
</svg>
);
}
// A compact card follows the pointer instead of a clone of the whole tile.
function NodePreview({ node }: { node: FileNode }) {
return (
<Draggable.Preview className={styles.Preview} offset="pointer">
{node.type === 'folder' ? (
<FolderIcon className={styles.PreviewIcon} />
) : (
<FileIcon className={styles.PreviewIcon} />
)}
{node.name}
</Draggable.Preview>
);
}
// A folder is both a drag source and a drop target: `render` puts both roles on
// the same element. A plain click, Space, or Enter opens it. Alt+Enter starts a
// keyboard drag without taking those keys from navigation.
function FolderTile({
node,
nodes,
onMove,
onOpen,
}: {
node: FileNode;
nodes: FileSystem;
onMove: (sourceId: string, folderId: string) => void;
onOpen: (folderId: string) => void;
}) {
const handleKeyDown = useKeyboardControls(() => onOpen(node.id));
return (
<Draggable.Root
label={node.name}
kind={nodeKind}
payload={node.id}
// Arrow keys hop between accepting targets only: in a grid, free space is
// never a valid position.
keyboardMovement={Draggable.targetsOnlyKeyboardMovement}
keyboardActivation="manual"
keyboardInstructions="Press Space or Enter to open. Press Alt+Enter to start dragging."
role="button"
className={styles.Item}
onClick={() => onOpen(node.id)}
onKeyDownCapture={handleKeyDown}
render={
<DropTarget.Root
label={node.name}
accept={nodeKind}
canDrop={({ source }) => canDropInto(nodes, node.id, source.payload)}
onDrop={({ source }) => onMove(source.payload, node.id)}
/>
}
>
<FolderIcon className={styles.Icon} />
<span className={styles.Label}>{node.name}</span>
<NodePreview node={node} />
</Draggable.Root>
);
}
function FileTile({ node }: { node: FileNode }) {
const handleKeyDown = useKeyboardControls();
return (
<Draggable.Root
label={node.name}
kind={nodeKind}
payload={node.id}
keyboardMovement={Draggable.targetsOnlyKeyboardMovement}
keyboardActivation="manual"
keyboardInstructions="Press Alt+Enter to start dragging."
role="button"
className={styles.Item}
onKeyDownCapture={handleKeyDown}
>
<FileIcon className={styles.Icon} />
<span className={styles.Label}>{node.name}</span>
<NodePreview node={node} />
</Draggable.Root>
);
}
// Breadcrumb segments navigate on click and take drops, so a node can move to
// an ancestor without leaving the current view. Every segment is a target,
// including the current folder: the shared rules withdraw the segments a drop
// could not change.
function Crumb({
folder,
nodes,
isCurrent,
onMove,
onNavigate,
}: {
folder: FileNode;
nodes: FileSystem;
isCurrent: boolean;
onMove: (sourceId: string, folderId: string) => void;
onNavigate: (folderId: string) => void;
}) {
return (
<DropTarget.Root
label={folder.name}
accept={nodeKind}
canDrop={({ source }) => canDropInto(nodes, folder.id, source.payload)}
onDrop={({ source }) => onMove(source.payload, folder.id)}
render={
<button
type="button"
className={styles.Crumb}
aria-current={isCurrent ? 'true' : undefined}
onClick={() => onNavigate(folder.id)}
>
{folder.name}
</button>
}
/>
);
}
export default function FileExplorer() {
const [nodes, setNodes] = React.useState<FileSystem>(INITIAL_NODES);
const [currentFolderId, setCurrentFolderId] = React.useState('home');
// Moving a node is a single parent change; `canDrop` already vetted it.
const moveNode = useStableCallback((sourceId: string, folderId: string) => {
setNodes((prev) => ({ ...prev, [sourceId]: { ...prev[sourceId], parentId: folderId } }));
});
const path = getPath(nodes, currentFolderId);
const children = getChildren(nodes, currentFolderId);
return (
// Custom preview content renders beside the provider's children.
<Draggable.PreviewProvider>
<div className={styles.Root}>
<nav aria-label="Breadcrumb" className={styles.Breadcrumb}>
{path.map((folder, index) => (
<React.Fragment key={folder.id}>
{index > 0 && (
<span className={styles.Separator} aria-hidden="true">
/
</span>
)}
<Crumb
folder={folder}
nodes={nodes}
isCurrent={folder.id === currentFolderId}
onMove={moveNode}
onNavigate={setCurrentFolderId}
/>
</React.Fragment>
))}
</nav>
{/* The grid is a drop target for the folder it displays, so a release on
its background lands in that folder. `DragAutoScroll.Root` scrolls the
container when a pointer drag nears an edge. */}
<DropTarget.Root
label={nodes[currentFolderId].name}
accept={nodeKind}
canDrop={({ source }) => canDropInto(nodes, currentFolderId, source.payload)}
onDrop={({ source }) => moveNode(source.payload, currentFolderId)}
render={<DragAutoScroll.Root className={styles.Grid} />}
>
{children.map((node) =>
node.type === 'folder' ? (
<FolderTile
key={node.id}
node={node}
nodes={nodes}
onMove={moveNode}
onOpen={setCurrentFolderId}
/>
) : (
<FileTile key={node.id} node={node} />
),
)}
{children.length === 0 && <div className={styles.Empty}>This folder is empty</div>}
</DropTarget.Root>
</div>
</Draggable.PreviewProvider>
);
}