Raft Design System

Patterns

Drag and drop

The one way everything in Raft is dragged — how a drag starts, what follows the hand, where the item will land, what a refused place shows, how a drop and a cancel move, and the keyboard and screen-reader path.

Everything people can drag in Raft (sidebar rows and sections, tabs, servers, task cards, composer attachments, side sections, an agent onto a computer) is dragged the same way. The item is picked up the same way, a lifted copy of it follows the hand, the item it came from stays behind at 40%, one dashed place shows where it will land, a place that won't take it says why, and a drop or a cancel settles in 160ms. Learning one drag is learning all of them, and none of them needs a mouse.

raft-ui carries the model: useDragSensors, DragLift, DropPlaceholder, DragRefusalHint and the announcement helpers (Drag), from @botiverse/raft-ui/wip until their API settles. TaskDnd, SortableTabsList and SortableComposerAttachments are built on them.

When it applies

Any item a person moves by dragging: reordering a list, moving an item to another list or column, dropping it into a container, or moving a section of a layout. Resizing a column by its seam is not a drag of an item; it follows Resizable, with one rule from here: Escape puts the starting width back. Files dragged in from outside the browser follow the Composer's drop overlay.

Dragging is never the only way to do something. Each drag has a keyboard path (below) and, where the action matters, a control that does the same: a task's status chip, a Move button (Accessibility).

Structure

Two lists, as a sidebar's Pinned and Channels sections. Drag a channel within a section or into the other one, by pointer, by touch or with the keyboard (Tab to a row, Space, the arrow keys, Space):

drag/compositions/lists

A board, where a column can refuse a card. Any status can be set from any status, so a refusal is about permission, not about the status: here the person may not change #137's status, so every column but its own refuses it:

task-dnd/compositions/task-dnd

Starting a drag

  • With a mouse or a pen, start the drag once the pointer has moved 6px with the button down. A click never moves anything, and 6px is the same on every surface.
  • On a touch screen, start it on the first 10px move after the finger has held still for 500ms. Moving earlier scrolls, and holding still without moving opens the item's menu (Dragging on a touch screen).
  • With the keyboard, Space picks the focused item up. Enter never starts a drag: on an item that also opens (a sidebar row, a card), Enter opens it.
  • Use useDragSensors() for all three, so no surface picks its own numbers.

