Reorderable list
Free · MITReact Native · ExpoA 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.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/reorderable-list.jsonThen 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
- 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.
- 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
| Prop | Type | Default |
|---|---|---|
| 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. | number | 60 |
| 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. | boolean | false |
| delayLongPress Hold time in milliseconds before a row lifts. Default 300. | number | 300 |
| 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`. | boolean | true |
| haptic `false` silences the lift tap and the swap ticks. | boolean | true |
| disabled Turns dragging off, for example outside an edit mode. | boolean | false |
| 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.
Related
Settings and navigation cells with a token-colored icon tile, title, subtitle and a trailing value, switch, badge, check or chevron, grouped in inset sections with headers and footers. On touch down the highlight floods in, the tile sinks and the chevron leans forward; on release the highlight lingers and fades.
Row actions that unfold with the finger; a long swipe lets the edge action take over and run, and destructive actions collapse the row before it leaves.
A sectioned list whose sticky headers change as they stick: the title shrinks toward the leading edge and a surface with a hairline (frosted on the web) fades in under it. A contacts-style index on the side jumps between sections while a large letter follows the finger, with a tick per letter.