GrSortableList
Let people reorder a list by dragging — or entirely from the keyboard, announced as they go.
Overview
When to take it
- the order is set by the user — the priorities of tasks, the fields of a report, the steps of a route, the columns of a builder;
- the order is saved — the model changes on release, and all that is left is to write it down;
- moving is needed from the keyboard — that is exactly the reason the component lives in the design system;
- it has to be dragged by a handle —
handleOnlyleaves the text selectable and the row clickable.
When to take something else
| Need | Take |
|---|---|
| Move between two lists | GrTransfer |
| The order is fixed | GrList |
| The elements are nested | GrTree with draggable |
| The order of the columns of a table | GrDataTable |
| Widgets on a two-dimensional grid | GrDashboard |
| The rows are only selected, not moved | GrDataTable |
Variants every tone and view, rendered by the real component
variant
elevatedoutlinedghost
States
normaldisabled
The grid is live: hover and press it. Hover, focus and active states are not drawn here — they belong to the browser, not to a prop.
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrSortableList v-model="value" />Examples 3 each with a live demo and its code
Columns of a CSV export
The order is the value: it becomes the first line of the file. Rows hold their own checkboxes and buttons, so only the handle starts a move — the default
handle-only;item-keyfollows a column by its id.movereports one operation instead of the whole list, and the exposedmove()andfocusItem()send a column to the front by the same mechanics.Columns of the CSV exportDrag a handle, or focus the list and press Space on a rowInvoice numberINV-2026-0418CustomerNorthwind TradersIssue date02 Oct 2026Due date01 Nov 2026Total€1,284.50VAT€240.18The first line of the fileInvoice number;Customer;Issue date;TotalWho is paged first
Rows hold nothing to click, so
:handle-only="false"lets the whole row be dragged.max-heightturns the list into its own scroller that moves by itself near the edges while a row is carried,changesays when a move is done — the moment to save — and theitemslot getsdraggingandgrabbedto mark the carried row.Escalation order · payments incidentsOrder saved · 7 people 1MOMina OrtizPayments · +351 912 004 118first2DKDaniel KimBilling · +44 7700 900 4313GWGrace WuPlatform · +353 87 555 01924JWJonas WeberPlatform · +49 151 2345 67895ATAiko TanakaBilling · +351 913 552 7016LMLucas MartinPayments · +33 6 12 34 56 787SLSara LindqvistSupport · +46 70 123 45 67The cover of a listing
orientation="horizontal"lays the photos in a wrapping row and moves the keyboard to ←/→; the first one is the cover. Tiles need no lines, sodividedis off. A published listing keeps its order —disabledleaves it readable — and when every photo is removed theemptyslot says what that means and offers a way back.2-room flat in Alfama · photos CoverLiving roomKitchenBedroomBalcony
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValuerequiredv-model | T[] | — | The set in its current order. `v-model`: a new array goes out, and the input is not mutated. |
itemKey | string | ((item: T, index: number) => string | number) | undefined | undefined | The key of an item: the name of a field or a function. Without it — the index. |
disabled | boolean | undefined | false | The list is read only: neither by the pointer nor from the keyboard. |
orientation | GrSortableOrientation | undefined | "vertical" | The axis of the move. `horizontal` is a row with wrapping by the width. |
variant | GrCardVariant | undefined | undefined | The surface under the list — the variant of the card. |
divided | boolean | undefined | true | Separators between the rows. |
handleOnly | boolean | undefined | true | Dragging is possible only by the handle. Off and the whole row drags. |
maxHeight | string | number | undefined | undefined | The height of the visible part: the list becomes a scroller with auto-scrolling during a move. |
emptyText | string | undefined | undefined | The text of the empty state. The `#empty` slot is stronger. |
ariaLabel | string | undefined | undefined | The name of the list for a screen reader. Unset — it is taken from the locale. |
Slots
| Slot | Type | Description |
|---|---|---|
item | { item: T; index: number; dragging: boolean; grabbed: boolean; } | The content of a row; without the slot the item itself is printed. `dragging` — the row is being dragged by the pointer, `grabbed` — it has been picked up from the keyboard. |
handle | { item: T; index: number; disabled: boolean; } | The drag handle icon. It is rendered inside the button: the grab and the label stay with the component. |
empty | any | The empty state instead of `emptyText`. |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (e: "update:modelValue", value: T[]): void | The new order after a move — a new array; the original is not mutated. |
move | (e: "move", from: number, to: number): void | A row has been moved from index `from` to `to`. Sent only when the move is complete. |
change | (e: "change", value: T[]): void | The reordering is complete — the same array that went into `update:modelValue`. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrSortableListProps, GrSortableListEmits, GrSortableOrientation } from '@feugene/granularity/components/GrSortableList'Filedist/types/components/GrSortableList/GrSortableList.vue.d.ts
interface GrSortableListProps<TItem = unknown> {
modelValue: TItem[];
itemKey?: string | ((item: TItem, index: number) => string | number);interface GrSortableListEmits<TItem = unknown> {
(e: 'update:modelValue', value: TItem[]): void;
(e: 'move', from: number, to: number): void;
(e: 'change', value: TItem[]): void;
}Filedist/types/components/GrSortableList/grSortableListStyles.d.ts
type GrSortableOrientation = 'horizontal' | 'vertical';Filedist/types/components/GrSortableList/config.d.ts
const grSortableListConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSortableList">;Filedist/types/components/GrSortableList/safelist.d.ts
const grSortableListSafelist: string[];Filedist/types/components/GrSortableList/index.d.ts
type GrSortableListInstance = ComponentExposed<typeof GrSortableListComponent>;Accessibility
APG pattern
list (roving tabindex)
Keys
↑ / ↓Space / EnterEscHome / End
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
The data model
v-model is an array in its current order. A new array goes out: the input is not mutated, so
a “before — after” comparison and the history in the consumer’s store keep working. Beside
update:modelValue the component gives away move with a pair of indices — it is more convenient
when the order is stored on the server and a single operation has to be sent rather than the whole
list.
itemKey is the name of a field or a function. Without it the index becomes the key: for a static
set that is fine, but with elements being added and removed it will lead to extra repaints.
The keyboard
| Key | Action |
|---|---|
Tab | one stop for the whole list |
↑ / ↓ (in the horizontal one — ← / →) | move the focus between rows |
Space / Enter | pick a row up · put it down |
↑ / ↓ in the picked-up state | move the row itself |
Esc | cancel the move |
Home / End | to the first and the last row |
Picking up, every movement, putting down and cancelling are announced into a live region
(useAnnouncer) — without that a keyboard move happens blindly. The focus
leaving the list releases the grip: a row cannot stay picked up forever.
The handle
By default only the handle is draggable (handleOnly). That way the row stays clickable, and links
and buttons can be kept inside it. :handle-only="false" makes the whole row draggable — which
suits short lists with no interactive elements inside.
The handle is a button outside the tab order (tabindex="-1"): the one Tab for the list belongs
to the row, and from the keyboard the move starts with Space on that same row. The content of the
handle is replaced with the #handle slot.
The props, emits and slots
There is no list of props here — it is generated from the sources. What is worth knowing beyond the signatures:
orientation="horizontal"switches both the layout and the axis of the keyboard at once;maxHeightturns the list into a scroller and switches on auto-scrolling at the edges during a move;varianttravels into theGrCardunder the list, as inGrList;disabledforbids moving both ways but leaves the list readable; the surface covers the whole list rather than the rows, so the size of the surface does not change — in the horizontal layout as well;- expose:
move(from, to)— a programmatic rearrangement by the same path as a move,focusItem(index)— the focus onto a row.
Limits
- There is no virtualisation. A move requires the target to be rendered, and a clipping window
does not guarantee that. For long lists without sorting there is
GrListwithvirtual.
The mechanics of moving is separated into the useDragSort composable — if a
list of your own with markup of your own is needed, build on it rather than on this component.
Props in this example