Browse all of Kata

Direct manipulation

Source docs/design/patterns/direct-manipulation.mdMarkdown

On this page

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:

  • polite carries progress — the grab, and the in-flight message on every arrow press.
  • assertive carries 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 polite and will sometimes be dropped when arrows repeat quickly. That is correct — and the outcome is assertive for 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 in aria-roledescription on 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?