GrSortableList

  • core
  • data
  • API · 10 props
  • ⌨ keys

Let people reorder a list by dragging — or entirely from the keyboard, announced as they go.

import { GrSortableList } from '@feugene/granularity/components/GrSortableList'

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 — handleOnly leaves the text selectable and the row clickable.

When to take something else

NeedTake
Move between two listsGrTransfer
The order is fixedGrList
The elements are nestedGrTree with draggable
The order of the columns of a tableGrDataTable
Widgets on a two-dimensional gridGrDashboard
The rows are only selected, not movedGrDataTable

Variants every tone and view, rendered by the real component

variant

  • elevated
  • outlined
  • ghost

States

  • normal
  • disabled

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

Stage

Loading…

0 changed· state is in the URL

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-key follows a column by its id. move reports one operation instead of the whole list, and the exposed move() and focusItem() send a column to the front by the same mechanics.

    Columns of the CSV export
    Invoice numberINV-2026-0418
    CustomerNorthwind Traders
    Issue date02 Oct 2026
    Due date01 Nov 2026
    Total€1,284.50
    VAT€240.18
    Drag a handle, or focus the list and press Space on a row
    The first line of the fileInvoice number;Customer;Issue date;Total
  • Who is paged first

    Rows hold nothing to click, so :handle-only="false" lets the whole row be dragged. max-height turns the list into its own scroller that moves by itself near the edges while a row is carried, change says when a move is done — the moment to save — and the item slot gets dragging and grabbed to mark the carried row.

    Escalation order · payments incidentsOrder saved · 7 people
    1MOMina OrtizPayments · +351 912 004 118first
    2DKDaniel KimBilling · +44 7700 900 431
    3GWGrace WuPlatform · +353 87 555 0192
    4JWJonas WeberPlatform · +49 151 2345 6789
    5ATAiko TanakaBilling · +351 913 552 701
    6LMLucas MartinPayments · +33 6 12 34 56 78
    7SLSara LindqvistSupport · +46 70 123 45 67
  • The 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, so divided is off. A published listing keeps its order — disabled leaves it readable — and when every photo is removed the empty slot says what that means and offers a way back.

    2-room flat in Alfama · photos
    CoverLiving room
    Kitchen
    Bedroom
    Balcony

API generated from the package · cannot drift

Props

PropTypedefaultDescription
modelValuerequiredv-modelT[]—The set in its current order. `v-model`: a new array goes out, and the input is not mutated.
itemKeystring | ((item: T, index: number) => string | number) | undefinedundefinedThe key of an item: the name of a field or a function. Without it — the index.
disabledboolean | undefinedfalseThe list is read only: neither by the pointer nor from the keyboard.
orientationGrSortableOrientation | undefined"vertical"The axis of the move. `horizontal` is a row with wrapping by the width.
variantGrCardVariant | undefinedundefinedThe surface under the list — the variant of the card.
dividedboolean | undefinedtrueSeparators between the rows.
handleOnlyboolean | undefinedtrueDragging is possible only by the handle. Off and the whole row drags.
maxHeightstring | number | undefinedundefinedThe height of the visible part: the list becomes a scroller with auto-scrolling during a move.
emptyTextstring | undefinedundefinedThe text of the empty state. The `#empty` slot is stronger.
ariaLabelstring | undefinedundefinedThe name of the list for a screen reader. Unset — it is taken from the locale.

Slots

SlotTypeDescription
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.
emptyanyThe empty state instead of `emptyText`.

Events

EventSignatureDescription
update:modelValue(e: "update:modelValue", value: T[]): voidThe new order after a move — a new array; the original is not mutated.
move(e: "move", from: number, to: number): voidA row has been moved from index `from` to `to`. Sent only when the move is complete.
change(e: "change", value: T[]): voidThe 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
import type { GrSortableListProps, GrSortableListEmits, GrSortableOrientation } from '@feugene/granularity/components/GrSortableList'

Filedist/types/components/GrSortableList/GrSortableList.vue.d.ts

GrSortableListPropsinterface
interface GrSortableListProps<TItem = unknown> {
    modelValue: TItem[];
    itemKey?: string | ((item: TItem, index: number) => string | number);
GrSortableListEmitsinterface
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

GrSortableOrientationtype
type GrSortableOrientation = 'horizontal' | 'vertical';

Filedist/types/components/GrSortableList/config.d.ts

grSortableListConfigconst
const grSortableListConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSortableList">;

Filedist/types/components/GrSortableList/safelist.d.ts

grSortableListSafelistconst
const grSortableListSafelist: string[];

Filedist/types/components/GrSortableList/index.d.ts

GrSortableListInstancetype
type GrSortableListInstance = ComponentExposed<typeof GrSortableListComponent>;

Accessibility

APG pattern

list (roving tabindex)

Keys

↑ / ↓Space / EnterEscHome / End

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
KeyAction
Tabone stop for the whole list
↑ / ↓ (in the horizontal one — ← / →)move the focus between rows
Space / Enterpick a row up · put it down
↑ / ↓ in the picked-up statemove the row itself
Esccancel the move
Home / Endto 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;
  • maxHeight turns the list into a scroller and switches on auto-scrolling at the edges during a move;
  • variant travels into the GrCard under the list, as in GrList;
  • disabled forbids 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 GrList with virtual.

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.