Sortable

Animated

A list reordered by drag or by keyboard, both ending in the same place.

View Source

#Installation

npx @dinachi/cli@latest add sortable

#Usage

tsx
import { Sortable, SortableItem, SortableHandle } from "@/components/ui/sortable"
tsx
const [order, setOrder] = useState(["overview", "install", "components"])
 
<Sortable value={order} onValueChange={setOrder}>
  {order.map((id) => (
    <SortableItem key={id} id={id} label={pages[id].title}>
      <SortableHandle />
      <span>{pages[id].title}</span>
    </SortableItem>
  ))}
</Sortable>

#Examples

#The order is ids

value is a string[], not your objects. Identity is what a reorder is about, and it is what React's keys need anyway, so the list carries the ids and you keep the lookup. It also means the drag and the announcements agree on what a row is without a comparator.

#Reordering without a pointer

Reordering by pointer and reordering by keyboard have to end at the same place, and only one of them can be expressed as a gesture. SortableHandle is a real button, and every key below goes through the same reorder the drag does.

KeyEffect
Space / EnterPicks the row up, or drops it if it is already up.
/ Moves the grabbed row one position.
EscRestores the order from before the grab.
TabDrops the row where it is. Focus left, but the moves were deliberate.

Escape is stopped from propagating while a row is grabbed, so cancelling a reorder inside a dialog does not also close the dialog out from under it.

#When to use it

Use it forNot for
A list whose order is content: navigation, a playlist, a pinned set, form fields.A list the reader only reads. A drag affordance on a list nobody reorders is one more thing on the row.
Short lists the reader can see whole.A thousand rows. Reordering by dragging across a scroll is a different, harder component.

#Behaviour

  • The row only moves from the handle. A whole-row drag target fights text selection and swallows every click inside the row.
  • The lifted row scales a hair and takes a shadow, because a row being dragged is above the page rather than in it. Without that, a row in flight and a row at rest are the same object in two places.
  • The shadow is an opacity change, not an interpolated boxShadow. A shadow keyframe repaints the blur on every frame; fading a layer that already has one does not.
  • Displaced rows animate with FLIP, so a row that moved because another passed it reads as pushed rather than repainted somewhere else.
  • The row's contents are yours. Put the handle wherever the layout wants it.

#Accessibility

  • The list keeps ul/li semantics; only the markers are dropped.
  • Each handle is named after its row, as in Reorder Installation, and carries aria-pressed while the row is up.
  • Every move is announced through an assertive live region, position included. A reorder produces no DOM event a screen reader reports on its own, and the reader is mid-interaction: the position is the only thing telling them where the row went.
  • Reduced motion keeps the reorder and drops the lift. Reordering is the function.

#API Reference

PropTypeDefaultDescription
valuestring[]The current order, as stable ids. Ids rather than objects because identity is what a reorder is about
onValueChange(next: string[]) => voidFired with the new order, whether it came from a drag or from the keyboard
idstringOn SortableItem. The id this row carries in value. Give the same string to React's key
labelstringOn SortableItem. Names the row in the reorder announcements and in its handle's accessible name
childrenReactNodegrip iconOn SortableHandle. The affordance inside the button