React Native Reanimated DnDReact Native Reanimated DnD
Hooks

useSortable

The useSortable hook provides the core functionality for individual items within a sortable list, handling drag gestures, position animations, auto-scrolling, and reordering logic.

Overview

This hook works in conjunction with useSortableList to provide a complete sortable solution. It handles the drag interactions for individual items, manages their position within the list, and provides smooth animations during reordering.

Basic Usage

import { useSortable } from "react-native-reanimated-dnd";
import { GestureDetector } from "react-native-gesture-handler";

function SortableTaskItem({ task, positions, ...sortableProps }) {
  const { animatedStyle, panGestureHandler, isMoving } = useSortable({
    id: task.id,
    positions,
    ...sortableProps,
    onMove: (id, from, to) => {
      console.log(`Task ${id} moved from ${from} to ${to}`);
      reorderTasks(id, from, to);
    },
  });

  return (
    <GestureDetector gesture={panGestureHandler}>
      <Animated.View style={[styles.taskItem, animatedStyle]}>
        <Text style={[styles.taskText, isMoving && styles.dragging]}>
          {task.title}
        </Text>
      </Animated.View>
    </GestureDetector>
  );
}

Parameters

UseSortableOptions<T>

OptionTypeDefaultDescription
idstringRequiredUnique identifier for the sortable item
positionsSharedValue<{[id: string]: number}>RequiredShared value tracking item positions
lowerBoundSharedValue<number>RequiredLower scroll boundary for auto-scroll
autoScrollDirectionSharedValue<ScrollDirection>RequiredAuto-scroll direction state
itemsCountnumberRequiredTotal number of items in the list
itemHeightnumberRequiredHeight of each item in pixels
containerHeightnumber-Height of the container
onMove(id: string, from: number, to: number) => void-Callback when item is moved
onDragStart(id: string, position: number) => void-Callback when dragging starts
onDrop(id: string, position: number) => void-Callback when dragging ends
onDragging(id: string, overItemId: string | null, yPosition: number) => void-Callback during dragging

ScrollDirection Enum

enum ScrollDirection {
  None = "none",
  Up = "up",
  Down = "down",
}

Return Value

UseSortableReturn

PropertyTypeDescription
animatedStyleStyleProp<ViewStyle>Animated styles for position and transforms
panGestureHandlerGestureTypePan gesture to pass to GestureDetector
isMovingbooleanWhether the item is currently being dragged
hasHandlebooleanWhether the item has a drag handle
registerHandle(registered: boolean) => voidCallback for handle registration

Examples

Basic Sortable Item with Callbacks

