โœฆ StealThis .dev

React Native Drag to Reorder

A drag-to-reorder list for React Native using PanResponder with long-press activation, animated item displacement, and drop-to-place.

react-native typescript
Targets: React Native

Expo Snack

Code

import React, { useState, useRef, useCallback } from "react";
import {
  View,
  Text,
  PanResponder,
  Animated,
  StyleSheet,
  type ViewStyle,
  type TextStyle,
  type GestureResponderEvent,
  type PanResponderGestureState,
} from "react-native";

// โ”€โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

interface DraggableListProps<T> {
  data: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  onReorder: (fromIndex: number, toIndex: number) => void;
  itemHeight: number;
}

// โ”€โ”€โ”€ DraggableList โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function DraggableList<T>({ data, renderItem, onReorder, itemHeight }: DraggableListProps<T>) {
  const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
  const [hoverIndex, setHoverIndex] = useState<number | null>(null);

  const dragY = useRef(new Animated.Value(0)).current;
  const scaleAnim = useRef(new Animated.Value(1)).current;
  const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const isDragging = useRef(false);
  const startY = useRef(0);
  const currentIndex = useRef(0);
  const dataRef = useRef(data);
  dataRef.current = data;

  // Displacement animations for non-dragged items
  const displacements = useRef<Animated.Value[]>(data.map(() => new Animated.Value(0)));
  if (displacements.current.length !== data.length) {
    displacements.current = data.map(() => new Animated.Value(0));
  }

  const getHoverIndex = useCallback(
    (gestureY: number) => {
      const raw = Math.round((startY.current + gestureY) / itemHeight);
      return Math.max(0, Math.min(data.length - 1, raw));
    },
    [data.length, itemHeight]
  );

  const updateDisplacements = useCallback(
    (from: number, to: number) => {
      for (let i = 0; i < data.length; i++) {
        let targetY = 0;
        if (i !== from) {
          if (from < to && i > from && i <= to) {
            targetY = -itemHeight;
          } else if (from > to && i >= to && i < from) {
            targetY = itemHeight;
          }
        }
        Animated.spring(displacements.current[i], {
          toValue: targetY,
          useNativeDriver: true,
          tension: 300,
          friction: 25,
        }).start();
      }
    },
    [data.length, itemHeight]
  );

  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onMoveShouldSetPanResponder: () => isDragging.current,

      onPanResponderGrant: (evt: GestureResponderEvent) => {
        const y = evt.nativeEvent.pageY;
        // We'll compute the index from layout
        // For simplicity, use locationY relative to the list
        const locationY = evt.nativeEvent.locationY;
        const index = Math.floor(locationY / itemHeight);
        const clampedIndex = Math.max(0, Math.min(data.length - 1, index));

        currentIndex.current = clampedIndex;
        startY.current = clampedIndex * itemHeight;

        longPressTimer.current = setTimeout(() => {
          isDragging.current = true;
          setDraggingIndex(clampedIndex);
          setHoverIndex(clampedIndex);

          Animated.spring(scaleAnim, {
            toValue: 1.05,
            useNativeDriver: true,
            tension: 300,
            friction: 20,
          }).start();
        }, 500);
      },

      onPanResponderMove: (_: GestureResponderEvent, gestureState: PanResponderGestureState) => {
        if (Math.abs(gestureState.dy) > 10 && !isDragging.current) {
          // Cancel long press if finger moves too much before activation
          if (longPressTimer.current) {
            clearTimeout(longPressTimer.current);
            longPressTimer.current = null;
          }
          return;
        }

        if (!isDragging.current) return;

        dragY.setValue(gestureState.dy);

        const newHover = getHoverIndex(gestureState.dy);
        if (newHover !== currentIndex.current) {
          setHoverIndex(newHover);
          updateDisplacements(currentIndex.current, newHover);
        }
      },

      onPanResponderRelease: (_: GestureResponderEvent, gestureState: PanResponderGestureState) => {
        if (longPressTimer.current) {
          clearTimeout(longPressTimer.current);
          longPressTimer.current = null;
        }

        if (!isDragging.current) return;

        isDragging.current = false;
        const from = currentIndex.current;
        const to = getHoverIndex(gestureState.dy);

        // Snap animation
        Animated.parallel([
          Animated.spring(dragY, {
            toValue: (to - from) * itemHeight,
            useNativeDriver: true,
            tension: 300,
            friction: 25,
          }),
          Animated.spring(scaleAnim, {
            toValue: 1,
            useNativeDriver: true,
            tension: 300,
            friction: 20,
          }),
        ]).start(() => {
          // Reset all animations
          dragY.setValue(0);
          scaleAnim.setValue(1);
          displacements.current.forEach((d) => d.setValue(0));

          setDraggingIndex(null);
          setHoverIndex(null);

          if (from !== to) {
            onReorder(from, to);
          }
        });
      },

      onPanResponderTerminate: () => {
        if (longPressTimer.current) {
          clearTimeout(longPressTimer.current);
          longPressTimer.current = null;
        }
        isDragging.current = false;
        dragY.setValue(0);
        scaleAnim.setValue(1);
        displacements.current.forEach((d) => d.setValue(0));
        setDraggingIndex(null);
        setHoverIndex(null);
      },
    })
  ).current;

  return (
    <View
      style={[styles.listContainer, { height: data.length * itemHeight }]}
      {...panResponder.panHandlers}
    >
      {data.map((item, index) => {
        const isDraggedItem = index === draggingIndex;

        const animatedStyle = isDraggedItem
          ? {
              transform: [{ translateY: dragY }, { scale: scaleAnim }],
              zIndex: 999,
              shadowColor: "#38bdf8",
              shadowOffset: { width: 0, height: 8 },
              shadowOpacity: 0.4,
              shadowRadius: 16,
              elevation: 12,
              opacity: 0.9,
            }
          : {
              transform: [{ translateY: displacements.current[index] || new Animated.Value(0) }],
              zIndex: 1,
            };

        return (
          <Animated.View
            key={index}
            style={[
              styles.itemWrapper,
              { top: index * itemHeight, height: itemHeight },
              animatedStyle,
            ]}
          >
            {renderItem(item, index)}
          </Animated.View>
        );
      })}
    </View>
  );
}

