Dice UI
Components

Sortable

A drag and drop sortable component for reordering items.

"use client";
 
import * as React from "react";
import * as Sortable from "@/components/ui/sortable";
 
export function SortableDemo() {
  const [tricks, setTricks] = React.useState([
    {
      id: "1",
      title: "The 900",
      description: "The 900 is a trick where you spin 900 degrees in the air.",
    },
    {
      id: "2",
      title: "Indy Backflip",
      description:
        "The Indy Backflip is a trick where you backflip in the air.",
    },
    {
      id: "3",
      title: "Pizza Guy",
      description: "The Pizza Guy is a trick where you flip the pizza guy.",
    },
    {
      id: "4",
      title: "Rocket Air",
      description: "The Rocket Air is a trick where you rocket air.",
    },
    {
      id: "5",
      title: "Kickflip Backflip",
      description:
        "The Kickflip Backflip is a trick where you kickflip backflip.",
    },
    {
      id: "6",
      title: "FS 540",
      description: "The FS 540 is a trick where you fs 540.",
    },
  ]);
 
  return (
    <Sortable.Root
      value={tricks}
      onValueChange={setTricks}
      getItemValue={(item) => item.id}
      orientation="mixed"
    >
      <Sortable.Content className="grid auto-rows-fr grid-cols-3 gap-2.5">
        {tricks.map((trick) => (
          <Sortable.Item key={trick.id} value={trick.id} asChild asHandle>
            <div className="flex size-full flex-col gap-1 rounded-md border bg-zinc-100 p-4 text-foreground shadow-sm dark:bg-zinc-900">
              <div className="font-medium text-sm leading-tight sm:text-base">
                {trick.title}
              </div>
              <span className="line-clamp-2 hidden text-muted-foreground text-sm sm:inline-block">
                {trick.description}
              </span>
            </div>
          </Sortable.Item>
        ))}
      </Sortable.Content>
      <Sortable.Overlay>
        <div className="size-full rounded-md bg-primary/10" />
      </Sortable.Overlay>
    </Sortable.Root>
  );
}

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/sortable"

Manual

Install the following dependencies:

npm install @dnd-kit/core @dnd-kit/modifiers @dnd-kit/sortable @dnd-kit/utilities @radix-ui/react-slot

Copy the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following code into your project.

"use client";
 