function TaskItem({ task, positions, ...sortableProps }) {
  const [isDragging, setIsDragging] = useState(false);
  const [dragStartTime, setDragStartTime] = useState(0);

  const { animatedStyle, panGestureHandler, isMoving } = useSortable({
    id: task.id,
    positions,
    ...sortableProps,
    onMove: (id, from, to) => {
      console.log(`Task ${id} moved from position ${from} to ${to}`);

      // Update data
      reorderTasks(id, from, to);

      // Analytics
      analytics.track("task_reordered", {
        taskId: id,
        fromPosition: from,
        toPosition: to,
        taskTitle: task.title,
        dragDuration: Date.now() - dragStartTime,
      });

      // Auto-save
      saveTasks();
    },
    onDragStart: (id, position) => {
      setIsDragging(true);
      setDragStartTime(Date.now());
      hapticFeedback();

      console.log(`Started dragging task ${id} at position ${position}`);

      // Show global drag feedback
      showDragOverlay(task);
    },
    onDrop: (id, position) => {
      setIsDragging(false);
      hideDragOverlay();

      console.log(`Dropped task ${id} at position ${position}`);

      // Success feedback
      showToast("Task reordered successfully");
    },
    onDragging: (id, overItemId, yPosition) => {
      if (overItemId) {
        // Highlight the item being hovered over
        highlightItem(overItemId);
      }

      // Update drag position for global overlay
      updateDragPosition(yPosition);
    },
  });

  return (
    <GestureDetector gesture={panGestureHandler}>
      <Animated.View style={[styles.taskItem, animatedStyle]}>
        <View
          style={[
            styles.taskContent,
            isDragging && styles.draggingContent,
            isMoving && styles.movingContent,
          ]}
        >
          <Text style={styles.taskTitle}>{task.title}</Text>
          <Text style={styles.taskDescription}>{task.description}</Text>
          <View style={styles.taskMeta}>
            <Text style={styles.taskPriority}>{task.priority}</Text>
            <Text style={styles.taskDue}>{task.dueDate}</Text>
          </View>
          {isDragging && (
            <View style={styles.dragIndicator}>
              <Text style={styles.dragText}>Dragging...</Text>
            </View>
          )}
        </View>
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  taskItem: {
    marginHorizontal: 16,
    marginVertical: 4,
  },
  taskContent: {
    backgroundColor: "white",
    borderRadius: 8,
    padding: 16,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
    elevation: 2,
  },
  draggingContent: {
    shadowOpacity: 0.3,
    shadowRadius: 8,
    elevation: 8,
    backgroundColor: "#f8f9fa",
  },
  movingContent: {
    opacity: 0.8,
  },
  taskTitle: {
    fontSize: 16,
    fontWeight: "600",
    marginBottom: 4,
    color: "#1f2937",
  },
  taskDescription: {
    fontSize: 14,
    color: "#6b7280",
    marginBottom: 8,
  },
  taskMeta: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "center",
  },
  taskPriority: {
    fontSize: 12,
    fontWeight: "bold",
    textTransform: "uppercase",
    color: "#ef4444",
  },
  taskDue: {
    fontSize: 12,
    color: "#9ca3af",
  },
  dragIndicator: {
    position: "absolute",
    top: 8,
    right: 8,
    backgroundColor: "rgba(59, 130, 246, 0.1)",
    borderRadius: 4,
    padding: 4,
  },
  dragText: {
    fontSize: 10,
    color: "#3b82f6",
    fontWeight: "bold",
  },
});

Sortable Item with Drag Handle

import { SortableHandle } from "react-native-reanimated-dnd";

function TaskWithHandle({ task, positions, ...sortableProps }) {
  const { animatedStyle, panGestureHandler, hasHandle, isMoving } = useSortable(
    {
      id: task.id,
      positions,
      ...sortableProps,
    }
  );

  return (
    <GestureDetector gesture={panGestureHandler}>
      <Animated.View style={[styles.taskContainer, animatedStyle]}>
        <View style={[styles.taskContent, isMoving && styles.movingTask]}>
          <View style={styles.taskInfo}>
            <Text style={styles.taskTitle}>{task.title}</Text>
            <Text style={styles.taskSubtitle}>{task.subtitle}</Text>
          </View>

          {/* Only this handle can initiate dragging */}
          <SortableHandle style={styles.dragHandle}>
            <View style={styles.handleIcon}>
              <View style={styles.handleDot} />
              <View style={styles.handleDot} />
              <View style={styles.handleDot} />
              <View style={styles.handleDot} />
              <View style={styles.handleDot} />
              <View style={styles.handleDot} />
            </View>
          </SortableHandle>
        </View>
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  taskContainer: {
    marginHorizontal: 16,
    marginVertical: 4,
  },
  taskContent: {
    flexDirection: "row",
    alignItems: "center",
    backgroundColor: "white",
    padding: 16,
    borderRadius: 8,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
    elevation: 2,
  },
  movingTask: {
    shadowOpacity: 0.3,
    shadowRadius: 8,
    elevation: 8,
    transform: [{ scale: 1.02 }],
  },
  taskInfo: {
    flex: 1,
  },
  taskTitle: {
    fontSize: 16,
    fontWeight: "600",
    marginBottom: 4,
    color: "#1f2937",
  },
  taskSubtitle: {
    fontSize: 14,
    color: "#6b7280",
  },
  dragHandle: {
    padding: 8,
    marginLeft: 12,
  },
  handleIcon: {
    width: 20,
    height: 20,
    flexDirection: "row",
    flexWrap: "wrap",
    justifyContent: "space-between",
    alignContent: "space-between",
  },
  handleDot: {
    width: 3,
    height: 3,
    backgroundColor: "#9ca3af",
    borderRadius: 1.5,
  },
});

