Skip to content

Sortable List

ComponentMantine ≥ 9MantineProvider

Accessible pointer and keyboard sortable list with reorder callbacks.

Install

One command copies the item's source into your project, where you own and edit it.

Terminal window
npx manteen add @house/dnd-list

Dependencies

  • @dnd-kit/core@^6.3.1npm
  • @dnd-kit/sortable@^10.0.0npm
  • @dnd-kit/utilities@^3.2.2npm
  • @mantine/core@^9npm
  • @mantine/hooks@^9npm
  • clsx@^2.1.1npm

Installing this item also installs @house/mantine-ui-license from the registry.

Preview

Usage

A copy-ready example published by the registry author. Imports use the aliases Manteen configures at init.

dnd-list.usage.tsx
import { DndList } from "@/components/ui/dnd-list";
export function ProjectPriorityList() {
return (
<DndList
initialItems={[
{
id: "1",
label: "Ship onboarding redesign",
description: "Due this Friday",
leading: "1",
},
{
id: "2",
label: "Fix billing webhook retries",
description: "Blocked on infra",
leading: "2",
},
{
id: "3",
label: "Write Q3 roadmap draft",
description: "Needs stakeholder review",
leading: "3",
},
{ id: "4", label: "Migrate docs to new theme", description: "Nice to have", leading: "4" },
]}
onChange={(items) =>
console.log(
"reordered",
items.map((item) => item.id),
)
}
/>
);
}

Props

Author-documented props, carried verbatim from the registry item.

PropTypeDefaultDescription
initialItems*DndListItem[]Seeds the internal list state that the sortable items render from and reorder within.
onChange(items: DndListItem[]) => voidCalled with the full reordered item array whenever a drag ends on a different position.

* required

Styling

DndList exposes these parts through the public classNames/styles interface:

  • item
  • itemSection
  • itemLabel
  • itemDescription

Author-declared; Manteen reports the declaration but does not verify each selector is wired.

Source

2 installable files ship with this item.

registry/mantine-ui/dnd-list/dnd-list.tsxcomponent · 4.4 kB · tsx
/**
* Adapted from Mantine UI's DndList at
* ffbf61c559f374a7ea28fcf00355e84dcbe9a908. MIT licensed; see
* LICENSES/MANTINE-UI.txt after installation.
*/
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
type Factory,
factory,
type StylesApiProps,
Text,
useProps,
useStyles,
} from "@mantine/core";
import { useListState } from "@mantine/hooks";
import cx from "clsx";
import type { ReactNode } from "react";
import classes from "./dnd-list.module.css";
export interface DndListItem {
id: string;
label: ReactNode;
description?: ReactNode;
leading?: ReactNode;
}
/**
* DndList renders no wrapper element of its own — `DndContext`/`SortableContext`
* are context providers, not DOM nodes, and the component's top-level output is
* the bare list of per-item `<div>`s. There is therefore no `root` selector:
* every selector here names a part of the repeated item row instead.
*/
export type DndListStylesNames = "item" | "itemSection" | "itemLabel" | "itemDescription";
export interface DndListProps extends StylesApiProps<DndListFactory> {
initialItems: DndListItem[];
onChange?: (items: DndListItem[]) => void;
}
export type DndListFactory = Factory<{
props: DndListProps;
stylesNames: DndListStylesNames;
}>;
interface SortableItemProps {
item: DndListItem;
getStyles: ReturnType<typeof useStyles<DndListFactory>>;
}
function SortableItem({ item, getStyles }: SortableItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
});
const itemStyles = getStyles("item");
return (
<div
ref={setNodeRef}
{...itemStyles}
className={cx(itemStyles.className, { [classes.itemDragging]: isDragging })}
// dnd-kit's transform/transition are mandatory for drag positioning and must
// always win; they're applied last so a consumer's `styles={{ item: {...} }}`
// can still set any other CSS property on this element.
style={{
...itemStyles.style,
transform: CSS.Transform.toString(transform),
transition,
}}
{...attributes}
{...listeners}
>
{item.leading && <div {...getStyles("itemSection")}>{item.leading}</div>}
<div>
<Text {...getStyles("itemLabel")}>{item.label}</Text>
{item.description && (
<Text {...getStyles("itemDescription")} size="sm">
{item.description}
</Text>
)}
</div>
</div>
);
}
export const DndList = factory<DndListFactory>((_props) => {
const props = useProps("DndList", null, _props);
const { classNames, styles, unstyled, vars, attributes, initialItems, onChange } = props;
const getStyles = useStyles<DndListFactory>({
name: "DndList",
classes,
props,
classNames,
styles,
unstyled,
attributes,
vars,
});
const [items, handlers] = useListState(initialItems);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const previousIndex = items.findIndex((item) => item.id === active.id);
const nextIndex = items.findIndex((item) => item.id === over.id);
if (previousIndex === -1 || nextIndex === -1) return;
const reordered = arrayMove(items, previousIndex, nextIndex);
handlers.setState(reordered);
onChange?.(reordered);
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((item) => item.id)} strategy={verticalListSortingStrategy}>
{items.map((item) => (
<SortableItem key={item.id} item={item} getStyles={getStyles} />
))}
</SortableContext>
</DndContext>
);
});
DndList.classes = classes;
DndList.displayName = "DndList";
export namespace DndList {
export type Props = DndListProps;
export type StylesNames = DndListStylesNames;
export type Factory = DndListFactory;
}
registry/mantine-ui/dnd-list/dnd-list.module.cssstyle · 1.2 kB · css
/*
* Adapted from Mantine UI's DndList at
* ffbf61c559f374a7ea28fcf00355e84dcbe9a908. MIT licensed; see
* LICENSES/MANTINE-UI.txt after installation.
*/
.item {
display: flex;
align-items: center;
margin-bottom: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-lg);
cursor: grab;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-5));
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md);
touch-action: none;
}
.item:active {
cursor: grabbing;
}
.itemDragging {
box-shadow: var(--mantine-shadow-sm);
}
.itemSection {
display: grid;
min-width: 60px;
font-size: 30px;
font-weight: 700;
place-items: center start;
}
/*
* itemLabel is a purely structural selector: the label had no default styling
* before this selector existed either, so it intentionally has no rule here
* (see the customization test's carve-out comment for the same reason). Adding
* an empty rule just to satisfy tooling would trip Biome's noEmptyBlock lint.
*/
.itemDescription {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4));
}

Author notes

Adapted from Mantine UI DndList at ffbf61c559…; hardcoded periodic-table data was replaced with a reusable item contract.