Reorderable list

Free · MITReact Native · Expo

A drag-to-sort list: a long press (or the grip) lifts a row with a little scale and a shadow, the other rows slide out of its way with a tick per swap, the list scrolls itself near the edges, and the row settles into its slot on release.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add https://goodui.dev/native/r/reorderable-list.json

Then run npx expo install --fix so react-native-gesture-handler, react-native-reanimated, react-native-svg match your Expo SDK. Files land in components/goodui-native/reorderable-list.

Usage

import { useState } from "react";
import { Text, View } from "react-native";
import { ReorderableList } from "@/components/goodui-native/reorderable-list/reorderable-list";

type Song = { id: string; title: string; artist: string };

export function Queue({ initial }: { initial: Song[] }) {
  const [songs, setSongs] = useState(initial);
  return (
    <ReorderableList
      data={songs}
      keyExtractor={(s) => s.id}
      getItemLabel={(s) => s.title}
      accessibilityLabel="Up next"
      onReorder={setSongs}
      itemHeight={60}
      style={{ flex: 1 }}
      contentContainerStyle={{ padding: 16 }}
      renderItem={({ item }) => (
        <View style={{ paddingHorizontal: 16 }}>
          <Text>{item.title}</Text>
          <Text>{item.artist}</Text>
        </View>
      )}
    />
  );
}

When to use

Use it for
  • The person sets an order that matters: playlist, workout, checklist steps, payment priority, home widgets.
  • An edit mode for a list that is normally read-only (pass disabled outside edit mode with dragHandle).
  • Short to medium lists, up to about a hundred rows, of equal height.
Reach for something else
  • Rows of different heights or thousands of rows: this list is not virtualized and needs a fixed itemHeight.
  • Sorting by a value (date, price): give a sort control such as segmented-control instead of manual dragging.
  • Rows with swipe actions: use swipe-actions; the two gestures compete.
  • Moving items between groups or a grid: build a dedicated board.

Examples

Grip handle with an edit mode and row taps

<ReorderableList
  data={tasks}
  keyExtractor={(t) => t.id}
  dragHandle
  disabled={!editing}
  onReorder={setTasks}
  onItemPress={(t) => toggleDone(t.id)}
  renderItem={({ item }) => <TaskRow task={item} />}
/>

Know where it moved

<ReorderableList
  data={cards}
  keyExtractor={(c) => c.id}
  onReorder={(next, { from, to }) => {
    setCards(next);
    api.saveOrder(next.map((c) => c.id));
  }}
  renderItem={({ item, active }) => <CardRow card={item} lifted={active} />}
/>

API reference

PropTypeDefault
data*
Items in their current order. Update it from `onReorder`.
T[]—
keyExtractor*
Stable, unique key per item.
(item: T, index: number) => string—
renderItem*
Renders one row's content, `itemHeight` tall. `active` is true while the row is lifted. Do not put Pressables inside: use `onItemPress`, which works together with dragging on every platform.
(info: { item: T; index: number; active: boolean }) => ReactNode—
onReorder*
Called after a drop or an accessibility move with the items in their new order.
(data: T[], move: { from: number; to: number }) => void—
itemHeight
Height of every row in points. Rows are fixed height so they can slide without measuring. Default 60.
number60
dragHandle
`false` (default): long-press a row to lift it (on the web, press and drag with the mouse). `true`: a grip on the trailing edge drags at once and the rest of the row stays free for taps. The grip hides while `disabled`, so it doubles as an edit mode.
booleanfalse
delayLongPress
Hold time in milliseconds before a row lifts. Default 300.
number300
onItemPress
Tap on a row.
(item: T, index: number) => void—
variant
`inset` puts the rows on a rounded surface with hairlines; `plain` draws no surface (style rows yourself).
"inset" | "plain""inset"
autoScroll
Scroll the list when a lifted row nears its top or bottom edge. Needs a bounded height. Default `true`.
booleantrue
haptic
`false` silences the lift tap and the swap ticks.
booleantrue
disabled
Turns dragging off, for example outside an edit mode.
booleanfalse
getItemLabel
Name of an item for screen readers and move announcements, usually its title.
(item: T, index: number) => string—
accessibilityLabel
Name of the list, for example "Playlist".
string—
labels
Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide.
Partial<ReorderableListLabels>—
style
Style of the scrolling viewport. Give it a height or `flex: 1` for auto-scroll.
StyleProp<ViewStyle>—
contentContainerStyle
Padding around the rows inside the scroll view.
StyleProp<ViewStyle>—
testID
Identifier for end-to-end tests.
string—

Motion and haptics

  • Lift: the row scales to 1.03 on the snappy spring and a raised surface with a shadow fades in under it.
  • The lifted row follows the finger 1:1 with a little give past the first and last slot; the others slide to their new slots on the smooth spring as it crosses their midpoints.
  • Auto-scroll: within 64 pt of the top or bottom edge the list scrolls up to 14 pt per frame, faster closer to the edge, and the row keeps tracking the finger.
  • Drop: the row settles into its slot on the smooth spring while the shadow fades.
  • Reduced motion: no lift scale, the other rows jump to their slots, the drop and the shadow use a short fade.
  • Haptic: drag-start when a row lifts, drop when it lands
  • Haptic: select on every swap while dragging and on each accessibility move
  • Dragging, sliding and auto-scroll run on the UI thread with one shared positions map; React state changes only on lift and drop.
  • Rows are absolutely positioned at a fixed height and not virtualized: keep lists to roughly a hundred rows.
  • The auto-scroll frame callback runs only while a row is lifted.

Accessibility

  • Rows are list items in a list, each with Move up and Move down accessibility actions (plus activate when onItemPress is set). Every move is announced as '<label>, position 3 of 8'.
  • On the web every row is focusable: Alt + Arrow Up and Alt + Arrow Down move it (declared with aria-keyshortcuts), Enter presses it.
  • getItemLabel names rows on native and in announcements; on the web the visible text names the row.
  • The grip is hidden from screen readers because the actions do the same; it has a 44 pt target.

Notes for AI

  • keyExtractor must return stable unique keys; onReorder gives the new array, store it (setData). If you ignore it the list keeps the dragged order on screen.
  • All rows are itemHeight tall (default 60). Content taller than that is clipped by the slot, so size renderItem to it.
  • Do not put Pressable, Switch or buttons in renderItem: they swallow the drag on the web. Use onItemPress for row taps.
  • Give the list a bounded height (style={{ flex: 1 }} or a fixed height) when it can be longer than the screen, or auto-scroll has nothing to scroll.
  • variant='inset' draws a rounded surface; with variant='plain' put your own row styling in renderItem (it sits on the screen background).
  • On the web without dragHandle a row drags as soon as the mouse moves, so touch scrolling over rows on mobile web is not available; use dragHandle for touch-heavy web.