Advanced Sortable Item with State Management

function AdvancedSortableItem({ item, positions, ...sortableProps }) {
  const [localState, setLocalState] = useState({
    isDragging: false,
    isHovered: false,
    dragStartPosition: null,
    dragDistance: 0,
  });

  const { animatedStyle, panGestureHandler, isMoving } = useSortable({
    id: item.id,
    positions,
    ...sortableProps,
    onMove: (id, from, to) => {
      // Calculate drag distance
      const distance = Math.abs(to - from);
      setLocalState((prev) => ({ ...prev, dragDistance: distance }));

      // Update global state
      dispatch(reorderItems({ id, from, to }));

      // Complex analytics
      analytics.track("item_reordered", {
        itemId: id,
        fromPosition: from,
        toPosition: to,
        dragDistance: distance,
        itemType: item.type,
        category: item.category,
        timestamp: Date.now(),
      });

      // Conditional auto-save based on distance
      if (distance > 2) {
        debouncedSave();
      } else {
        immediateSave();
      }
    },
    onDragStart: (id, position) => {
      setLocalState((prev) => ({
        ...prev,
        isDragging: true,
        dragStartPosition: position,
        dragDistance: 0,
      }));

      // Global state updates
      dispatch(setDraggedItem(id));

      // Haptic feedback based on item type
      if (item.type === "important") {
        strongHapticFeedback();
      } else {
        lightHapticFeedback();
      }
    },
    onDrop: (id, position) => {
      setLocalState((prev) => ({
        ...prev,
        isDragging: false,
        dragStartPosition: null,
      }));

      // Clear global drag state
      dispatch(clearDraggedItem());

      // Success feedback with position info
      const moved = localState.dragStartPosition !== position;
      if (moved) {
        showToast(`Moved ${item.name} to position ${position + 1}`);
      }
    },
    onDragging: (id, overItemId, yPosition) => {
      // Update hover state
      setLocalState((prev) => ({
        ...prev,
        isHovered: !!overItemId,
      }));

      // Real-time position feedback
      if (overItemId) {
        showPositionIndicator(overItemId, yPosition);
      }

      // Update global drag position for overlays
      dispatch(updateDragPosition({ itemId: id, yPosition }));
    },
  });

  // Derived state for styling
  const itemStyle = useMemo(() => {
    const baseStyle = [styles.item, animatedStyle];

    if (localState.isDragging) {
      baseStyle.push(styles.dragging);
    }

    if (localState.isHovered) {
      baseStyle.push(styles.hovered);
    }

    if (isMoving) {
      baseStyle.push(styles.moving);
    }

    // Style based on item properties
    if (item.priority === "high") {
      baseStyle.push(styles.highPriority);
    }

    return baseStyle;
  }, [animatedStyle, localState, isMoving, item.priority]);

  return (
    <GestureDetector gesture={panGestureHandler}>
      <Animated.View style={itemStyle}>
        <View style={styles.itemContent}>
          <View style={styles.itemHeader}>
            <Text style={styles.itemTitle}>{item.name}</Text>
            {localState.isDragging && (
              <View style={styles.dragBadge}>
                <Text style={styles.dragBadgeText}>
                  {localState.dragDistance > 0
                    ? `+${localState.dragDistance}`
                    : "Dragging"}
                </Text>
              </View>
            )}
          </View>

          <Text style={styles.itemDescription}>{item.description}</Text>

          <View style={styles.itemFooter}>
            <Text style={styles.itemCategory}>{item.category}</Text>
            <Text style={styles.itemPosition}>
              Position: {positions.value[item.id] + 1}
            </Text>
          </View>
        </View>
      </Animated.View>
    </GestureDetector>
  );
}

