Display
Sortable
Put things in order by dragging them: a list, a grid or a row. Works the same with a mouse, a finger, a pen or the keyboard.
Installation
npx sinaui add sortableCopy src/lib/ui/Sortable.svelte, announce.ts and tokens.css. No dependencies.
Examples
With a handle
Drag by the grip. The others make room as you go, and it settles where you let go.
- 1 Book of Optics Ibn al-Haytham
- 2 The Compendious Book on Calculation al-Khwarizmi
- 3 The Canon of Medicine Ibn Sina
- 4 Book of Fixed Stars al-Sufi
- 5 Book of Ingenious Devices Banu Musa
Press Space to pick up, arrow keys to move, Space again to drop, Escape to cancel.
<script lang="ts">
import { DragDropVerticalIcon } from '@hugeicons/core-free-icons';
import Icon from '#lib/ui/Icon.svelte';
import Sortable from '#lib/ui/Sortable.svelte';
let books = $state([
{ id: 'optics', title: 'Book of Optics', author: 'Ibn al-Haytham' },
{ id: 'algebra', title: 'The Compendious Book on Calculation', author: 'al-Khwarizmi' },
{ id: 'canon', title: 'The Canon of Medicine', author: 'Ibn Sina' },
{ id: 'stars', title: 'Book of Fixed Stars', author: 'al-Sufi' },
{ id: 'ingenious', title: 'Book of Ingenious Devices', author: 'Banu Musa' }
]);
</script>
<Sortable
bind:items={books}
key={(b) => b.id}
itemLabel={(b) => b.title}
label="Reading list"
class="reading"
>
{#snippet children(book, handle, { index })}
<div class="card">
<button type="button" class="grip" {...handle}>
<Icon icon={DragDropVerticalIcon} size={18} />
</button>
<span class="n">{index + 1}</span>
<span class="text">
<strong>{book.title}</strong>
<span>{book.author}</span>
</span>
</div>
{/snippet}
</Sortable>
<style>
:global(.reading) {
inline-size: min(26rem, 100%);
}
.card {
display: flex;
align-items: center;
gap: 0.5rem;
padding-block: 0.5rem;
padding-inline: 0.375rem 0.875rem;
border-radius: calc(var(--ui-radius) * 1.5);
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
}
.grip {
display: grid;
place-items: center;
inline-size: 2rem;
block-size: 2.25rem;
padding: 0;
border: 0;
border-radius: var(--ui-radius);
background: none;
color: var(--ui-muted);
transition: background var(--ui-dur) ease;
}
.grip:hover,
.grip[aria-pressed='true'] {
background: var(--ui-subtle);
color: var(--ui-fg);
}
.grip:focus-visible {
outline: var(--ui-ring-width) solid var(--ui-ring);
outline-offset: var(--ui-ring-offset);
}
.n {
inline-size: 1.25rem;
color: var(--ui-muted);
font-variant-numeric: tabular-nums;
text-align: center;
}
.text {
display: grid;
min-inline-size: 0;
font-size: 0.875rem;
line-height: 1.35;
}
.text strong {
font-weight: 600;
}
.text span {
color: var(--ui-muted);
}
</style>A grid, dragged by the whole tile
On a phone, press and hold a tile to pick it up; a quick swipe still scrolls the page.
Press Space to pick up, arrow keys to move, Space again to drop, Escape to cancel.
<script lang="ts">
import Sortable from '#lib/ui/Sortable.svelte';
// Whole tiles drag: on a phone, press and hold a tile, then move it.
let folios = $state(
['1r', '1v', '2r', '2v', '3r', '3v'].map((name, i) => ({ name, hue: 150 + i * 28 }))
);
</script>
<Sortable
bind:items={folios}
key={(f) => f.name}
itemLabel={(f) => `Folio ${f.name}`}
label="Folios"
layout="grid"
class="folios"
>
{#snippet children(folio, handle, { dragging })}
<button
type="button"
class={['tile', dragging && 'dragging']}
style:--hue={folio.hue}
data-sortable-item
{...handle}
>
<span>Folio {folio.name}</span>
</button>
{/snippet}
</Sortable>
<style>
:global(.folios) {
--sortable-columns: 3;
inline-size: min(26rem, 100%);
}
.tile {
display: grid;
align-items: end;
inline-size: 100%;
aspect-ratio: 3 / 4;
padding: 0.625rem;
border: 0;
border-radius: calc(var(--ui-radius) * 1.5);
/* Parchment tinted per folio, so the order is easy to follow. */
background: linear-gradient(160deg, oklch(0.93 0.04 var(--hue)), oklch(0.82 0.07 var(--hue)));
color: oklch(0.3 0.05 var(--hue));
font: 600 0.8125rem/1 var(--ui-font);
text-align: start;
user-select: none;
-webkit-user-select: none;
}
.tile:focus-visible {
outline: var(--ui-ring-width) solid var(--ui-ring);
outline-offset: var(--ui-ring-offset);
}
</style>A row
Chips in one line, reordered left and right.
Press Space to pick up, arrow keys to move, Space again to drop, Escape to cancel.
<script lang="ts">
import Sortable from '#lib/ui/Sortable.svelte';
let chapters = $state(['Light', 'Color', 'The eye', 'Reflection', 'Refraction']);
</script>
<Sortable
bind:items={chapters}
key={(c) => c}
itemLabel={(c) => c}
label="Chapter order"
layout="row"
class="chapters"
>
{#snippet children(chapter, handle)}
<button type="button" class="chip" data-sortable-item {...handle}>{chapter}</button>
{/snippet}
</Sortable>
<style>
:global(.chapters) {
max-inline-size: 100%;
padding-block: 0.25rem;
}
.chip {
padding: 0.5rem 0.875rem;
border: 0;
border-radius: 999px;
background: var(--ui-subtle);
color: var(--ui-fg);
font: 500 0.875rem/1 var(--ui-font);
white-space: nowrap;
user-select: none;
-webkit-user-select: none;
}
.chip:focus-visible {
outline: var(--ui-ring-width) solid var(--ui-ring);
outline-offset: var(--ui-ring-offset);
}
</style>Props
| Prop | Type | Default |
|---|---|---|
items | T[] (bindable) | required |
key | (item) => string | number | required |
itemLabel | (item) => string, read aloud as it moves | required |
label | string, names the list | required |
layout | 'list' | 'row' | 'grid' | 'list' |
onreorder | (items) => void, after a drop | |
children | snippet: (item, handle, { dragging, index }) | required |
Spread handle onto the button people grab. Add data-sortable-item to it
when it's the whole item, so a touch waits for a press-and-hold. Set grid columns with --sortable-columns and the gap with --sortable-gap.
Keyboard
| Key | Does |
|---|---|
| Space or Enter | Pick up, then drop |
| Arrow keys | Move one place (up and down by a row in a grid) |
| Home / End | Move to the start or end |
| Escape | Put it back where it was |
Accessibility
- Each handle says what it moves and how; picking up, every move and the drop are announced with the position.
- Focus stays on the item you're moving.
- Near an edge while dragging, the list or page scrolls.
- With reduced motion, items move without sliding.