// โ”€โ”€โ”€ Demo App โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

interface Task {
  id: number;
  title: string;
  priority: "low" | "med" | "high";
}

const INITIAL_TASKS: Task[] = [
  { id: 1, title: "Design system tokens", priority: "high" },
  { id: 2, title: "Set up CI pipeline", priority: "high" },
  { id: 3, title: "Write API endpoints", priority: "high" },
  { id: 4, title: "Implement auth flow", priority: "med" },
  { id: 5, title: "Create onboarding screens", priority: "med" },
  { id: 6, title: "Add push notifications", priority: "med" },
  { id: 7, title: "Write unit tests", priority: "low" },
  { id: 8, title: "Update documentation", priority: "low" },
];

const PRIORITY_COLORS: Record<Task["priority"], string> = {
  high: "#ef4444",
  med: "#f59e0b",
  low: "#22c55e",
};

export default function App() {
  const [tasks, setTasks] = useState<Task[]>(INITIAL_TASKS);
  const ITEM_HEIGHT = 72;

  const handleReorder = useCallback((from: number, to: number) => {
    setTasks((prev) => {
      const next = [...prev];
      const [moved] = next.splice(from, 1);
      next.splice(to, 0, moved);
      return next;
    });
  }, []);

  return (
    <View style={styles.app}>
      <Text style={styles.title}>Task Priority</Text>
      <Text style={styles.subtitle}>Long-press and drag to reorder</Text>

      <DraggableList<Task>
        data={tasks}
        itemHeight={ITEM_HEIGHT}
        onReorder={handleReorder}
        renderItem={(task, index) => (
          <View style={styles.taskRow}>
            <Text style={styles.dragHandle}>โ‰ก</Text>
            <View style={styles.taskContent}>
              <Text style={styles.taskTitle}>{task.title}</Text>
              <View style={styles.taskMeta}>
                <View
                  style={[styles.priorityDot, { backgroundColor: PRIORITY_COLORS[task.priority] }]}
                />
                <Text style={styles.priorityLabel}>{task.priority}</Text>
              </View>
            </View>
            <Text style={styles.orderNumber}>{index + 1}</Text>
          </View>
        )}
      />
    </View>
  );
}

// โ”€โ”€โ”€ Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

const styles = StyleSheet.create({
  app: {
    flex: 1,
    backgroundColor: "#0f172a",
    paddingTop: 60,
    paddingHorizontal: 16,
  } as ViewStyle,
  title: {
    fontSize: 24,
    fontWeight: "700",
    color: "#f8fafc",
    marginBottom: 4,
  } as TextStyle,
  subtitle: {
    fontSize: 14,
    color: "#64748b",
    marginBottom: 24,
  } as TextStyle,
  listContainer: {
    position: "relative",
  } as ViewStyle,
  itemWrapper: {
    position: "absolute",
    left: 0,
    right: 0,
    paddingVertical: 4,
  } as ViewStyle,
  taskRow: {
    flex: 1,
    flexDirection: "row",
    alignItems: "center",
    backgroundColor: "#1e293b",
    borderRadius: 12,
    paddingHorizontal: 14,
    borderWidth: 1,
    borderColor: "#334155",
  } as ViewStyle,
  dragHandle: {
    fontSize: 22,
    color: "#475569",
    marginRight: 12,
    fontWeight: "700",
  } as TextStyle,
  taskContent: {
    flex: 1,
  } as ViewStyle,
  taskTitle: {
    fontSize: 16,
    fontWeight: "600",
    color: "#f8fafc",
    marginBottom: 2,
  } as TextStyle,
  taskMeta: {
    flexDirection: "row",
    alignItems: "center",
  } as ViewStyle,
  priorityDot: {
    width: 8,
    height: 8,
    borderRadius: 4,
    marginRight: 6,
  } as ViewStyle,
  priorityLabel: {
    fontSize: 12,
    color: "#64748b",
    textTransform: "uppercase",
    fontWeight: "600",
    letterSpacing: 0.5,
  } as TextStyle,
  orderNumber: {
    fontSize: 14,
    color: "#475569",
    fontWeight: "700",
    fontVariant: ["tabular-nums"],
  } as TextStyle,
});

Drag to Reorder List

A fully gesture-driven sortable list built with React Nativeโ€™s built-in PanResponder โ€” no third-party gesture libraries required. Long-press an item to pick it up, drag it over other rows, and release to drop it into its new position.

Features

  • Long-press activation โ€” a 500ms hold triggers drag mode, preventing accidental reorders on scroll
  • Visual lift โ€” the dragged item scales up, gains a shadow, and becomes slightly transparent so it feels โ€œpicked upโ€
  • Animated displacement โ€” surrounding items smoothly slide out of the way as the dragged item moves through the list
  • Snap on release โ€” when you let go, the item animates to its final position in the reordered list
  • Zero dependencies โ€” uses only PanResponder and Animated from React Native core

Usage

Pass your data array, an itemHeight value, and an onReorder callback that receives (fromIndex, toIndex). The component manages all gesture and animation state internally. Use renderItem to control how each row looks.