File List Sortable Item

function SortableFileItem({ file, positions, ...sortableProps }) {
  const [isSelected, setIsSelected] = useState(false);
  const [showPreview, setShowPreview] = useState(false);

  const { animatedStyle, panGestureHandler, isMoving } = useSortable({
    id: file.id,
    positions,
    ...sortableProps,
    onMove: (id, from, to) => {
      reorderFiles(id, from, to);

      // File-specific analytics
      analytics.track("file_reordered", {
        fileId: id,
        fileName: file.name,
        fileType: file.type,
        fileSize: file.size,
        fromPosition: from,
        toPosition: to,
      });
    },
  });

  const getFileIcon = (type) => {
    switch (type) {
      case "image":
        return "image";
      case "video":
        return "video";
      case "audio":
        return "music";
      case "document":
        return "file-text";
      default:
        return "file";
    }
  };

  const getFileColor = (type) => {
    switch (type) {
      case "image":
        return "#10b981";
      case "video":
        return "#3b82f6";
      case "audio":
        return "#8b5cf6";
      case "document":
        return "#f59e0b";
      default:
        return "#6b7280";
    }
  };

  return (
    <GestureDetector gesture={panGestureHandler}>
      <Animated.View style={[styles.fileItem, animatedStyle]}>
        <Pressable
          style={[
            styles.fileContent,
            isSelected && styles.selectedFile,
            isMoving && styles.movingFile,
          ]}
          onPress={() => setIsSelected(!isSelected)}
          onLongPress={() => setShowPreview(true)}
        >
          <View style={styles.fileIcon}>
            <Icon
              name={getFileIcon(file.type)}
              size={24}
              color={getFileColor(file.type)}
            />
          </View>

          <View style={styles.fileInfo}>
            <Text style={styles.fileName} numberOfLines={1}>
              {file.name}
            </Text>
            <View style={styles.fileDetails}>
              <Text style={styles.fileSize}>{formatFileSize(file.size)}</Text>
              <Text style={styles.fileDate}>
                {formatDate(file.modifiedDate)}
              </Text>
            </View>
          </View>

          <View style={styles.fileActions}>
            {isSelected && (
              <View style={styles.selectedIndicator}>
                <Icon name="check" size={16} color="#3b82f6" />
              </View>
            )}

            <SortableHandle style={styles.dragHandle}>
              <Icon name="grip-vertical" size={16} color="#9ca3af" />
            </SortableHandle>
          </View>
        </Pressable>

        {showPreview && (
          <FilePreview file={file} onClose={() => setShowPreview(false)} />
        )}
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  fileItem: {
    marginHorizontal: 16,
    marginVertical: 2,
  },
  fileContent: {
    flexDirection: "row",
    alignItems: "center",
    backgroundColor: "white",
    padding: 12,
    borderRadius: 6,
    borderWidth: 1,
    borderColor: "#e5e7eb",
  },
  selectedFile: {
    backgroundColor: "#eff6ff",
    borderColor: "#3b82f6",
  },
  movingFile: {
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.2,
    shadowRadius: 4,
    elevation: 4,
  },
  fileIcon: {
    marginRight: 12,
  },
  fileInfo: {
    flex: 1,
  },
  fileName: {
    fontSize: 14,
    fontWeight: "500",
    marginBottom: 2,
    color: "#1f2937",
  },
  fileDetails: {
    flexDirection: "row",
    gap: 8,
  },
  fileSize: {
    fontSize: 12,
    color: "#6b7280",
  },
  fileDate: {
    fontSize: 12,
    color: "#9ca3af",
  },
  fileActions: {
    flexDirection: "row",
    alignItems: "center",
    gap: 8,
  },
  selectedIndicator: {
    width: 20,
    height: 20,
    borderRadius: 10,
    backgroundColor: "#dbeafe",
    justifyContent: "center",
    alignItems: "center",
  },
  dragHandle: {
    padding: 4,
  },
});

