Sortable list
Put things in order by dragging, by keyboard or with buttons. The others glide out of the way, every move is announced to screen readers.
One of 4 components in Lists, trees and timelines
Variants
Details
What Sortable list brings along, read from the registry and the source.
- Category
- Lists, trees and timelines
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/sortable-list.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 13 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SortableListProps interface from components/ui/sortable-list.tsx, with the comments from the code.
variantoptionalSortableListVariantlistcardsgridcolumns
itemsoptionalSortableItem[]list, cards, grid: the items in their start order
columnsoptionalSortableColumn[]columns: two or more lists to move between
onChangeoptional(order: string[] | Record<string, string[]>) => voidCalled after every drop with the new order (columns: every list)
labeloptionalstringAccessible name of the list
buttonsoptionalbooleancards: show up and down buttons as well, for touch and pointer users
Built-in texts
Sortable list writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 13 texts
| Key | English | German |
|---|---|---|
| grip | English Move | German Verschieben |
| picked | English picked up. Use the arrow keys to move, Space to drop, Escape to cancel. | German aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab. |
| moved | English moved to position | German jetzt an Position |
| of | English of | German von |
| dropped | English dropped at position | German abgelegt an Position |
| cancelled | English Move cancelled. | German Verschieben abgebrochen. |
| instructions | English Press Space to pick up. Arrow keys move, Space drops, Escape cancels. | German Leertaste nimmt auf. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab. |
| up | English Move up | German Nach oben |
| down | English Move down | German Nach unten |
| cover | English Cover | German Titelbild |
| to | English to | German nach |
| moveTo | English Move to | German Verschieben nach |
| empty | English Drop items here | German Hierher ziehen |
Accessibility
Read from the source of the component.
- Sortable list handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Tab and Space itself.
- Sets aria-describedby, aria-label, aria-labelledby, aria-live and aria-pressed, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 467 lines
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
/*
* Put things in order by dragging, by keyboard or with the arrow buttons.
* Pointer: hold the grip and drag, the others make room with a short glide.
* Keyboard: Space or Enter on the grip picks the item up, the arrows move it
* (left and right between columns), Space drops it, Escape puts it back.
* Every move is announced in a live region, so a screen reader follows.
* Four looks: a plain list, ranked cards, a grid of pictures with a cover,
* and two columns to move items between.
*/
const TEXTS = {
en: {
grip: 'Move', picked: 'picked up. Use the arrow keys to move, Space to drop, Escape to cancel.', moved: 'moved to position', of: 'of', dropped: 'dropped at position', cancelled: 'Move cancelled.',
instructions: 'Press Space to pick up. Arrow keys move, Space drops, Escape cancels.', up: 'Move up', down: 'Move down', cover: 'Cover', to: 'to', moveTo: 'Move to', empty: 'Drop items here',
},
de: {
grip: 'Verschieben', picked: 'aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.', moved: 'jetzt an Position', of: 'von', dropped: 'abgelegt an Position', cancelled: 'Verschieben abgebrochen.',
instructions: 'Leertaste nimmt auf. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.', up: 'Nach oben', down: 'Nach unten', cover: 'Titelbild', to: 'nach', moveTo: 'Verschieben nach', empty: 'Hierher ziehen',
},
};
type Texts = (typeof TEXTS)['en'];
export interface SortableItem {
id: string;
label: string;
description?: string;
icon?: IconName;
/** Quiet text at the end, e.g. a count or a duration */
meta?: string;
/** grid: the picture of the tile */
media?: React.ReactNode;
}
export interface SortableColumn {
id: string;
title: string;
items: SortableItem[];
}
export type SortableListVariant = 'list' | 'cards' | 'grid' | 'columns';
export interface SortableListProps {
variant?: SortableListVariant;
/** list, cards, grid: the items in their start order */
items?: SortableItem[];
/** columns: two or more lists to move between */
columns?: SortableColumn[];
/** Called after every drop with the new order (columns: every list) */
onChange?: (order: string[] | Record<string, string[]>) => void;
/** Accessible name of the list */
label?: string;
/** cards: show up and down buttons as well, for touch and pointer users */
buttons?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/** The order lives as ids, the items themselves always come fresh from the props */
type Lists = Record<string, string[]>;
/** Glides elements from their old to their new place after a reorder (FLIP) */
function useFlip(root: React.RefObject<HTMLElement | null>, key: string, skip: string | null) {
const last = useRef(new Map<string, DOMRect>());
useLayoutEffect(() => {
const el = root.current;
if (!el) return;
const nodes = Array.from(el.querySelectorAll<HTMLElement>('[data-flip]'));
const reduce = prefersReducedMotion();
for (const n of nodes) {
const id = n.dataset.flip!;
const now = n.getBoundingClientRect();
const was = last.current.get(id);
last.current.set(id, now);
if (!was || reduce || id === skip || typeof n.animate !== 'function') continue;
const dx = was.left - now.left;
const dy = was.top - now.top;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
n.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }], { duration: 280, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}
}, [root, key, skip]);
}
const GRIP =
'grid shrink-0 cursor-grab touch-none place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color] duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] active:cursor-grabbing';
/**
* Put items in order by drag, keyboard or buttons, with live announcements.
* <SortableList label="Menu order" items={items} onChange={setOrder} />
* <SortableList variant="cards" buttons items={features} />
* <SortableList variant="grid" items={photos} />
* <SortableList variant="columns" columns={[{ id: 'shown', title: 'Shown', items }, { id: 'hidden', title: 'Hidden', items: [] }]} />
*/
export function SortableList({ variant = 'list', items = [], columns, onChange, label, buttons = variant === 'cards', labels, className }: SortableListProps) {
const t = useTexts(TEXTS, labels);
const base = useId();
const source: Lists = variant === 'columns' && columns ? Object.fromEntries(columns.map((c) => [c.id, c.items.map((i) => i.id)])) : { main: items.map((i) => i.id) };
const [lists, setLists] = useState<Lists>(source);
const byId = useMemo(() => new Map([...items, ...(columns ?? []).flatMap((c) => c.items)].map((i) => [i.id, i])), [items, columns]);
const itemsOf = (k: string) => (lists[k] ?? []).map((id) => byId.get(id)).filter(Boolean) as SortableItem[];
// New or removed items in the props: keep the order the person made, add new ones at the end
const sourceKey = JSON.stringify(source);
useEffect(() => {
setLists((cur) => {
const known = new Set(Object.values(source).flat());
const next: Lists = {};
for (const k of Object.keys(source)) next[k] = (cur[k] ?? []).filter((id) => known.has(id));
const placed = new Set(Object.values(next).flat());
for (const k of Object.keys(source)) for (const id of source[k]) if (!placed.has(id)) next[k].push(id);
return JSON.stringify(next) === JSON.stringify(cur) ? cur : next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKey]);
const order = variant === 'columns' && columns ? columns.map((c) => c.id) : ['main'];
const titles: Record<string, string> = Object.fromEntries((columns ?? []).map((c) => [c.id, c.title]));
const [grabbed, setGrabbed] = useState<{ id: string; from: Lists; mode: 'key' | 'pointer' } | null>(null);
const [said, setSaid] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ id: string; pointerId: number; x: number; y: number; el: HTMLElement; startRect: DOMRect } | null>(null);
const [offset, setOffset] = useState({ x: 0, y: 0 });
// The latest lists for pointer moves that arrive before the next render
const listsRef = useRef(lists);
listsRef.current = lists;
// React moves the row in the DOM on a reorder, which blurs the grip: that blur is not a cancel
const reordering = useRef(false);
const flipKey = order.map((k) => (lists[k] ?? []).join(',')).join('|');
useFlip(rootRef, flipKey, grabbed?.mode === 'pointer' ? grabbed.id : null);
const where = (l: Lists, id: string) => {
for (const k of Object.keys(l)) {
const i = l[k].indexOf(id);
if (i >= 0) return { list: k, index: i };
}
return null;
};
const labelOf = (id: string) => byId.get(id)?.label ?? '';
const commit = (next: Lists) => {
if (variant === 'columns') onChange?.({ ...next });
else onChange?.([...next.main]);
};
const move = useCallback((l: Lists, id: string, toList: string, toIndex: number): Lists => {
const at = where(l, id);
if (!at) return l;
const item = l[at.list][at.index];
const next: Lists = { ...l, [at.list]: l[at.list].filter((x) => x !== id) };
const target = [...(next[toList] ?? [])];
target.splice(Math.max(0, Math.min(toIndex, target.length)), 0, item);
next[toList] = target;
return next;
}, []);
const announce = (id: string, l: Lists, word: string) => {
const at = where(l, id);
if (!at) return;
const inList = variant === 'columns' ? ` ${t.to} ${titles[at.list]}` : '';
setSaid(`${labelOf(id)} ${word} ${at.index + 1} ${t.of} ${l[at.list].length}${inList}.`);
};
/* ---------- keyboard ---------- */
const onGripKey = (e: React.KeyboardEvent, id: string) => {
const k = e.key;
if (!grabbed || grabbed.id !== id) {
if (k === ' ' || k === 'Enter') {
e.preventDefault();
setGrabbed({ id, from: lists, mode: 'key' });
setSaid(`${labelOf(id)} ${t.picked}`);
}
return;
}
const at = where(lists, id)!;
const cols = variant === 'grid' ? gridColumns() : 1;
let next: Lists | null = null;
if (k === 'ArrowUp' || (k === 'ArrowLeft' && variant !== 'columns')) next = move(lists, id, at.list, at.index - (k === 'ArrowUp' && variant === 'grid' ? cols : 1));
else if (k === 'ArrowDown' || (k === 'ArrowRight' && variant !== 'columns')) next = move(lists, id, at.list, at.index + (k === 'ArrowDown' && variant === 'grid' ? cols : 1));
else if ((k === 'ArrowLeft' || k === 'ArrowRight') && variant === 'columns') {
const li = order.indexOf(at.list) + (k === 'ArrowRight' ? 1 : -1);
if (li >= 0 && li < order.length) next = move(lists, id, order[li], at.index);
} else if (k === 'Home') next = move(lists, id, at.list, 0);
else if (k === 'End') next = move(lists, id, at.list, lists[at.list].length);
else if (k === ' ' || k === 'Enter') {
e.preventDefault();
setGrabbed(null);
announce(id, lists, t.dropped);
commit(lists);
return;
} else if (k === 'Escape' || k === 'Tab') {
if (k === 'Escape') e.preventDefault();
setLists(grabbed.from);
setGrabbed(null);
setSaid(t.cancelled);
return;
}
if (next) {
e.preventDefault();
reordering.current = true;
setLists(next);
announce(id, next, t.moved);
// The grip keeps the focus after the item moved
requestAnimationFrame(() => {
document.getElementById(`${base}-grip-${id}`)?.focus();
reordering.current = false;
});
}
};
const gridColumns = () => {
const el = rootRef.current?.querySelector<HTMLElement>('[data-grid]');
return el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1;
};
/* ---------- pointer ---------- */
const onGripDown = (e: React.PointerEvent, id: string) => {
if (e.button !== 0) return;
const el = (e.currentTarget as HTMLElement).closest<HTMLElement>('[data-flip]');
if (!el) return;
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
drag.current = { id, pointerId: e.pointerId, x: e.clientX, y: e.clientY, el, startRect: el.getBoundingClientRect() };
setGrabbed({ id, from: lists, mode: 'pointer' });
setOffset({ x: 0, y: 0 });
};
const onGripMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
// The item under the pointer decides the new place; the dragged one follows the pointer
const root = rootRef.current!;
const lists = listsRef.current;
const nodes = Array.from(root.querySelectorAll<HTMLElement>('[data-flip]')).filter((n) => n.dataset.flip !== d.id);
let target: { list: string; index: number } | null = null;
for (const n of nodes) {
const r = n.getBoundingClientRect();
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
if (!inside) continue;
const at = where(lists, n.dataset.flip!)!;
const after = variant === 'grid' ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2;
target = { list: at.list, index: at.index + (after ? 1 : 0) };
break;
}
// Over an empty column, or below the last item of a column
if (!target && variant === 'columns') {
for (const k of order) {
const col = root.querySelector<HTMLElement>(`[data-col="${k}"]`);
const r = col?.getBoundingClientRect();
if (r && e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) target = { list: k, index: lists[k].filter((x) => x !== d.id).length };
}
}
let next = lists;
if (target) {
const at = where(lists, d.id)!;
const idx = target.list === at.list && target.index > at.index ? target.index - 1 : target.index;
if (target.list !== at.list || idx !== at.index) {
next = move(lists, d.id, target.list, idx);
listsRef.current = next;
setLists(next);
}
}
// Translate from where the item sits now in the layout to the pointer
requestAnimationFrame(() => {
const now = root.querySelector<HTMLElement>(`[data-flip="${d.id}"]`);
if (!now || !drag.current) return;
const prevTransform = now.style.transform;
now.style.transform = 'none';
const r = now.getBoundingClientRect();
now.style.transform = prevTransform;
setOffset({ x: d.startRect.left + (e.clientX - d.x) - r.left, y: d.startRect.top + (e.clientY - d.y) - r.top });
});
};
const onGripUp = (e: React.PointerEvent) => {
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
drag.current = null;
const now = listsRef.current;
const moved = grabbed && JSON.stringify(grabbed.from) !== JSON.stringify(now);
setGrabbed(null);
setOffset({ x: 0, y: 0 });
if (moved) {
announce(d.id, now, t.dropped);
commit(now);
}
};
const buttonMove = (id: string, dir: -1 | 1) => {
const at = where(lists, id)!;
const next = move(lists, id, at.list, at.index + dir);
setLists(next);
announce(id, next, t.moved);
commit(next);
};
const moveToColumn = (id: string, toList: string) => {
const next = move(lists, id, toList, lists[toList].length);
setLists(next);
announce(id, next, t.moved);
commit(next);
};
/* ---------- one item ---------- */
const grip = (item: SortableItem, cls?: string) => (
<button
type="button"
id={`${base}-grip-${item.id}`}
aria-label={`${t.grip}: ${item.label}`}
aria-describedby={`${base}-help`}
aria-pressed={grabbed?.id === item.id}
onKeyDown={(e) => onGripKey(e, item.id)}
onPointerDown={(e) => onGripDown(e, item.id)}
onPointerMove={onGripMove}
onPointerUp={onGripUp}
onPointerCancel={onGripUp}
onBlur={() => !reordering.current && grabbed?.mode === 'key' && grabbed.id === item.id && onGripKey({ key: 'Tab', preventDefault() {} } as React.KeyboardEvent, item.id)}
className={cn(GRIP, cls)}
>
<Icon name="grip" size={16} />
</button>
);
const lifted = (id: string) => grabbed?.id === id;
const liftStyle = (id: string): React.CSSProperties | undefined =>
lifted(id) && grabbed?.mode === 'pointer' ? { transform: `translate(${Math.round(offset.x)}px, ${Math.round(offset.y)}px) scale(1.02)`, zIndex: 20 } : undefined;
const liftClass = (id: string) =>
lifted(id)
? 'relative z-20 shadow-[var(--highlight),var(--shadow-lg)] ring-1 ring-[color-mix(in_oklab,var(--primary)_45%,transparent)]'
: 'relative';
const help = (
<>
<p id={`${base}-help`} className="sr-only">{t.instructions}</p>
<span aria-live="assertive" className="sr-only">{said}</span>
</>
);
/* ---------- grid ---------- */
if (variant === 'grid') {
const list = itemsOf('main');
return (
<div ref={rootRef} className={cn('w-full', className)}>
<ul data-grid aria-label={label} className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{list.map((item, i) => (
<li
key={item.id}
data-flip={item.id}
style={liftStyle(item.id)}
className={cn('group/tile overflow-hidden rounded-xl border border-border bg-card transition-[box-shadow] duration-200', liftClass(item.id), grabbed?.mode !== 'pointer' && 'transition-[box-shadow,transform]')}
>
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
<div className="absolute inset-0 transition-[scale] duration-500 ease-[var(--ease-kit)] group-hover/tile:scale-[1.04] [&>*]:size-full">{item.media}</div>
{i === 0 && (
<span className="absolute start-2 top-2 rounded-md bg-background/90 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-[opacity,scale] duration-300 starting:scale-75 starting:opacity-0">
{t.cover}
</span>
)}
{grip(item, 'absolute end-2 top-2 size-11 bg-background/85 text-foreground shadow-[var(--shadow-sm)] backdrop-blur sm:size-8')}
</div>
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<span className="truncate text-[13px] font-medium text-foreground">{item.label}</span>
<span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{i + 1}</span>
</div>
</li>
))}
</ul>
{help}
</div>
);
}
/* ---------- columns ---------- */
if (variant === 'columns') {
return (
<div ref={rootRef} className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2', className)}>
{order.map((k, ci) => (
<section key={k} aria-labelledby={`${base}-${k}`} className="flex flex-col rounded-xl border border-border bg-card">
<h4 id={`${base}-${k}`} className="flex items-center justify-between border-b border-border px-4 py-3 text-sm font-semibold text-foreground">
{titles[k]}
<span key={itemsOf(k).length} className="rounded-md bg-muted px-1.5 py-0.5 text-[12px] font-medium tabular-nums text-muted-foreground transition-[scale] duration-300 starting:scale-125">
{itemsOf(k).length}
</span>
</h4>
<ul data-col={k} className="flex min-h-[8rem] flex-1 flex-col gap-1.5 p-2">
{itemsOf(k).map((item) => (
<li key={item.id} data-flip={item.id} style={liftStyle(item.id)} className={cn('flex min-h-11 items-center gap-2 rounded-lg border border-border bg-background pe-1.5 ps-1', liftClass(item.id))}>
{grip(item, 'size-11 sm:size-8')}
{item.icon && <Icon name={item.icon} size={16} className="shrink-0 text-muted-foreground" />}
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{item.label}</span>
{order.length === 2 && (
<button
type="button"
aria-label={`${t.moveTo} ${titles[order[1 - ci]]}: ${item.label}`}
onClick={() => moveToColumn(item.id, order[1 - ci])}
className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:size-8"
>
<Icon name={ci === 0 ? 'arrow-right' : 'arrow-left'} size={15} className="max-sm:rotate-90" />
</button>
)}
</li>
))}
{itemsOf(k).length === 0 && <li className="grid flex-1 place-items-center rounded-lg border border-dashed border-border-strong text-[13px] text-subtle-foreground">{t.empty}</li>}
</ul>
</section>
))}
{help}
</div>
);
}
/* ---------- cards: ranked ---------- */
if (variant === 'cards') {
const list = itemsOf('main');
return (
<div ref={rootRef} className={cn('w-full', className)}>
<ol aria-label={label} className="grid gap-2.5">
{list.map((item, i) => (
<li key={item.id} data-flip={item.id} style={liftStyle(item.id)} className={cn('flex items-center gap-3 rounded-xl border border-border bg-card p-3 pe-2 sm:gap-4 sm:p-4 sm:pe-3', liftClass(item.id))}>
{/* The rank changes with a short roll when the order changes */}
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-sm font-semibold tabular-nums text-primary">
<span key={i} className="transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none">{i + 1}</span>
</span>
{item.icon && <Icon name={item.icon} size={18} className="shrink-0 text-muted-foreground max-sm:hidden" />}
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{item.label}</span>
{item.description && <span className="mt-0.5 block text-[13px] text-muted-foreground">{item.description}</span>}
</span>
{buttons && (
<span className="flex shrink-0 flex-col max-sm:hidden">
<button type="button" aria-label={`${t.up}: ${item.label}`} disabled={i === 0} onClick={() => buttonMove(item.id, -1)} className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:opacity-30">
<Icon name="chevron-up" size={15} />
</button>
<button type="button" aria-label={`${t.down}: ${item.label}`} disabled={i === list.length - 1} onClick={() => buttonMove(item.id, 1)} className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:opacity-30">
<Icon name="chevron-down" size={15} />
</button>
</span>
)}
{grip(item, 'size-11 sm:h-14 sm:w-8')}
</li>
))}
</ol>
{help}
</div>
);
}
/* ---------- list ---------- */
const list = itemsOf('main');
return (
<div ref={rootRef} className={cn('w-full', className)}>
<ul aria-label={label} className="rounded-xl border border-border bg-card">
{list.map((item, i) => (
<li
key={item.id}
data-flip={item.id}
style={liftStyle(item.id)}
className={cn('flex min-h-14 items-center gap-3 bg-card pe-4 ps-1.5 first:rounded-t-[inherit] last:rounded-b-[inherit]', i > 0 && 'border-t border-border', liftClass(item.id), lifted(item.id) && 'rounded-lg')}
>
{grip(item, 'size-11 sm:h-10 sm:w-8')}
{item.icon && <Icon name={item.icon} size={17} className="shrink-0 text-muted-foreground" />}
<span className="min-w-0 flex-1 py-2.5">
<span className="block text-pretty text-sm font-medium text-foreground">{item.label}</span>
{item.description && <span className="line-clamp-2 block text-[13px] text-muted-foreground sm:truncate">{item.description}</span>}
</span>
{item.meta && <span className="shrink-0 text-[13px] tabular-nums text-subtle-foreground">{item.meta}</span>}
</li>
))}
</ul>
{help}
</div>
);
}Frequent questions
How do I install Sortable list?
With aiw add @ai-website/sortable-list. The command puts components/ui/sortable-list.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Sortable list have?
4 variants, List, Ranking, Photo grid and Two columns. You pick one with the variant prop, for example variant="grid".
Can I translate the texts of Sortable list?
Yes. Sortable list carries 13 texts of its own in English and German, such as “Move”, “picked up. Use the arrow keys to move, Space to drop, Escape to cancel.” and “moved to position”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Sortable list?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sortable list with aiw_components and install it with aiw_add @ai-website/sortable-list. Name the variant in your prompt, for example variant="cards", and the assistant writes your content straight into the props.
Can I adjust Sortable list without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching SortableList code to copy.