Sortable List
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.
npx manteen add @house/dnd-listDependencies
@dnd-kit/core@^6.3.1npm@dnd-kit/sortable@^10.0.0npm@dnd-kit/utilities@^3.2.2npm@mantine/core@^9npm@mantine/hooks@^9npmclsx@^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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
initialItems* | DndListItem[] | — | Seeds the internal list state that the sortable items render from and reorder within. |
onChange | (items: DndListItem[]) => void | — | Called 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:
itemitemSectionitemLabelitemDescription
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.