Drag, drop, reorder — and the keyboard equivalent that is not optional.
Part of the Konjo design language. The laws in the spine apply here unless this chapter contradicts them.
Every other pattern in Konjo is a state machine over taps: press → navigate, press → validate, press-arm → press-fire. This one is not. A drag is a continuous gesture with a mid-flight state, a spatial target, a cancel, a rollback, and a second complete interaction — the keyboard path — that has to produce identical outcomes through a different modality.
The chapter exists because a completeness test specified a Studio board and found the entire specification of direct manipulation in this system was one clause in a screen inventory: "Drag-and-drop cards." Thirty-three of that round's decisions were the agent's and not the system's, twelve of them here. The shapes this covers: a pipeline board, a sortable list, the belt-ladder editor, a schedule grid, and anything else where the person moves the object rather than filling in a field about it.
Before anything else: is a drag allowed here?
A drag must be reversible, or it is not a drag.
Serious actions require the consequence stated before the commit, "in
caption, next to the control that causes it". A drag has no control and no before-the-tap
moment — by the time anything is stated, the thing has happened. So:
- Reversible move → a drag is legal, and the announcement after the drop is the notice.
- Irreversible, or it moves money, or someone is told → it is not a drag. It is a control with a confirm, and the object may still be draggable as a shortcut to the control, which opens the confirm rather than committing.
If you cannot say what undoes it, you are not ready to build it.
The three visual states, and why none of them is a shadow
The universal convention for a lifted object is a drop shadow, and L3 bans shadow, blur, glow and gradient outright. The system's one recipe for a floating surface — scrim behind plus a 1px hairline — cannot apply either: a scrim would hide the drop targets that are the entire point of the gesture. So the lift is built from what is left, and it is built from meaning rather than depth.
| Treatment | Why | |
|---|---|---|
| The dragged object | opacity.pressed 0.85 and a 2px border.control edge, keeping its own radius |
Opacity is already the system's "this is under your finger" signal. border.control on surface.page is 3.15:1 and border.control on surface.card is 3.53:1, so the edge reads on either ground |
| The origin slot | a 1px border.control outline at the object's exact size, no fill |
It holds the space so nothing reflows mid-gesture, and it is the answer to "where does this go back to" |
| The drop target | a 2px border.focus outline at the region's radius, inset spacing.s8 |
14.73:1 on the page, 16.48:1 on a card |
Focus and the drop target are the same thing, deliberately. A drag in flight has exactly one active region, and it is the one a drop would land in — whether the person is pointing at it or arrowed to it. Giving them separate treatments would put two rings on one screen meaning almost the same thing, and would make the pointer path and the keyboard path look like different features. While a drag is in flight, focus is the drop target.
Nothing here is dashed. borderWidth ships hairline 1 and emphasis 2 and there is no
border-style token; a dashed edge is a fifth visual language nobody sanctioned.
Pointer
| Rest | cursor: grab on the object. See Studio's cursor table |
| Activation | movement ≥ 5px. Below that, pointerup is a click and does whatever a click does |
| In flight | the object tracks the pointer 1:1; cursor: grabbing |
| Auto-scroll | a 48px zone at each edge of the scrolling container, ramping to ~12px per frame. Never the page — horizontal scroll is contained |
| Cancel | Escape, and the object returns to its origin slot |
| Drop outside any target | the same as cancel. A drag never destroys anything by being released in the wrong place |
A whole object may be the drag source, or a handle may be. Use a handle when the object
also contains controls — a card with a link and a menu inside it — because a drag that starts
on a link is a link that sometimes does not work. Use the whole object when it contains only
text. The handle is iconSize.inline 16, text.tertiary, and it is not the object's only
affordance: the cursor and the keyboard path both work on the object itself.
Keyboard, which is a requirement and not a courtesy
WCAG SC 2.5.7 (Dragging Movements, 2.2 AA): every drag needs a single-pointer alternative that is not path-based. SC 2.1.1 additionally requires the whole thing be reachable by keyboard. These are not the same requirement and one control can satisfy both.
The best answer is usually not a keyboard drag. It is a plain control that sets the same value — a stage chip row, a "move to" select, a menu on the row. That serves keyboard users, switch users, screen-reader users and anyone on a tremor-affected hand at once, and it is derivable from sheet-picker and buttons with nothing invented. Build that first. If the collection is large enough that the control is unusable, add the grab-and-move path below as well, never instead.
| Key | Behaviour |
|---|---|
Tab |
The collection is one tab stop; focus lands on the last-focused object |
| Arrows | Move focus within and between groups |
Home / End |
First / last in the group |
Enter |
The object's own action — open it |
Space |
Grab. The object takes the dragged treatment in place |
| Arrows, while grabbed | Move the grabbed object; announce on each |
Space / Enter, while grabbed |
Drop |
Escape, while grabbed |
Cancel and return |
A collection is one tab stop, and that is an exception worth stating, because
accessibility otherwise says tab reaches every control. A
40-card column with every card a tab stop reproduces exactly the problem a skip link exists to
solve. Roving tabindex — one 0, the rest -1 — is the mechanism. It applies to a composite
widget: a board, a grid, a tree, a toolbar. It does not apply to a list of links.
What it says
A drag is invisible to a screen reader unless it is narrated. Two live regions for the whole collection — not one, and not one per group:
politecarries progress — the grab, and the in-flight message on every arrow press.assertivecarries the outcome — the drop, the cancel, the failure.
One region cannot do both: politeness is read when the region is created, not per message. A single polite region drops the message that matters (rapid arrow presses coalesce), and a single assertive one interrupts the screen reader on every keypress.
| Event | Announcement |
|---|---|
| Grab | "Ana Torres grabbed, in New, 1 of 12. Arrows to choose a stage. Space to drop. Escape to cancel." |
| Move, in flight | "Contacted, 4." — short, because it repeats on every keypress |
| Drop | "Ana Torres moved from New to Contacted. New has 11, Contacted has 5." |
| Cancel | "Move cancelled. Ana Torres is still in New." |
| Failure | "Couldn't move Ana Torres. Ana Torres is still in New." |
- The pointer path announces too. A screen-magnifier user drags with a mouse.
- In-flight messages are
politeand will sometimes be dropped when arrows repeat quickly. That is correct — and the outcome isassertivefor the same reason, because it is the one that must survive. It restates the whole result, not just the change. - Never
aria-grabbed/aria-dropeffect— deprecated since ARIA 1.1. State lives in the live region and inaria-roledescriptionon the object. - Voice: plain, name the person, no first person, no exclamation. See voice.
Motion
| Tracking the pointer | 1:1, no easing, no lag. This is input, not animation |
| Drop settle | No travel animation. Remove from the origin, cross-fade in at the destination at motion.duration.quick 150. Motion puts the ceiling for a movement at 8–24pt; an object crossing three columns travels several hundred |
| Neighbours reflowing | no animation — content already on screen does not animate into place |
| Drop-target outline | motion.duration.instant 100 |
Reduced motion, and the one exemption in the system. Movement is normally replaced, never deleted (WCAG 2.3.3). 1:1 pointer tracking is exempt: it is not a transition, it is the person's own hand, and removing it removes the interaction rather than its decoration. This is the only essential-motion exemption Konjo has, and it does not generalise — everything else in a drag still obeys the rule: the settle becomes an instant swap, auto-scroll steps instead of ramping.
States
- Optimistic by default. The object moves on drop; a failure rolls it back and says so in an inline block, never a toast — a toast disappears and this needs a decision.
- Rollback returns the object to its exact origin, not to the end of the origin group.
- A drop into a group that is full, closed, or not allowed shows no drop target at all. Never accept a drop and then reject it.
- Two people on one board is an ordinary Tuesday. If a move lands on stale state, the object goes where the server says and the announcement says what happened — never silently.
The self-check
- Can someone who cannot drag do the same thing, in the same number of steps, without one?
- Does the drop announce the outcome, not just the action?
- If the network fails mid-gesture, where does the object end up, and is that said out loud?
- Is the thing this moves reversible? If not, why is it a drag?