Long-press-drag reorderable list with lifted shadow, animated slot shifting, and pickup/drop haptics
components/ui/reorderable-list/reorderable-list.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0expo-haptics@*Long-press-drag reorderable list with lifted shadow, animated slot shifting, and pickup/drop haptics
import { ReorderableList } from "@/components/ui/reorderable-list/reorderable-list";
export function Example() {
return (
<ReorderableList />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedReaction,
useAnimatedScrollHandler,
useAnimatedRef,
useFrameCallback,
scrollTo,
withSpring,
withTiming,
interpolate,
runOnJS,
type SharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, useHaptic, spring, timing, useReducedMotion, announce } from "@lumioui/core";
/**
* ReorderableList — PRO component. Long-press an item, then drag to reorder.
* The dragged item lifts with a shadow bloom, siblings glide aside, the list
* edge-scrolls proportionally while hovering an edge, and the row settles
* into its slot with a spring before the reorder commits. Haptics fire on
* pickup, slot crossings, and drop. Requires a dev build
* (react-native-gesture-handler).
*
* NOTE: items are laid out at a fixed `itemHeight` (default 72) so slot
* math stays on the UI thread.
*
* The list renders inside an Animated.ScrollView — give it a bounded
* height (e.g. `className="h-96"` or a flex parent) to enable scrolling
* and edge auto-scroll while dragging. Unbounded, it renders at full
* content height like a plain column.
*/
/**
* Edge auto-scroll — velocity-driven (a frame loop applies the speed every
* frame while the drag hovers the edge), so the constants are scroll physics,
* not motion tokens: deeper into the edge zone = faster crawl.
*/
const AUTO_SCROLL_EDGE = 56; // pt from the viewport edge that starts scrolling
const AUTO_SCROLL_MAX = 0.6; // px/ms at full edge penetration (~600px/s)
const AUTO_SCROLL_MIN = 0.12; // px/ms floor — shallow entry still creeps
/** Fallback clear if a controlled parent never commits the reorder. */
const SETTLE_FALLBACK_MS = 400;
export interface ReorderableListRenderInfo<T> {
item: T;
index: number;
isActive: boolean;
}
interface ReorderableListBaseProps<T> extends Omit<ViewProps, "className"> {
data: T[];
renderItem: (info: ReorderableListRenderInfo<T>) => React.ReactElement;
/** Called with the new ordering after a drop. */
onReorder: (data: T[]) => void;
/** Fixed row height used for slot calculations. Default 72. */
itemHeight?: number;
keyExtractor?: (item: T, index: number) => string;
/** Milliseconds of press before drag activates. Default 300. */
activationDelay?: number;
className?: string;
}
export type ReorderableListProps<T> = ReorderableListBaseProps<T> & {
ref?: React.Ref<View>;
};
interface RowProps<T> {
index: number;
item: T;
itemHeight: number;
count: number;
activationDelay: number;
activeIndex: SharedValue<number>;
targetIndex: SharedValue<number>;
dragOffset: SharedValue<number>;
/** True while the dropped row is settling into its slot — onFinalize must
* not cancel the settle flight. */
dropping: SharedValue<number>;
/** Last slot the drag hovered — drives reorder-tick haptics. */
lastTarget: SharedValue<number>;
/** Edge auto-scroll speed (px/ms), written during the drag. */
autoScrollSpeed: SharedValue<number>;
/** Scroll plumbing for edge auto-scroll while dragging. */
scrollY: SharedValue<number>;
viewportH: SharedValue<number>;
contentH: SharedValue<number>;
startScrollY: SharedValue<number>;
renderItem: (info: ReorderableListRenderInfo<T>) => React.ReactElement;
onPickup: () => void;
onDrop: (from: number, to: number) => void;
onSlotTick: () => void;
onActiveChange: (index: number) => void;
isActiveFlag: boolean;
}
function ReorderableRow<T>({
index,
item,
itemHeight,
count,
activationDelay,
activeIndex,
targetIndex,
dragOffset,
dropping,
lastTarget,
autoScrollSpeed,
scrollY,
viewportH,
contentH,
startScrollY,
renderItem,
onPickup,
onDrop,
onSlotTick,
onActiveChange,
isActiveFlag,
}: RowProps<T>) {
const reduce = useReducedMotion();
// Per-row animated channels — written by the reaction below (and the pan
// worklets via dragOffset), read as plain numbers by the animated style.
// Lift scale/shadow and sibling slide live here because withTiming/
// withSpring inside useAnimatedStyle return animation objects, not
// numbers — animating must happen in reactions/callbacks, not the style.
const rowShift = useSharedValue(0);
const rowScale = useSharedValue(1);
/** Shadow bloom driver — 0 idle, 1 lifted. */
const rowShadow = useSharedValue(0);
// Lift + sibling-shift state derives from the LIST-level shared values
// activeIndex/targetIndex, which change from this row's own gesture, from
// other rows' gestures, and from the parent's post-drop cleanup — a
// reaction is the only path that observes all three.
useAnimatedReaction(
() => ({ a: activeIndex.value, t: targetIndex.value }),
(s) => {
const active = s.a === index;
// Items between the dragged row's original slot and target slot move
// out of the way.
let shift = 0;
if (!active && s.a >= 0) {
if (s.a < s.t && index > s.a && index <= s.t) shift = -itemHeight;
else if (s.a > s.t && index < s.a && index >= s.t) shift = itemHeight;
}
rowShift.value = reduce ? shift : withTiming(shift, timing.fast);
rowScale.value = reduce ? 1 : withSpring(active ? 1.05 : 1, spring.press);
rowShadow.value = reduce
? active
? 1
: 0
: withTiming(active ? 1 : 0, timing.fast);
},
[index, itemHeight, reduce]
);
const pan = React.useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(activationDelay)
.onStart(() => {
"worklet";
// Grabbing a new row mid-settle cancels the flight instantly.
if (dropping.value) {
dropping.value = 0;
dragOffset.value = 0;
}
activeIndex.value = index;
targetIndex.value = index;
lastTarget.value = index;
dragOffset.value = 0;
startScrollY.value = scrollY.value;
autoScrollSpeed.value = 0;
runOnJS(onActiveChange)(index);
runOnJS(onPickup)();
})
.onUpdate((e: { translationY: number }) => {
"worklet";
const offset = e.translationY + (scrollY.value - startScrollY.value);
dragOffset.value = offset;
const t = Math.max(
0,
Math.min(count - 1, Math.round(index + offset / itemHeight))
);
if (t !== targetIndex.value) {
targetIndex.value = t;
if (t !== lastTarget.value) {
lastTarget.value = t;
runOnJS(onSlotTick)();
}
}
// Edge auto-scroll: write a speed proportional to how deep the
// dragged row sits in the edge zone; the frame loop applies it.
const maxScroll = Math.max(0, contentH.value - viewportH.value);
if (viewportH.value > 0 && maxScroll > 0) {
const itemTop = index * itemHeight + offset;
const topEdge = scrollY.value + AUTO_SCROLL_EDGE;
const bottomEdge = scrollY.value + viewportH.value - AUTO_SCROLL_EDGE;
if (itemTop < topEdge) {
const f = Math.min(1, (topEdge - itemTop) / AUTO_SCROLL_EDGE);
autoScrollSpeed.value = -(AUTO_SCROLL_MIN + (AUTO_SCROLL_MAX - AUTO_SCROLL_MIN) * f);
} else if (itemTop + itemHeight > bottomEdge) {
const f = Math.min(1, (itemTop + itemHeight - bottomEdge) / AUTO_SCROLL_EDGE);
autoScrollSpeed.value = AUTO_SCROLL_MIN + (AUTO_SCROLL_MAX - AUTO_SCROLL_MIN) * f;
} else {
autoScrollSpeed.value = 0;
}
}
})
.onEnd(() => {
"worklet";
const from = activeIndex.value;
const to = targetIndex.value;
autoScrollSpeed.value = 0;
// Settle: fly the lifted row into its target slot, then hand the
// reorder to JS. activeIndex stays set during the flight AND while
// the parent re-renders, so siblings hold their shifted positions
// until the new data lands — no snap-back flash.
const slot = (to - index) * itemHeight;
dropping.value = 1;
if (reduce) {
dragOffset.value = slot;
runOnJS(onDrop)(from, to);
} else {
dragOffset.value = withSpring(slot, spring.layout, (finished) => {
if (finished) runOnJS(onDrop)(from, to);
});
}
})
.onFinalize(() => {
"worklet";
// Cancel path only — after a drop, `dropping` owns the cleanup.
if (dropping.value) return;
if (activeIndex.value === index) {
autoScrollSpeed.value = 0;
// Glide the row home first, then release the drag flags —
// clearing first would snap it back instead of settling.
dragOffset.value = reduce
? 0
: withSpring(0, spring.layout, (finished) => {
"worklet";
if (finished) {
activeIndex.value = -1;
targetIndex.value = -1;
runOnJS(onActiveChange)(-1);
}
});
if (reduce) {
activeIndex.value = -1;
targetIndex.value = -1;
runOnJS(onActiveChange)(-1);
}
}
}),
[
index,
itemHeight,
count,
activationDelay,
activeIndex,
targetIndex,
dragOffset,
dropping,
lastTarget,
autoScrollSpeed,
scrollY,
viewportH,
contentH,
startScrollY,
onPickup,
onDrop,
onSlotTick,
onActiveChange,
reduce,
]
);
const style = useAnimatedStyle(() => {
const active = activeIndex.value === index;
return {
transform: [
// The dragged row tracks the finger 1:1 via dragOffset (which also
// carries the settle flight on drop); siblings read their slide
// offset from rowShift, animated by the reaction above.
{ translateY: active ? dragOffset.value : rowShift.value },
{ scale: rowScale.value },
],
zIndex: active ? 10 : 0,
// Shadow bloom — radius and opacity grow together so the lift reads
// as the row rising toward the finger, not just a scale bump.
shadowOpacity: interpolate(rowShadow.value, [0, 1], [0, 0.3]),
shadowRadius: interpolate(rowShadow.value, [0, 1], [4, 12]),
elevation: active ? 10 : 0,
};
});
return (
<GestureDetector gesture={pan}>
<Animated.View
accessible
accessibilityRole="adjustable"
accessibilityLabel={`Reorderable item ${index + 1} of ${count}`}
accessibilityValue={{ min: 1, max: count, now: index + 1 }}
// RNW drops accessibilityValue — mirror so the web slider role
// reports the row's slot.
aria-valuemin={1}
aria-valuemax={count}
aria-valuenow={index + 1}
aria-valuetext={`position ${index + 1} of ${count}`}
accessibilityHint="Long press, then drag to reorder. Actions can also move this item."
accessibilityActions={[
{ name: "move-up", label: "Move up" },
{ name: "move-down", label: "Move down" },
{ name: "move-top", label: "Move to top" },
{ name: "move-bottom", label: "Move to bottom" },
]}
onAccessibilityAction={(e) => {
const action = e.nativeEvent.actionName;
if (action === "move-up" && index > 0) onDrop(index, index - 1);
else if (action === "move-down" && index < count - 1) onDrop(index, index + 1);
else if (action === "move-top" && index > 0) onDrop(index, 0);
else if (action === "move-bottom" && index < count - 1) onDrop(index, count - 1);
}}
style={[
{
height: itemHeight,
// EXCEPTION: the palette has no shadow token — shadows stay
// black under both schemes (a light-scheme `foreground`
// would glow white in dark mode).
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
},
style,
]}
>
{renderItem({ item, index, isActive: isActiveFlag })}
</Animated.View>
</GestureDetector>
);
}
function ReorderableListInner<T>(
{
data,
renderItem,
onReorder,
itemHeight = 72,
keyExtractor,
activationDelay = 300,
className,
...props
}: ReorderableListBaseProps<T>,
ref: React.Ref<View>
) {
const haptics = useHaptic();
const [activeIdx, setActiveIdx] = React.useState(-1);
const activeIndex = useSharedValue(-1);
const targetIndex = useSharedValue(-1);
const dragOffset = useSharedValue(0);
/** 1 while a dropped row is flying into its slot; cleared when the
* reordered data commits (or by the fallback timeout). */
const dropping = useSharedValue(0);
const lastTarget = useSharedValue(-1);
const autoScrollSpeed = useSharedValue(0);
// Scroll plumbing for edge auto-scroll — shared values so the row
// worklets can read viewport geometry and drive scrollTo on the UI thread.
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollY = useSharedValue(0);
const viewportH = useSharedValue(0);
const contentH = useSharedValue(0);
const startScrollY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
scrollY.value = e.contentOffset.y;
},
});
const slotTick = React.useCallback(() => {
haptics.selection();
}, [haptics]);
// Row count as a shared value so the frame callback's worklet stays valid
// across data changes.
const countSV = useSharedValue(data.length);
React.useEffect(() => {
countSV.value = data.length;
}, [data.length, countSV]);
// Edge auto-scroll: per-frame application of the speed the active row's
// onUpdate wrote. Velocity-proportional and frame-coupled, so a finger
// parked at the edge creeps smoothly instead of stepping per touch event.
// The scroll delta folds into dragOffset so the row stays glued to the
// finger — and re-evaluates the target slot as content scrolls past.
useFrameCallback((frame) => {
"worklet";
const speed = autoScrollSpeed.value;
if (speed === 0 || activeIndex.value < 0) return;
const maxScroll = Math.max(0, contentH.value - viewportH.value);
if (maxScroll <= 0) {
autoScrollSpeed.value = 0;
return;
}
const dt = frame.timeSincePreviousFrame ?? 16;
const nextY = Math.max(0, Math.min(maxScroll, scrollY.value + speed * dt));
if (nextY === scrollY.value) {
autoScrollSpeed.value = 0;
return;
}
const dy = nextY - scrollY.value;
scrollTo(scrollRef, 0, nextY, false);
scrollY.value = nextY;
dragOffset.value += dy;
const t = Math.max(
0,
Math.min(countSV.value - 1, Math.round(activeIndex.value + dragOffset.value / itemHeight))
);
if (t !== targetIndex.value) {
targetIndex.value = t;
if (t !== lastTarget.value) {
lastTarget.value = t;
runOnJS(slotTick)();
}
}
});
/** Set when a drop is waiting on the parent's reordered data. */
const settlePending = React.useRef(false);
/** Fallback-timer handle — cleared on early settle and on unmount. */
const settleTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Never leave the fallback armed after unmount.
React.useEffect(
() => () => {
if (settleTimer.current) clearTimeout(settleTimer.current);
},
[]
);
/** Release the post-drop drag state — lifted flags clear, rows rest. */
const clearSettle = React.useCallback(() => {
if (settleTimer.current) {
clearTimeout(settleTimer.current);
settleTimer.current = null;
}
// `dropping` already 0 means a fresh grab superseded this settle —
// don't wipe the new drag's flags.
if (!settlePending.current || dropping.value === 0) {
settlePending.current = false;
return;
}
settlePending.current = false;
dropping.value = 0;
activeIndex.value = -1;
targetIndex.value = -1;
dragOffset.value = 0;
setActiveIdx(-1);
}, [dropping, activeIndex, targetIndex, dragOffset]);
// Clear the drag state in a LAYOUT effect on data change: the reordered
// rows commit with fresh indexes and the dragged row is already resting at
// its slot — clearing here (before paint) avoids a snap-back flash.
React.useLayoutEffect(() => {
clearSettle();
}, [data, clearSettle]);
const handleDrop = React.useCallback(
(from: number, to: number) => {
if (from < 0 || from === to) {
// Dropped where it started — the row already glided home; release
// immediately since no reorder render is coming.
haptics.light();
settlePending.current = false;
dropping.value = 0;
activeIndex.value = -1;
targetIndex.value = -1;
dragOffset.value = 0;
setActiveIdx(-1);
return;
}
haptics.medium();
settlePending.current = true;
const next = data.slice();
const [moved] = next.splice(from, 1);
if (moved !== undefined) next.splice(to, 0, moved);
announce(`Moved to position ${to + 1} of ${data.length}`);
onReorder(next);
// Fallback: a controlled parent that swallows onReorder would leave
// the row lifted forever — release it after a beat.
if (settleTimer.current) clearTimeout(settleTimer.current);
settleTimer.current = setTimeout(() => {
settleTimer.current = null;
clearSettle();
}, SETTLE_FALLBACK_MS);
},
[data, onReorder, haptics, dropping, activeIndex, targetIndex, dragOffset, clearSettle]
);
const handlePickup = React.useCallback(() => {
haptics.medium();
}, [haptics]);
return (
<View ref={ref} className={cn("w-full", className)} {...props}>
{/* Scrollable viewport — auto-scroll target while dragging.
flex-1 fills a bounded parent; unbounded it hugs content height. */}
<Animated.ScrollView
ref={scrollRef}
className="w-full flex-1"
onScroll={onScroll}
scrollEventThrottle={16}
onLayout={(e) => {
viewportH.value = e.nativeEvent.layout.height;
}}
onContentSizeChange={(_w, h) => {
contentH.value = h;
}}
>
{data.map((item, index) => (
<ReorderableRow<T>
key={keyExtractor ? keyExtractor(item, index) : `row-${index}`}
index={index}
item={item}
itemHeight={itemHeight}
count={data.length}
activationDelay={activationDelay}
activeIndex={activeIndex}
targetIndex={targetIndex}
dragOffset={dragOffset}
dropping={dropping}
lastTarget={lastTarget}
autoScrollSpeed={autoScrollSpeed}
scrollY={scrollY}
viewportH={viewportH}
contentH={contentH}
startScrollY={startScrollY}
renderItem={renderItem}
onPickup={handlePickup}
onDrop={handleDrop}
onSlotTick={slotTick}
onActiveChange={setActiveIdx}
isActiveFlag={activeIdx === index}
/>
))}
</Animated.ScrollView>
</View>
);
}
/**
* Generic forwardRef wrapper — keeps `ReorderableList<T>` inference intact
* while forwarding the root View ref.
*/
export const ReorderableList = Object.assign(
forwardRef(ReorderableListInner) as <T>(
props: ReorderableListProps<T>
) => React.ReactElement,
{ displayName: "ReorderableList" }
);