Custom Animation Sortable Item

function AnimatedSortableItem({ item, positions, ...sortableProps }) {
  const scale = useSharedValue(1);
  const rotation = useSharedValue(0);
  const opacity = useSharedValue(1);

  const { animatedStyle, panGestureHandler, isMoving } = useSortable({
    id: item.id,
    positions,
    ...sortableProps,
    onDragStart: () => {
      // Custom drag start animation
      scale.value = withSpring(1.05, { damping: 15 });
      rotation.value = withSpring(2, { damping: 20 });
      opacity.value = withTiming(0.9, { duration: 200 });
    },
    onDrop: () => {
      // Custom drop animation
      scale.value = withSpring(1, { damping: 15 });
      rotation.value = withSpring(0, { damping: 20 });
      opacity.value = withTiming(1, { duration: 200 });
    },
  });

  const customAnimatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }, { rotate: `${rotation.value}deg` }],
    opacity: opacity.value,
  }));

  return (
    <GestureDetector gesture={panGestureHandler}>
      <Animated.View
        style={[styles.animatedItem, animatedStyle, customAnimatedStyle]}
      >
        <View style={styles.itemContent}>
          <Text style={styles.itemTitle}>{item.title}</Text>
          <Text style={styles.itemSubtitle}>{item.subtitle}</Text>
          {isMoving && (
            <View style={styles.movingIndicator}>
              <Text style={styles.movingText}>Moving...</Text>
            </View>
          )}
        </View>
      </Animated.View>
    </GestureDetector>
  );
}

Auto-scrolling

The hook automatically handles scrolling when dragging items near the edges:

  • Scroll Up: When dragging near the top of the container
  • Scroll Down: When dragging near the bottom of the container
  • Smooth Scrolling: Uses momentum-based scrolling for natural feel
  • Configurable Threshold: Auto-scroll triggers based on proximity to edges

Handle Detection

Handle detection uses a registration pattern. When a SortableItem.Handle component mounts inside a sortable item, it calls registerHandle(true), which sets hasHandle to true. When the handle unmounts, it calls registerHandle(false).

// Without handle - entire item is draggable
const { hasHandle } = useSortable({
  id: item.id,
  // ... other props
});
// hasHandle will be false

// With handle - the handle registers itself on mount
// Inside SortableItem, simply include SortableItem.Handle as a child:
<SortableItem {...props}>
  <View>
    <Text>Content</Text>
    <SortableItem.Handle>
      <Icon name="drag" />
    </SortableItem.Handle>
  </View>
</SortableItem>
// hasHandle will be true (set automatically via registerHandle)

Performance Tips

  • Use React.memo for item content that doesn't change frequently
  • Avoid heavy computations in drag callbacks
  • Use useCallback for stable callback references
  • Keep item heights consistent for better performance
  • Minimize the number of animated styles

TypeScript Support

The hook is fully typed with generic support:

interface TaskData {
  id: string;
  title: string;
  priority: "low" | "medium" | "high";
  completed: boolean;
}

// Fully typed hook usage
const { animatedStyle, panGestureHandler } = useSortable<TaskData>({
  id: task.id,
  positions,
  // ... other props
  onMove: (id: string, from: number, to: number) => {
    // Fully typed callback
    reorderTasks(id, from, to);
  },
});

Integration with useSortableList

This hook is designed to work with useSortableList:

function SortableList() {
  const { getItemProps, ...listProps } = useSortableList({
    data: items,
    itemHeight: 60,
  });

  return (
    <SortableListContainer {...listProps}>
      {items.map((item, index) => {
        const itemProps = getItemProps(item, index);
        return <SortableItemComponent key={item.id} {...itemProps} />;
      })}
    </SortableListContainer>
  );
}

See Also