The handle

  • Make the whole item the handle for small items: rows, tabs, chips, avatars, cards. A grip on a row is one more thing to aim at, and the row is already the target.
  • Make only the grip the handle for a panel of live content, such as a side section. Its head already does things when clicked (a thread head, a jump to the channel), so a drag from anywhere on it would fight those clicks. Show the grip bar on the head while it is hovered or the grip has keyboard focus (always, on a device that can't hover, where there is no hover to reveal it), and make its hit area larger than the bar: at least 24px tall, wider than the bar on both sides, so it doesn't need careful aim. The rest of the head keeps its clicks and doesn't show grab. The grip is also the keyboard's way in (Space on the grip).
  • Keep the grip's hit area clear of a neighboring resize seam's hit band (Resizable), and draw the bar inside the grip's hit area: a press on the bar always lifts the panel, never resizes it.
  • Show grab over a handle, grabbing everywhere while something is lifted, and not-allowed over a place that refuses it. DragLift and DragRefusalHint set the last two for the whole page, whatever is under the pointer.

What follows the hand

  • Move a lifted copy, never the item itself. It tracks the pointer 1:1 from the point it was grabbed, every frame. Never clamp it to its container or hold it to one axis: the hand can go anywhere, and the copy goes with it.
  • For a small item, the copy is the item. For a large one (a panel, a section), the copy is a compact proxy: the head's icon and title, at most 240px wide, held so the hand stays on it.
  • Give the copy the lift look: an opaque layer-popover surface, shadow-float and a line-seam ring, with no scale and no tilt (Elevation). The ring separates the copy from what is under it in dark mode, where a shadow barely shows. The same ring is on the HUD chip of a refused place.
  • In a keyboard drag, set the copy 6px down and right of the place it stands on, so the dashed outline under it stays visible.
  • Keep the copy above everything, popovers, dialogs and toasts included, and render it where no ancestor can hold it in, neither a transform that would move it (a drawer, a dialog) nor a stacking context it can't rise out of (a scroll area); DragLift renders it in document.body: it is the one thing under the hand, and it must stay there.

Where it came from

  • Leave the item in its place, at 40% (data-drag-origin), until something else takes its place. One value everywhere, so the faded item always reads as "where this was".

Where it will land

One placeholder for every place: a 1.5px dashed line-strong outline on fill-subtle, with the item's radius and the item's size (DropPlaceholder).

  • In lists and boards, open the place at the landing spot: the siblings slide apart (160ms, motion-drag-settle-*) and the item's own spot closes once it stands somewhere else. The item's own slot is the place (DraggedSlot), so the place is always exactly the item's size.
  • In layouts (panels, columns), the target column makes room, and nothing is drawn over live content. The section being moved stays where it was at 40% (data-drag-origin) and nothing covers it until the drop, so the person always sees where it came from and where a cancel puts it. In its own column, only the sections it moves past give up height; it and the sections on its other side keep their size. In another column, the sections there stand as they will after the drop, and the gap takes the moved section's share, so a Thread shrinks and its composer stays in view. The gap holds a DropPlaceholder. Measure the places once, when the drag starts, so the targets don't move under the pointer as the column makes room. A new column is a full-height dashed strip, 24px wide, on the seam where the column will appear: narrow enough to stand on the seam over no column's content, a strip as wide as the new column would cover the next column and its head's buttons; the existing columns don't change until the drop (stdrc, 2026-10-26). Past the row's last column there is no seam to stand on, so while the pointer is at the row's end the last column gives the strip its 24px, and the strip covers none of that column's buttons.
  • A section alone in its column is not offered the edges next to its own column: moving it there would leave the layout as it is.
  • In a conversation's row the thread, Tasks, Files, the Artifact and a profile move, each by the grip on its head, once more than one of them stands in the row. The conversation doesn't move: a section dropped anywhere on it makes a new column right after the thread, or the first one where no thread is open. A new column is offered wherever the row has fewer than four side columns, the thread's own column aside (the side layout's cap since task #165), because the row always holds it, narrowed, clipped or as a tab (The channel's side columns); a fifth is not a place. Nothing is lifted while a column is expanded, and a thread folded over the conversation is its layer, not a column to move (stdrc, 2026-11-03 and 2026-11-04, #proj-raft-ui:#189).
  • A column whose order isn't the person's to set (a board whose columns are sorted, by last update, by due date) is itself the place: it takes fill-subtle (data-drag-into, TaskDropZone's dragInto) while the item is over it, and no placeholder opens between its items. Dropped, the item would jump to the spot the sort gives it, so a place between two items would promise an order the list doesn't keep (stdrc, 2026-11-10, #wg-task-v2:8a7ae237). Offer places inside a column only where the person's order is what the list shows. With the keyboard, Left and Right move between such columns; Up and Down don't move the item within one.
  • A container you drop into (another sidebar section, a computer's row) takes fill-subtle (data-drag-into) while the item is in it, with the place at the insertion point.

Refused places

  • Show no placeholder over a place that won't take the item, set the not-allowed cursor, and say why in a HUD chip by the pointer (by the copy, in a keyboard drag): "You can't change the status of #137". DragRefusalHint does all three.
  • Keep the chip in the window and clear of the copy. It sits right of and below the pointer; where the window's right edge would cut it, it goes left of the pointer and of the copy (a compact proxy held near its end reaches left of the pointer); where the bottom edge would, above the pointer. It never sits under the copy, which is drawn over it, and always stays at least 8px inside the window. DragRefusalHint places it so.
  • Say the reason in the announcement too: the chip is not read out.
  • A drop on a refused place is a cancel: the copy glides home and nothing moves.

Drop and cancel

  • On a drop, glide the copy into the place in 160ms with motion-drag-settle-easing (ease-out), then show the item there. In a layout, the reflow animates over the same 160ms, in step with the glide (Motion).
  • Cancel on Escape, a pointercancel (the system took the touch), the window losing focus and the tab going hidden: the copy glides back to the item, and everything that moved returns. DragLift covers the ones dnd-kit leaves open, without a synthetic Escape: a cancel must never close the dialog or panel the drag happens in.
  • Escape during a drag belongs to the drag, however it started (pointer, touch or keyboard): it puts the item back and reaches nothing else. useDragSensors takes it in the capture phase on the window and stops it, so no app handler that closes a panel, a dialog or a popover on Escape hears it. Once the drag is over, Escape reaches the app again.
  • With reduced motion, the copy and the siblings settle at once.

Keyboard

  • Space picks up, the arrow keys move one place at a time, Space drops, Escape puts the item back. Up and Down move through a list and on into the next one; Left and Right move between columns, tabs and attachments.
  • Move exactly one place per arrow, also right after the item enters another list, so what is announced and where it drops always agree. useDragAcrossLists (and useDragLists, built on it) measures each step from the item's real place and re-measures after a move between lists; without it, dnd-kit skips the place after entering a list.
  • While a tab or another item of a composite widget is lifted, its own arrow keys stay off, so the arrows move the item and not the focus.
  • A layout's places are stepped through in their order on screen, skipping places with no room.
  • A section of a conversation's row also moves without a drag: Move left and Move right in its head's ⋯. Out of a stack, they put it in a new column of its own beside the stack; alone in its column, they swap the column with its neighbour. Each is offered only where it changes what shows and keeps the row within four side columns, and a head whose title room holds tabs offers neither (The channel's side columns).

Screen readers

  • Announce every step in a polite live region, by name and never by id: "Picked up #launch-week. Over Pinned, position 2 of 5." "Dropped #launch-week in Channels, position 1 of 4." "Cancelled. #launch-week is back in Pinned, position 2 of 5."
  • Build the strings from your translations: createDragAnnouncements takes the names and places and your messages, and dragInstructions says to press Space, not Enter. dnd-kit's own English, id-based defaults never ship: where there is no name for the item, say nothing. SortableTabsList and SortableComposerAttachments announce by the tab's and the attachment's name and take your messages; TaskDndProvider announces from the card's and the status's names you pass as describe, and says nothing without them.

Auto-scroll

  • Scroll any scrollable area when the pointer comes within 48px of its edge while dragging, faster the nearer the edge: the sidebar's list, a board's columns, a tab row, the page. DragLift does it for whatever is under the pointer; turn dnd-kit's own off (autoScroll={false}), so the two don't scroll together.

Touch

  • Keep a draggable item scrollable (touch-manipulation, not touch-none), so a list of them still scrolls under a finger.
  • Close the item's menu when its drag starts: a long press opened it, and the move after the hold picked the item up (Dragging on a touch screen).