Dots

Sortable List

A drag-to-reorder list that works with pointer, touch, keyboard and screen readers.

AM
Alex MorganPrimary contact
JL
Jordan LeeBackup contact
SR
Sam RiveraNotified last

Order: Alex, Jordan, Sam

"use client"

import * as React from "react"

Use a sortable list when people set their own order, such as the sections on a home screen, the accounts in a switcher or the contacts to notify first. Each row has a drag handle, an optional action and a muted state, and two lists that share a drag type can exchange rows for show and hide. Pick a plain Item list when the order is fixed.

Installation

pnpm dlx shadcn@latest add @dots/sortable-list

Usage

import { SortableList, SortableListItem } from "@/components/ui/sortable-list"
const [accounts, setAccounts] = React.useState([
  { id: "main", name: "Main account" },
  { id: "savings", name: "Savings" },
])
 
<SortableList
  aria-label="Account order"
  items={accounts}
  getItemText={(account) => account.name}
  onReorder={setAccounts}
>
  {(account) => (
    <SortableListItem id={account.id} textValue={account.name} />
  )}
</SortableList>

The list is controlled. It reports the new order through onReorder and renders the items you pass back.

Composition

SortableList
└── SortableListItem
    ├── leading
    ├── title and description
    ├── action
    └── drag handle

Features

  • Built on React Aria GridList drag and drop
  • Drag a row with the pointer or a finger, or focus its handle and use the keyboard
  • Screen readers announce where a row is picked up and dropped
  • A leading slot, a description line and an action slot on every row
  • A muted state for rows in a "Hidden" group
  • Lists that share a dragType accept each other's rows
  • An empty state that is also a drop target
  • isDragDisabled to lock the order, for example while saving

Show and hide

Give two lists the same dragType and handle onInsert on both. Rows can be dragged between them, and each row's action button moves it without dragging. Set muted on the hidden rows.

On Home

BalanceTotal and recent change
Quick actionsSend, request, top up
Recent activityLast five payments

Hidden

Savings goalsProgress toward each goal
Exchange ratesPairs you follow
"use client"

import * as React from "react"
<SortableList
  aria-labelledby={hiddenLabel}
  items={hidden}
  dragType="application/x-home-sections"
  onReorder={setHidden}
  onInsert={(keys, index) => moveTo("hidden", keys, index)}
  renderEmptyState={() => "Nothing hidden"}
>
  {(section) => (
    <SortableListItem id={section.id} textValue={section.title} muted />
  )}
</SortableList>

onInsert receives the dragged keys and the index they were dropped at. Remove the rows from the source list and insert them into the target list there.

Locked order

Set isDragDisabled to turn dragging off. Rows cannot be dragged until it is turned back on.

Main account$2,480.00
Savings$9,150.25
Travel fund$640.10
"use client"

import * as React from "react"

Accessibility

React Aria renders the list as a grid with one row per item. The pointer drags the whole row, while the handle is a real button and the keyboard and screen reader entry point: focus it, press Enter, move to a drop position and press Enter again to drop, or Escape to cancel. Drops are announced. Row actions, such as the hide and show buttons, give keyboard users a second way to move rows between lists.

Name the list

Give every list a name with aria-label, or with aria-labelledby pointing at a visible heading.

<SortableList aria-label="Contact order" items={people} onReorder={setPeople}>
  {/* rows */}
</SortableList>

Give rows a text value

textValue is required. React Aria reads it in the handle's name and in drag announcements, so it should match the visible title.

Label row actions

Action buttons are usually icon-only. Give each an aria-label with the action and the row.

<Button variant="ghost" size="icon-sm" aria-label={`Hide ${section.title}`}>
  <EyeOffIcon />
</Button>

API Reference

SortableList

The list. Renders a React Aria GridList with a stack layout and accepts its other props, such as aria-label and aria-labelledby.

PropTypeDefaultDescription
itemsT[]-The rows, in order. Each needs a stable id. Required.
children(item: T) => React.ReactNode-Renders one row, usually a SortableListItem. Required.
onReorder(items: T[]) => void-Called with the whole list in its new order after a drop inside it.
onInsert(keys: Key[], index: number) => void-Accepts rows from another list with the same dragType.
getItemText(item: T) => stringString(item.id)Text for the drag preview and for drags into other apps.
dragTypestringunique to the listLists that share it accept each other's rows. Compared in lowercase.
isDragDisabledbooleanfalseTurns dragging off.
renderEmptyState() => React.ReactNode"No items"Shown when the list has no rows. Rows can be dropped there.
classNamestring-Additional classes to apply to the list.

T is any object with an id of type Key (a string or a number).

SortableListItem

One row with a drag handle at the end. Renders a React Aria GridListItem and accepts its other props.

PropTypeDefaultDescription
idKey-The item's id. Must match the item in items.
textValuestring-Plain-text name read in the handle and announcements. Required.
childrenReact.ReactNodetextValueThe row's title.
descriptionReact.ReactNode-One quiet line under the title.
leadingReact.ReactNode-Avatar, logo or icon before the text.
actionReact.ReactNode-Controls before the handle, such as a hide or show button.
mutedbooleanfalseDims the row, for items in a "Hidden" group.
classNamestring-Additional classes to apply to the row.