Dice UI
Components

Sortable

A drag and drop sortable component for reordering items.

The 900
Indy Backflip
Pizza Guy
Rocket Air
Kickflip Backflip
FS 540
"use client";
 
import * as Sortable from "@/components/ui/sortable";
import * as React from "react";
 
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"
pnpm dlx shadcn@latest add "https://diceui.com/r/sortable"
yarn dlx shadcn@latest add "https://diceui.com/r/sortable"
bun x 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
pnpm add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/sortable @dnd-kit/utilities @radix-ui/react-slot
yarn add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/sortable @dnd-kit/utilities @radix-ui/react-slot
bun add @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> {
  // eslint-disable-next-line react-hooks/exhaustive-deps
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following code into your project.

"use client";
 
import {
  type Announcements,
  DndContext,
  type DndContextProps,
  type DragEndEvent,
  DragOverlay,
  type DragStartEvent,
  type DraggableAttributes,
  type DraggableSyntheticListeners,
  type DropAnimation,
  KeyboardSensor,
  MouseSensor,
  type ScreenReaderInstructions,
  TouchSensor,
  type UniqueIdentifier,
  closestCenter,
  closestCorners,
  defaultDropAnimationSideEffects,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import {
  restrictToHorizontalAxis,
  restrictToParentElement,
  restrictToVerticalAxis,
} from "@dnd-kit/modifiers";
import {
  SortableContext,
  type SortableContextProps,
  arrayMove,
  horizontalListSortingStrategy,
  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 { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import * as ReactDOM from "react-dom";
 
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.

The 900
Indy Backflip
Pizza Guy
Rocket Air
Kickflip Backflip
FS 540
"use client";
 
import * as Sortable from "@/components/ui/sortable";
import * as React from "react";
 
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.

TrickDifficultyPoints
The 900Expert9000
Indy BackflipAdvanced4000
Pizza GuyIntermediate1500
360 Varial McTwistExpert5000
"use client";
 
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";
import { GripVertical } from "lucide-react";
import * as React from "react";
 
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.

The 900
Indy Backflip
Pizza Guy
Rocket Air
Kickflip Backflip
FS 540
"use client";
 
import * as Sortable from "@/components/ui/sortable";
import * as React from "react";
 
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
onDragPending?
((event: DragPendingEvent) => void)
-
onDragAbort?
((event: DragAbortEvent) => void)
-
flatCursor?
boolean
false
onDragCancel?
((event: DragCancelEvent) => void)
-
onDragEnd?
((event: DragEndEvent) => void)
-
onDragOver?
((event: DragOverEvent) => void)
-
onDragMove?
((event: DragMoveEvent) => void)
-
onDragStart?
((event: DragStartEvent) => void)
-
measuring?
MeasuringConfiguration
-
collisionDetection?
CollisionDetection
Based on orientation: - vertical: closestCenter - horizontal: closestCenter - mixed: closestCorners
children?
ReactNode
-
cancelDrop?
CancelDrop
-
autoScroll?
boolean | Options
false
accessibility?
{ announcements?: Announcements | undefined; container?: Element | undefined; restoreFocus?: boolean | undefined; screenReaderInstructions?: ScreenReaderInstructions | undefined; }
-
id?
string
React.useId()
orientation?
"vertical" | "horizontal" | "mixed"
"vertical"
sensors?
SensorDescriptor<any>[]
[ useSensor(MouseSensor), useSensor(TouchSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ]
strategy?
SortingStrategy
Automatically selected based on orientation: - vertical: verticalListSortingStrategy - horizontal: horizontalListSortingStrategy - mixed: undefined
modifiers?
Modifiers
Automatically selected based on orientation: - vertical: [restrictToVerticalAxis, restrictToParentElement] - horizontal: [restrictToHorizontalAxis, restrictToParentElement] - mixed: [restrictToParentElement]
onMove?
((event: DragEndEvent & { activeIndex: number; overIndex: number; }) => void)
-
getItemValue?
((item: TData) => UniqueIdentifier)
-
onValueChange?
((items: TData[]) => void)
-
value
TData[]
-

Content

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

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

Item

Individual sortable item component.

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

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.