The kit's one reordering primitive: lists, grids, tables and multi-list boards with pointer, touch and keyboard dragging, spoken announcements and a Move menu on every item so nothing requires dragging. Also ships usePointerDrag and useTimeScale, the drag and time-axis hooks behind the scheduler and Gantt.
Main menu
Drag, or use the arrows menu- Home/
- Services/services
- Case studies/work
- Pricing/pricingDraft
- About us/about
- Contact/contact
Installation
The whole kit as one package — get updates by bumping the version. Add the styles to globals.css: @import "@vamos-ui/react/styles.css";
Usage
import { SortableList } from "@vamos-ui/react/sortable";
<SortableList
aria-label="Main menu"
items={pages}
getLabel={(p) => p.title}
onReorder={setPages}
renderItem={(p) => p.title}
/>
// compose your own markup
<Sortable items={lines} onReorder={setLines}>
{lines.map((l) => (
<SortableItem key={l.id} id={l.id} as="tr"><td><SortableHandle /></td>…<td><SortableMoveMenu /></td></SortableItem>
))}
<SortableOverlay>{(id) => <Row id={id} />}</SortableOverlay>
</Sortable>Keyboard: focus a handle, Space or Enter lifts, arrow keys move, Space or Enter drops, Escape cancels. Instructions are attached to every handle via aria-describedby.
Announcements come from the sortable dictionary namespace, e.g. "Picked up About us. Position 2 of 6." (tr: "Hakkımızda alındı. Konum 2 / 6.").
Repeated items never blur. The overlay is an opaque popover without backdrop-filter, so it renders the same in glass and solid. Reduced motion turns off the item-shift and drop animations.
Touch: long press (220 ms) lifts, so the list still scrolls. The Move menu is the dependable path on touch screens.
Tree reordering: projectTreeMove, flattenTree and moveTreeNode (core) are ready; the SortableTree component is documented under Sortable list / tree (/docs/components/sortable-list).
Examples
Grid
A CMS article gallery. The whole tile is the drag activator; arrow keys move in two dimensions and the Move menu says Earlier / Later.
- Cover
Harbour at dawnharbour-dawn.jpg · 2400 × 1600 Spice market, Eminönüspice-market.jpg · 1800 × 1200 Ferry deck at noonferry-deck.jpg · 2048 × 1365 Galata rooftopsgalata-rooftops.jpg · 2400 × 1600 Tea garden in Modatea-garden.jpg · 1600 × 1067 Bridge lightsnight-bridge.jpg · 2400 × 1350
The first image becomes the article cover.
Between lists
HR export columns: drag between Included and Available, or use Move to list. onMoveBetween fires once on drop with the final index.
Included in export4
- Full nameText
- Work emailEmail
- DepartmentSelect
- Start dateDate
Available columns3
- ManagerRelation
- Gross salaryMoney
- IBANBank
Horizontal
Saved views in a toolbar. Left and right arrows move the lifted item and follow the visual order, so they mirror in RTL.
- My open deals18
- Closing this month7
- No activity 14 days5
- Enterprise11
Disabled items and empty
Required onboarding steps keep their position (no handle, no menu). An empty list renders the empty slot.
Onboarding steps
- Sign employment contractHR · Required, fixed position
- Collect laptop and badgeIT
- Meet your onboarding buddyTeam lead
- Complete security trainingIT · Required, fixed position
Custom steps
Composable parts
Invoice lines in a table: Sortable + SortableItem as tr + SortableHandle + SortableMoveMenu, and a custom overlay. Amounts use exact decimal strings.
| Description | Qty | Price | Amount | Actions | |
|---|---|---|---|---|---|
| Discovery workshop (2 days) | 2 day | €1,450.00 | €2,900.00 | ||
| UX design — booking flow | 36 hour | €95.00 | €3,420.00 | ||
| Front-end build | 80 hour | €85.00 | €6,800.00 | ||
| Hosting, first year | 1 year | €1,200.00 | €1,200.00 |
1,000 rows, virtualized
keepMountedRange (a rangeExtractor for @tanstack/react-virtual, or any windowing) keeps the dragged row mounted while it scrolls out of view; the overlay carries the clone.
- 1Linen side tableHM-10000
- 2Oak side tableHM-10007
- 3Ceramic side tableHM-10014
- 4Wool side tableHM-10021
- 5Copper side tableHM-10028
- 6Walnut side tableHM-10035
- 7Cotton side tableHM-10042
- 8Brass side tableHM-10049
- 9Linen throwHM-10056
- 10Oak throwHM-10063
- 11Ceramic throwHM-10070
- 12Wool throwHM-10077
- 13Copper throwHM-10084
- 14Walnut throwHM-10091
- 15Cotton throwHM-10098
- 16Brass throwHM-10105
- 17Linen pendant lampHM-10112
- 18Oak pendant lampHM-10119
- 19Ceramic pendant lampHM-10126
- 20Wool pendant lampHM-10133
- 21Copper pendant lampHM-10140
usePointerDrag + useTimeScale
The non-list drag hook used by the scheduler and Gantt: pointer capture, axis lock, 15-minute snapping on a time scale in Europe/Istanbul, Esc to cancel, and an arrow-key alternative.
Drag the event (snaps to 15 minutes, Esc cancels) or focus it and use ← →.
API
SortableList / SortableGrid
| Prop | Type | Default |
|---|---|---|
itemsItems in their current order. | T[] | — |
onReorderCalled on drop or Move-menu move with the reordered array. | (next: T[], e: { id, from, to }) => void | — |
renderItemRow / tile content. Also renders the dragged clone (overlay: true). | (item, { index, isDragging, overlay, disabled }) => ReactNode | — |
getIdStable id. | (item) => string | item.id |
getLabelSpoken in announcements and control names ("Reorder About us"). | (item) => string | label / title / name |
disabledFixed items: no handle, no Move menu, not draggable. | boolean | (item) => boolean | — |
moveMenuMove menu on every item: the non-drag alternative. | boolean | true |
renderActionsTrailing actions beside the Move menu. | (item) => ReactNode | — |
emptyRendered instead of the list when items is empty. | ReactNode | — |
orientationSortableList only. Grid always moves in 2-D. | "vertical" | "horizontal" | "vertical" |
handleDrag only from the grip handle, or from the whole item. | boolean | List true · Grid false |
variantSortableList only. Separate rows, one box with dividers, or no chrome. | "card" | "inset" | "plain" | "card" |
sizeSortableList only. Row height follows --control-h. | "sm" | "md" | "md" |
minItemWidthSortableGrid only. Columns auto-fill at this minimum width. | string | "10rem" |
labelsBuilt-in strings (announcements, menu). Dictionary namespace: sortable. | Partial<SortableLabels> | — |
aria-label / aria-labelledbyName of the list. Required for a meaningful list. | string | — |
Sortable
| Prop | Type | Default |
|---|---|---|
items / getId / getLabel / onReorder / disabled / labelsSame as SortableList. | as above | — |
orientationKeyboard axes, sorting strategy and the axis lock. | "vertical" | "horizontal" | "grid" | "vertical" |
handletrue: put a SortableHandle in each item. false: the SortableItem itself is the activator. | boolean | true |
containersMulti-list mode (boards, Included / Available). items must hold every item. | { id, label?, items: string[] }[] | — |
onMoveBetweenMulti-list mode: called once per drop or menu move, also within one list. | (e: { id, from, to, index, fromIndex }) => void | — |
renderOverlayDefault content of <SortableOverlay/>. | (item) => ReactNode | — |
onDragStart / onDragEndLifecycle. <html data-dragging> is set while dragging so glass blur can switch off. | (id) => void / (id, cancelled) => void | — |
SortableItem
| Prop | Type | Default |
|---|---|---|
idItem id. | string | — |
asElement. Keep handle mode inside lists and tables so row semantics stay intact. | "div" | "li" | "tr" | "section" | "article" | "div" |
disabledOverrides the root disabled rule. | boolean | — |
SortableHandle
| Prop | Type | Default |
|---|---|---|
size24 or 28 px. Named "Reorder {item}"; hidden for disabled items. | "sm" | "md" | "md" |
childrenCustom icon. | ReactNode | grip icon |
SortableOverlay
| Prop | Type | Default |
|---|---|---|
childrenThe dragged clone. Portaled, opaque popover, never blurred. Add vui-sortable-clone to your root for the lifted style. | ReactNode | (id) => ReactNode | — |
SortableMoveMenu
| Prop | Type | Default |
|---|---|---|
idItem to move. | string | surrounding item |
sizeTrigger size. | "xs" | "sm" | "md" | "sm" |
triggerCustom trigger (asChild). | ReactElement | — |
alignMenu alignment. | "start" | "end" | "end" |
SortableContainer
| Prop | Type | Default |
|---|---|---|
idContainer id from containers. | string | — |
asElement. Also the drop target when empty (data-empty, data-over). | "div" | "ul" | "ol" | "section" | "div" |
childrenRenders the live (preview) order while dragging. | (ids: string[], { isOver, isEmpty }) => ReactNode | — |
useSortableItem(id)
| Prop | Type | Default |
|---|---|---|
returnsFor kit consumers with custom markup: spread handleProps on the activator, ref + style on the root. | { ref, handleProps, style, isDragging, isPlaceholder, disabled, index, label } | — |
usePointerDrag(options)
| Prop | Type | Default |
|---|---|---|
axisAxis lock. | "x" | "y" | "both" | "both" |
snapMap raw movement to a grid; snapToGrid({ y: 12 }) is provided. | (delta, state) => delta | — |
thresholdPixels before a press becomes a drag; below it onTap fires. | number | 3 (touch 6) |
scrollContainer / autoScrollEdge / autoScrollSpeedAuto-scroll near the container edges; scroll is added to delta. | RefObject | () => el / number / number | — / 32 / 14 |
onStart / onMove / onEnd / onCancel / onTapstate: { origin, delta, rawDelta, pointerType, altKey, shiftKey, target }. Esc and pointercancel call onCancel. | (state) => void | — |
returnsSpread bind on the element (pointer down + touch-action). | { bind, isDragging, cancel } | — |
useTimeScale(options) / createTimeScale
| Prop | Type | Default |
|---|---|---|
start / endRange. Date-only values are local midnight in timeZone. | Date | number | ISO string | YYYY-MM-DD | — |
scaleZoom preset (px per day + tick units). | "hour" | "day" | "week" | "month" | "quarter" | "day" |
pxPerDay / timeZone / locale / workCalendarDensity, zone for ticks, week start, working-day marks on day ticks. | number / string / string / WorkCalendar | — / "UTC" / "en-US" / — |
returnstoPx, toTime, durationToPx, pxToDuration, snap(t, unit | minutes), ticks(unit), visibleRange(scrollLeft, width), dateOf, width. | TimeScale | — |