import {
  type Announcements,
  closestCenter,
  closestCorners,
  DndContext,
  type DndContextProps,
  type DragEndEvent,
  type DraggableAttributes,
  type DraggableSyntheticListeners,
  DragOverlay,
  type DragStartEvent,
  type DropAnimation,
  defaultDropAnimationSideEffects,
  KeyboardSensor,
  MouseSensor,
  type ScreenReaderInstructions,
  TouchSensor,
  type UniqueIdentifier,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import {
  restrictToHorizontalAxis,
  restrictToParentElement,
  restrictToVerticalAxis,
} from "@dnd-kit/modifiers";
import {
  arrayMove,
  horizontalListSortingStrategy,
  SortableContext,
  type SortableContextProps,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
 
const orientationConfig = {
  vertical: {
    modifiers: [restrictToVerticalAxis, restrictToParentElement],
    strategy: verticalListSortingStrategy,
    collisionDetection: closestCenter,
  },
  horizontal: {
    modifiers: [restrictToHorizontalAxis, restrictToParentElement],
    strategy: horizontalListSortingStrategy,
    collisionDetection: closestCenter,
  },
  mixed: {
    modifiers: [restrictToParentElement],
    strategy: undefined,
    collisionDetection: closestCorners,
  },
};
 
const ROOT_NAME = "Sortable";
const CONTENT_NAME = "SortableContent";
const ITEM_NAME = "SortableItem";
const ITEM_HANDLE_NAME = "SortableItemHandle";
const OVERLAY_NAME = "SortableOverlay";
 
interface SortableRootContextValue<T> {
  id: string;
  items: UniqueIdentifier[];
  modifiers: DndContextProps["modifiers"];
  strategy: SortableContextProps["strategy"];
  activeId: UniqueIdentifier | null;
  setActiveId: (id: UniqueIdentifier | null) => void;
  getItemValue: (item: T) => UniqueIdentifier;
  flatCursor: boolean;
}
 
const SortableRootContext =
  React.createContext<SortableRootContextValue<unknown> | null>(null);
SortableRootContext.displayName = ROOT_NAME;
 
function useSortableContext(consumerName: string) {
  const context = React.useContext(SortableRootContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
interface GetItemValue<T> {
  /**
   * Callback that returns a unique identifier for each sortable item. Required for array of objects.
   * @example getItemValue={(item) => item.id}
   */
  getItemValue: (item: T) => UniqueIdentifier;
}
 
type SortableRootProps<T> = DndContextProps & {
  value: T[];
  onValueChange?: (items: T[]) => void;
  onMove?: (
    event: DragEndEvent & { activeIndex: number; overIndex: number },
  ) => void;
  strategy?: SortableContextProps["strategy"];
  orientation?: "vertical" | "horizontal" | "mixed";
  flatCursor?: boolean;
} & (T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>);
 
function SortableRoot<T>(props: SortableRootProps<T>) {
  const {
    value,
    onValueChange,
    collisionDetection,
    modifiers,
    strategy,
    onMove,
    orientation = "vertical",
    flatCursor = false,
    getItemValue: getItemValueProp,
    accessibility,
    ...sortableProps
  } = props;
 
  const id = React.useId();
  const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
 
  const sensors = useSensors(
    useSensor(MouseSensor),
    useSensor(TouchSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );
  const config = React.useMemo(
    () => orientationConfig[orientation],
    [orientation],
  );
 
  const getItemValue = React.useCallback(
    (item: T): UniqueIdentifier => {
      if (typeof item === "object" && !getItemValueProp) {
        throw new Error("getItemValue is required when using array of objects");
      }
      return getItemValueProp
        ? getItemValueProp(item)
        : (item as UniqueIdentifier);
    },
    [getItemValueProp],
  );
 
  const items = React.useMemo(() => {
    return value.map((item) => getItemValue(item));
  }, [value, getItemValue]);
 
  const onDragStart = React.useCallback(
    (event: DragStartEvent) => {
      sortableProps.onDragStart?.(event);
 
      if (event.activatorEvent.defaultPrevented) return;
 
      setActiveId(event.active.id);
    },
    [sortableProps.onDragStart],
  );
 
  const onDragEnd = React.useCallback(
    (event: DragEndEvent) => {
      sortableProps.onDragEnd?.(event);
 
      if (event.activatorEvent.defaultPrevented) return;
 
      const { active, over } = event;
      if (over && active.id !== over?.id) {
        const activeIndex = value.findIndex(
          (item) => getItemValue(item) === active.id,
        );
        const overIndex = value.findIndex(
          (item) => getItemValue(item) === over.id,
        );
 
        if (onMove) {
          onMove({ ...event, activeIndex, overIndex });
        } else {
          onValueChange?.(arrayMove(value, activeIndex, overIndex));
        }
      }
      setActiveId(null);
    },
    [value, onValueChange, onMove, getItemValue, sortableProps.onDragEnd],
  );
 
  const onDragCancel = React.useCallback(
    (event: DragEndEvent) => {
      sortableProps.onDragCancel?.(event);
 
      if (event.activatorEvent.defaultPrevented) return;
 
      setActiveId(null);
    },
    [sortableProps.onDragCancel],
  );
 
  const announcements: Announcements = React.useMemo(
    () => ({
      onDragStart({ active }) {
        const activeValue = active.id.toString();
        return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
      },
      onDragOver({ active, over }) {
        if (over) {
          const overIndex = over.data.current?.sortable.index ?? 0;
          const activeIndex = active.data.current?.sortable.index ?? 0;
          const moveDirection = overIndex > activeIndex ? "down" : "up";
          const activeValue = active.id.toString();
          return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
        }
        return "Sortable item is no longer over a droppable area. Press escape to cancel.";
      },
      onDragEnd({ active, over }) {
        const activeValue = active.id.toString();
        if (over) {
          const overIndex = over.data.current?.sortable.index ?? 0;
          return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
        }
        return `Sortable item "${activeValue}" dropped. No changes were made.`;
      },
      onDragCancel({ active }) {
        const activeIndex = active.data.current?.sortable.index ?? 0;
        const activeValue = active.id.toString();
        return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
      },
      onDragMove({ active, over }) {
        if (over) {
          const overIndex = over.data.current?.sortable.index ?? 0;
          const activeIndex = active.data.current?.sortable.index ?? 0;
          const moveDirection = overIndex > activeIndex ? "down" : "up";
          const activeValue = active.id.toString();
          return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
        }
        return "Sortable item is no longer over a droppable area. Press escape to cancel.";
      },
    }),
    [value],
  );
 
  const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
    () => ({
      draggable: `
        To pick up a sortable item, press space or enter.
        While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
        Press space or enter again to drop the item in its new position, or press escape to cancel.
      `,
    }),
    [orientation],
  );
 
  const contextValue = React.useMemo(
    () => ({
      id,
      items,
      modifiers: modifiers ?? config.modifiers,
      strategy: strategy ?? config.strategy,
      activeId,
      setActiveId,
      getItemValue,
      flatCursor,
    }),
    [
      id,
      items,
      modifiers,
      strategy,
      config.modifiers,
      config.strategy,
      activeId,
      getItemValue,
      flatCursor,
    ],
  );
 
  return (
    <SortableRootContext.Provider
      value={contextValue as SortableRootContextValue<unknown>}
    >
      <DndContext
        collisionDetection={collisionDetection ?? config.collisionDetection}
        modifiers={modifiers ?? config.modifiers}
        sensors={sensors}
        {...sortableProps}
        id={id}
        onDragStart={onDragStart}
        onDragEnd={onDragEnd}
        onDragCancel={onDragCancel}
        accessibility={{
          announcements,
          screenReaderInstructions,
          ...accessibility,
        }}
      />
    </SortableRootContext.Provider>
  );
}
 
const SortableContentContext = React.createContext<boolean>(false);
SortableContentContext.displayName = CONTENT_NAME;
 
interface SortableContentProps extends React.ComponentPropsWithoutRef<"div"> {
  strategy?: SortableContextProps["strategy"];
  children: React.ReactNode;
  asChild?: boolean;
  withoutSlot?: boolean;
}
 
const SortableContent = React.forwardRef<HTMLDivElement, SortableContentProps>(
  (props, forwardedRef) => {
    const {
      strategy: strategyProp,
      asChild,
      withoutSlot,
      children,
      ...contentProps
    } = props;
 
    const context = useSortableContext(CONTENT_NAME);
 
    const ContentPrimitive = asChild ? Slot : "div";
 
    return (
      <SortableContentContext.Provider value={true}>
        <SortableContext
          items={context.items}
          strategy={strategyProp ?? context.strategy}
        >
          {withoutSlot ? (
            children
          ) : (
            <ContentPrimitive
              data-slot="sortable-content"
              {...contentProps}
              ref={forwardedRef}
            >
              {children}
            </ContentPrimitive>
          )}
        </SortableContext>
      </SortableContentContext.Provider>
    );
  },
);
SortableContent.displayName = CONTENT_NAME;
 
interface SortableItemContextValue {
  id: string;
  attributes: DraggableAttributes;
  listeners: DraggableSyntheticListeners | undefined;
  setActivatorNodeRef: (node: HTMLElement | null) => void;
  isDragging?: boolean;
  disabled?: boolean;
}
 
const SortableItemContext =
  React.createContext<SortableItemContextValue | null>(null);
SortableItemContext.displayName = ITEM_NAME;
 
function useSortableItemContext(consumerName: string) {
  const context = React.useContext(SortableItemContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
  }
  return context;
}
 
interface SortableItemProps extends React.ComponentPropsWithoutRef<"div"> {
  value: UniqueIdentifier;
  asHandle?: boolean;
  asChild?: boolean;
  disabled?: boolean;
}
 
const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>(
  (props, forwardedRef) => {
    const {
      value,
      style,
      asHandle,
      asChild,
      disabled,
      className,
      ...itemProps
    } = props;
 
    const inSortableContent = React.useContext(SortableContentContext);
    const inSortableOverlay = React.useContext(SortableOverlayContext);
 
    if (!inSortableContent && !inSortableOverlay) {
      throw new Error(
        `\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``,
      );
    }
 
    if (value === "") {
      throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
    }
 
    const context = useSortableContext(ITEM_NAME);
    const id = React.useId();
    const {
      attributes,
      listeners,
      setNodeRef,
      setActivatorNodeRef,
      transform,
      transition,
      isDragging,
    } = useSortable({ id: value, disabled });
 
    const composedRef = useComposedRefs(forwardedRef, (node) => {
      if (disabled) return;
      setNodeRef(node);
      if (asHandle) setActivatorNodeRef(node);
    });
 
    const composedStyle = React.useMemo<React.CSSProperties>(() => {
      return {
        transform: CSS.Translate.toString(transform),
        transition,
        ...style,
      };
    }, [transform, transition, style]);
 
    const itemContext = React.useMemo<SortableItemContextValue>(
      () => ({
        id,
        attributes,
        listeners,
        setActivatorNodeRef,
        isDragging,
        disabled,
      }),
      [id, attributes, listeners, setActivatorNodeRef, isDragging, disabled],
    );
 
    const ItemPrimitive = asChild ? Slot : "div";
 
    return (
      <SortableItemContext.Provider value={itemContext}>
        <ItemPrimitive
          id={id}
          data-disabled={disabled}
          data-dragging={isDragging ? "" : undefined}
          data-slot="sortable-item"
          {...itemProps}
          {...(asHandle && !disabled ? attributes : {})}
          {...(asHandle && !disabled ? listeners : {})}
          ref={composedRef}
          style={composedStyle}
          className={cn(
            "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1",
            {
              "touch-none select-none": asHandle,
              "cursor-default": context.flatCursor,
              "data-dragging:cursor-grabbing": !context.flatCursor,
              "cursor-grab": !isDragging && asHandle && !context.flatCursor,
              "opacity-50": isDragging,
              "pointer-events-none opacity-50": disabled,
            },
            className,
          )}
        />
      </SortableItemContext.Provider>
    );
  },
);
SortableItem.displayName = ITEM_NAME;
 
interface SortableItemHandleProps
  extends React.ComponentPropsWithoutRef<"button"> {
  asChild?: boolean;
}
 
const SortableItemHandle = React.forwardRef<
  HTMLButtonElement,
  SortableItemHandleProps
>((props, forwardedRef) => {
  const { asChild, disabled, className, ...itemHandleProps } = props;
 
  const context = useSortableContext(ITEM_HANDLE_NAME);
  const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
 
  const isDisabled = disabled ?? itemContext.disabled;
 
  const composedRef = useComposedRefs(forwardedRef, (node) => {
    if (!isDisabled) return;
    itemContext.setActivatorNodeRef(node);
  });
 
  const HandlePrimitive = asChild ? Slot : "button";
 
  return (
    <HandlePrimitive
      type="button"
      aria-controls={itemContext.id}
      data-disabled={isDisabled}
      data-dragging={itemContext.isDragging ? "" : undefined}
      data-slot="sortable-item-handle"
      {...itemHandleProps}
      {...(isDisabled ? {} : itemContext.attributes)}
      {...(isDisabled ? {} : itemContext.listeners)}
      ref={composedRef}
      className={cn(
        "select-none disabled:pointer-events-none disabled:opacity-50",
        context.flatCursor
          ? "cursor-default"
          : "cursor-grab data-dragging:cursor-grabbing",
        className,
      )}
      disabled={isDisabled}
    />
  );
});
SortableItemHandle.displayName = ITEM_HANDLE_NAME;
 
const SortableOverlayContext = React.createContext(false);
SortableOverlayContext.displayName = OVERLAY_NAME;
 
const dropAnimation: DropAnimation = {
  sideEffects: defaultDropAnimationSideEffects({
    styles: {
      active: {
        opacity: "0.4",
      },
    },
  }),
};
 
interface SortableOverlayProps
  extends Omit<React.ComponentPropsWithoutRef<typeof DragOverlay>, "children"> {
  container?: Element | DocumentFragment | null;
  children?:
    | ((params: { value: UniqueIdentifier }) => React.ReactNode)
    | React.ReactNode;
}
 
function SortableOverlay(props: SortableOverlayProps) {
  const { container: containerProp, children, ...overlayProps } = props;
 
  const context = useSortableContext(OVERLAY_NAME);
 
  const [mounted, setMounted] = React.useState(false);
  React.useLayoutEffect(() => setMounted(true), []);
 
  const container =
    containerProp ?? (mounted ? globalThis.document?.body : null);
 
  if (!container) return null;
 
  return ReactDOM.createPortal(
    <DragOverlay
      dropAnimation={dropAnimation}
      modifiers={context.modifiers}
      className={cn(!context.flatCursor && "cursor-grabbing")}
      {...overlayProps}
    >
      <SortableOverlayContext.Provider value={true}>
        {context.activeId
          ? typeof children === "function"
            ? children({ value: context.activeId })
            : children
          : null}
      </SortableOverlayContext.Provider>
    </DragOverlay>,
    container,
  );
}
 
export {
  SortableRoot as Sortable,
  SortableContent,
  SortableItem,
  SortableItemHandle,
  SortableOverlay,
  //
  SortableRoot as Root,
  SortableContent as Content,
  SortableItem as Item,
  SortableItemHandle as ItemHandle,
  SortableOverlay as Overlay,
};

Layout

Import the parts, and compose them together.

import * as Sortable from "@/components/ui/sortable";

<Sortable.Root>
  <Sortable.Content>
    <Sortable.Item >
      <Sortable.ItemHandle />
    </Sortable.Item>
    <Sortable.Item />
  </Sortable.Content>
  <Sortable.Overlay />
</Sortable.Root>

Examples

With Dynamic Overlay

Display a dynamic overlay when an item is being dragged.

"use client";
 
import * as React from "react";
import * as Sortable from "@/components/ui/sortable";
 
interface Trick {
  id: string;
  title: string;
  description: string;
}
 
export function SortableDynamicOverlayDemo() {
  const [tricks, setTricks] = React.useState<Trick[]>([
    {
      id: "1",
      title: "The 900",
      description: "The 900 is a trick where you spin 900 degrees in the air.",
    },
    {
      id: "2",
      title: "Indy Backflip",
      description:
        "The Indy Backflip is a trick where you backflip in the air.",
    },
    {
      id: "3",
      title: "Pizza Guy",
      description: "The Pizza Guy is a trick where you flip the pizza guy.",
    },
    {
      id: "4",
      title: "Rocket Air",
      description: "The Rocket Air is a trick where you rocket air.",
    },
    {
      id: "5",
      title: "Kickflip Backflip",
      description:
        "The Kickflip Backflip is a trick where you kickflip backflip.",
    },
    {
      id: "6",
      title: "FS 540",
      description: "The FS 540 is a trick where you fs 540.",
    },
  ]);
 
  return (
    <Sortable.Root
      value={tricks}
      onValueChange={setTricks}
      getItemValue={(item) => item.id}
      orientation="mixed"
    >
      <Sortable.Content className="grid auto-rows-fr grid-cols-3 gap-2.5">
        {tricks.map((trick) => (
          <TrickCard key={trick.id} trick={trick} asHandle />
        ))}
      </Sortable.Content>
      <Sortable.Overlay>
        {(activeItem) => {
          const trick = tricks.find((trick) => trick.id === activeItem.value);
 
          if (!trick) return null;
 
          return <TrickCard trick={trick} />;
        }}
      </Sortable.Overlay>
    </Sortable.Root>
  );
}
 
interface TrickCardProps
  extends Omit<React.ComponentPropsWithoutRef<typeof Sortable.Item>, "value"> {
  trick: Trick;
}
 
function TrickCard({ trick, ...props }: TrickCardProps) {
  return (
    <Sortable.Item value={trick.id} asChild {...props}>
      <div className="flex size-full flex-col gap-1 rounded-md border bg-zinc-100 p-4 text-foreground shadow-sm dark:bg-zinc-900">
        <div className="font-medium text-sm leading-tight sm:text-base">
          {trick.title}
        </div>
        <span className="line-clamp-2 hidden text-muted-foreground text-sm sm:inline-block">
          {trick.description}
        </span>
      </div>
    </Sortable.Item>
  );
}

With Handle

Use ItemHandle as a drag handle for sortable items.

"use client";
 
import { GripVertical } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import {
  Sortable,
  SortableContent,
  SortableItem,
  SortableItemHandle,
  SortableOverlay,
} from "@/components/ui/sortable";
 
export function SortableHandleDemo() {
  const [tricks, setTricks] = React.useState([
    { id: "1", title: "The 900", difficulty: "Expert", points: 9000 },
    { id: "2", title: "Indy Backflip", difficulty: "Advanced", points: 4000 },
    { id: "3", title: "Pizza Guy", difficulty: "Intermediate", points: 1500 },
    {
      id: "4",
      title: "360 Varial McTwist",
      difficulty: "Expert",
      points: 5000,
    },
  ]);
 
  return (
    <Sortable
      value={tricks}
      onValueChange={setTricks}
      getItemValue={(item) => item.id}
    >
      <Table className="rounded-none border">
        <TableHeader>
          <TableRow className="bg-accent/50">
            <TableHead className="w-[50px] bg-transparent" />
            <TableHead className="bg-transparent">Trick</TableHead>
            <TableHead className="bg-transparent">Difficulty</TableHead>
            <TableHead className="bg-transparent text-right">Points</TableHead>
          </TableRow>
        </TableHeader>
        <SortableContent asChild>
          <TableBody>
            {tricks.map((trick) => (
              <SortableItem key={trick.id} value={trick.id} asChild>
                <TableRow>
                  <TableCell className="w-[50px]">
                    <SortableItemHandle asChild>
                      <Button variant="ghost" size="icon" className="size-8">
                        <GripVertical className="h-4 w-4" />
                      </Button>
                    </SortableItemHandle>
                  </TableCell>
                  <TableCell className="font-medium">{trick.title}</TableCell>
                  <TableCell className="text-muted-foreground">
                    {trick.difficulty}
                  </TableCell>
                  <TableCell className="text-right text-muted-foreground">
                    {trick.points}
                  </TableCell>
                </TableRow>
              </SortableItem>
            ))}
          </TableBody>
        </SortableContent>
      </Table>
      <SortableOverlay>
        <div className="size-full rounded-none bg-primary/10" />
      </SortableOverlay>
    </Sortable>
  );
}

With Primitive Values

Use an array of primitives (string or number) instead of objects for sorting.

"use client";
 
import * as React from "react";
import * as Sortable from "@/components/ui/sortable";
 
export function SortablePrimitiveValuesDemo() {
  const [tricks, setTricks] = React.useState([
    "The 900",
    "Indy Backflip",
    "Pizza Guy",
    "Rocket Air",
    "Kickflip Backflip",
    "FS 540",
  ]);
 
  return (
    <Sortable.Root value={tricks} onValueChange={setTricks} orientation="mixed">
      <Sortable.Content className="grid grid-cols-3 gap-2.5">
        {tricks.map((trick) => (
          <Sortable.Item key={trick} value={trick} asChild asHandle>
            <div className="flex size-full flex-col items-center justify-center rounded-md border border-zinc-200 bg-zinc-100 p-8 text-center shadow-xs dark:border-zinc-800 dark:bg-zinc-900">
              <div className="font-medium text-sm leading-tight sm:text-base">
                {trick}
              </div>
            </div>
          </Sortable.Item>
        ))}
      </Sortable.Content>
      <Sortable.Overlay>
        {(activeItem) => (
          <Sortable.Item value={activeItem.value} asChild>
            <div className="flex size-full flex-col items-center justify-center rounded-md border border-zinc-200 bg-zinc-100 p-8 text-center shadow-xs dark:border-zinc-800 dark:bg-zinc-900">
              <div className="font-medium text-sm leading-tight sm:text-base">
                {activeItem.value}
              </div>
            </div>
          </Sortable.Item>
        )}
      </Sortable.Overlay>
    </Sortable.Root>
  );
}

API Reference

Root

The main container component for sortable functionality.

PropTypeDefault
value
TData[]
-
onValueChange?
((items: TData[]) => void)
-
getItemValue?
((item: TData) => UniqueIdentifier)
-
onMove?
((event: DragEndEvent & { activeIndex: number; overIndex: number; }) => void)
-
modifiers?
Modifiers
Automatically selected based on orientation: - vertical: [restrictToVerticalAxis, restrictToParentElement] - horizontal: [restrictToHorizontalAxis, restrictToParentElement] - mixed: [restrictToParentElement]
strategy?
SortingStrategy
Automatically selected based on orientation: - vertical: verticalListSortingStrategy - horizontal: horizontalListSortingStrategy - mixed: undefined
sensors?
SensorDescriptor<any>[]
[ useSensor(MouseSensor), useSensor(TouchSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ]
orientation?
"vertical" | "horizontal" | "mixed"
"vertical"
id?
string
React.useId()
accessibility?
{ announcements?: Announcements | undefined; container?: Element | undefined; restoreFocus?: boolean | undefined; screenReaderInstructions?: ScreenReaderInstructions | undefined; }
-
autoScroll?
boolean | Options
false
cancelDrop?
CancelDrop
-
children?
ReactNode
-
collisionDetection?
CollisionDetection
Based on orientation: - vertical: closestCenter - horizontal: closestCenter - mixed: closestCorners
measuring?
MeasuringConfiguration
-
onDragStart?
((event: DragStartEvent) => void)
-
onDragMove?
((event: DragMoveEvent) => void)
-
onDragOver?
((event: DragOverEvent) => void)
-
onDragEnd?
((event: DragEndEvent) => void)
-
onDragCancel?
((event: DragCancelEvent) => void)
-
flatCursor?
boolean
false
onDragAbort?
((event: DragAbortEvent) => void)
-
onDragPending?
((event: DragPendingEvent) => void)
-

Content

Container for sortable items. Multiple SortableContent components can be used within a Sortable component.

PropTypeDefault
strategy?
SortingStrategy
Automatically selected based on orientation: - vertical: verticalListSortingStrategy - horizontal: horizontalListSortingStrategy - mixed: undefined
children
ReactNode
-
withoutSlot?
boolean
false
asChild?
boolean
false

Item

Individual sortable item component.

PropTypeDefault
value
UniqueIdentifier
-
disabled?
boolean
false
asHandle?
boolean
false
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the item is disabled.
[data-dragging]Present when the item is being dragged.

ItemHandle

A button component that acts as a drag handle for sortable items.

PropTypeDefault
asChild?
boolean
false
Data AttributeValue
[data-disabled]Present when the item is disabled.
[data-dragging]Present when the parent sortable item is being dragged.

The component extends the base Button component and adds the following styles:

  • select-none for pointer events
  • cursor-grab when not dragging (unless flatCursor is true)
  • cursor-grabbing when dragging (unless flatCursor is true)
  • cursor-default when flatCursor is true

Overlay

The overlay component that appears when an item is being dragged.

PropTypeDefault
container?
Element | DocumentFragment | null
document.body
dropAnimation?
DropAnimation
{ sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.4" } } }), }
children?
ReactNode | ((params: { value: UniqueIdentifier; }) => ReactNode)
-
adjustScale?
boolean
-
transition?
string | TransitionGetter
-
modifiers?
Modifiers
-
wrapperElement?
string | number | symbol
-
zIndex?
number
-

Accessibility

Keyboard Interactions

KeyDescription
EnterSpacePicks up the sortable item for reordering when released, and drops the item in its new position when pressed again.
ArrowUpMoves the sortable item up in vertical orientation.
ArrowDownMoves the sortable item down in vertical orientation.
ArrowLeftMoves the sortable item left in horizontal orientation.
ArrowRightMoves the sortable item right in horizontal orientation.
EscCancels the sort operation and returns the item to its original position.