Sortable
Drag-and-drop reorderable list with keyboard support, using the native HTML Drag and Drop API. Supports mouse drag, Alt+Arrow keyboard reordering, and live screen reader announcements.
Default
Drag items to reorder, or use keyboard: Tab to focus, Arrow keys to navigate, Alt+Arrow to move items.
- Build components
- Write documentation
- Deploy to production
- Run tests
<ul class="sortable" role="listbox" aria-label="Task priority">
<li class="sortable-item" draggable="true" role="option" tabindex="0">
<span class="sortable-handle"><i data-lucide="grip-vertical"></i></span>
<span>Item 1</span>
</li>
<li class="sortable-item" draggable="true" role="option" tabindex="-1">
<span class="sortable-handle"><i data-lucide="grip-vertical"></i></span>
<span>Item 2</span>
</li>
</ul>
Horizontal
Set data-orientation="horizontal" for a row layout. Arrow keys switch to Left/Right.
- React
- TypeScript
- Tailwind
- Vite
<ul class="sortable" role="listbox" aria-label="Reorder tags" data-orientation="horizontal">
<li class="sortable-item" draggable="true" role="option" tabindex="0">
<span>React</span>
</li>
<li class="sortable-item" draggable="true" role="option" tabindex="-1">
<span>TypeScript</span>
</li>
</ul>
With Icons
Rich content with icons alongside labels. The handle provides a clear drag affordance.
- Dashboard
- Settings
- Team
- Analytics
<ul class="sortable" role="listbox" aria-label="Prioritize features">
<li class="sortable-item" draggable="true" role="option" tabindex="0">
<span class="sortable-handle"><i data-lucide="grip-vertical"></i></span>
<i data-lucide="layout-dashboard"></i>
<span>Dashboard</span>
</li>
</ul>
Disabled Items
Set aria-disabled="true" to lock items in place. Disabled items are skipped by keyboard and cannot be dragged.
- Authentication (locked)
- Configure database
- Set up CI/CD
- Deploy application
<!-- Disabled: no draggable, aria-disabled="true" -->
<li class="sortable-item" aria-disabled="true" tabindex="-1">
<span class="sortable-handle"><i data-lucide="grip-vertical"></i></span>
<span>Locked item</span>
</li>
Keyboard Reordering
Focus the list with Tab, navigate with ↑/↓, reorder with Alt+↑/Alt+↓. A screen reader live region announces each move.
- JavaScript
- Python
- Rust
- Go
- TypeScript
<!-- Keyboard: Tab to focus, ↑/↓ to navigate, Alt+↑/↓ to reorder -->
<!-- Screen readers hear: "JavaScript, moved to position 2 of 5" -->
<ul class="sortable" role="listbox" aria-label="Rank languages">
<li class="sortable-item" draggable="true" role="option" tabindex="0">
<span class="sortable-handle"><i data-lucide="grip-vertical"></i></span>
<span>JavaScript</span>
</li>
<!-- more items... -->
</ul>
CSS view file
Styles for the sortable component. Includes drag states, drop indicators, keyboard focus, disabled state, horizontal orientation, and accessibility media queries.
@layer components {
.sortable {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
/* Horizontal orientation */
&[data-orientation="horizontal"] {
flex-direction: row;
flex-wrap: wrap;
}
}
.sortable-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background-color: var(--card);
font-size: 0.875rem;
color: var(--foreground);
cursor: grab;
transition: box-shadow 150ms ease, opacity 150ms ease, border-color 150ms ease;
user-select: none;
&:active { cursor: grabbing; }
/* Keyboard focus (roving tabindex) */
&:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Active descendant highlight (keyboard navigation) */
&[data-active] {
border-color: var(--ring);
background-color: var(--accent);
color: var(--accent-foreground);
}
/* Drag in progress */
&[data-dragging] {
opacity: 0.5;
box-shadow: var(--shadow-md);
border-style: dashed;
}
/* Drop target indicator */
&[data-over="before"] {
border-top: 2px solid var(--primary);
}
&[data-over="after"] {
border-bottom: 2px solid var(--primary);
}
/* Horizontal drop indicators */
.sortable[data-orientation="horizontal"] &[data-over="before"] {
border-top: 1px solid var(--border);
border-inline-start: 2px solid var(--primary);
}
.sortable[data-orientation="horizontal"] &[data-over="after"] {
border-bottom: 1px solid var(--border);
border-inline-end: 2px solid var(--primary);
}
/* Disabled */
&[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
}
.sortable-handle {
color: var(--muted-foreground);
cursor: grab;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
display: inline-flex;
align-items: center;
&:active { cursor: grabbing; }
& svg { width: 1rem; height: 1rem; }
}
/* Live region for screen reader announcements */
.sortable-live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.sortable-item { transition: none; }
}
@media (prefers-contrast: more) {
.sortable-item {
border-width: 2px;
&[data-active] { outline: 2px solid LinkText; }
&[data-over="before"] { border-top-width: 3px; }
&[data-over="after"] { border-bottom-width: 3px; }
}
}
@media (forced-colors: active) {
.sortable-item {
border-color: ButtonBorder;
color: ButtonText;
background-color: ButtonFace;
&[data-active] { border-color: Highlight; color: HighlightText; }
&[data-dragging] { border-color: GrayText; }
&[data-over="before"] { border-top-color: Highlight; }
&[data-over="after"] { border-bottom-color: Highlight; }
&[aria-disabled="true"] { color: GrayText; border-color: GrayText; }
}
.sortable-handle { color: GrayText; }
}
}
JavaScript view file
Drag-and-drop + keyboard reordering. Arrow keys navigate, Alt+Arrow reorders, live region announces moves to screen readers.
// -- Sortable -------------------------------------------------
// Drag-and-drop + keyboard reordering for sortable lists.
// Keyboard: Arrow keys navigate, Alt+Arrow reorders, Home/End jump.
// Live region announces position changes to screen readers.
function init() {
document.querySelectorAll('.sortable:not([data-init])').forEach((list) => {
list.dataset.init = '';
const isHorizontal = list.dataset.orientation === 'horizontal';
const NEXT_KEY = isHorizontal ? 'ArrowRight' : 'ArrowDown';
const PREV_KEY = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
// -- Live region for announcements --
let liveRegion = list.parentElement?.querySelector('.sortable-live');
if (!liveRegion) {
liveRegion = document.createElement('span');
liveRegion.className = 'sortable-live';
liveRegion.setAttribute('aria-live', 'assertive');
liveRegion.setAttribute('role', 'status');
list.parentElement
? list.parentElement.insertBefore(liveRegion, list.nextSibling)
: list.after(liveRegion);
}
function announce(msg) {
liveRegion.textContent = '';
requestAnimationFrame(() => { liveRegion.textContent = msg; });
}
function getItems() {
return Array.from(list.querySelectorAll('.sortable-item:not([aria-disabled="true"])'));
}
function getAllItems() {
return Array.from(list.querySelectorAll('.sortable-item'));
}
function getActiveItem() {
return list.querySelector('.sortable-item[data-active]');
}
function setActive(item) {
getAllItems().forEach((el) => {
el.removeAttribute('data-active');
el.setAttribute('tabindex', '-1');
});
if (item) {
item.setAttribute('data-active', '');
item.setAttribute('tabindex', '0');
item.focus();
}
}
function getItemLabel(item) {
const handle = item.querySelector('.sortable-handle');
const clone = item.cloneNode(true);
if (handle) {
const handleClone = clone.querySelector('.sortable-handle');
if (handleClone) handleClone.remove();
}
return clone.textContent.trim();
}
// -- Initialize tabindex --
const allItems = getAllItems();
allItems.forEach((item, i) => {
item.setAttribute('tabindex', i === 0 ? '0' : '-1');
});
// -- Drag and drop --
let dragged = null;
list.querySelectorAll('.sortable-item').forEach((item) => {
if (item.getAttribute('aria-disabled') === 'true') return;
item.addEventListener('dragstart', (e) => {
dragged = item;
item.setAttribute('data-dragging', '');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
});
item.addEventListener('dragend', () => {
item.removeAttribute('data-dragging');
list.querySelectorAll('[data-over]').forEach((el) => el.removeAttribute('data-over'));
dragged = null;
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!dragged || dragged === item) return;
const rect = item.getBoundingClientRect();
const midpoint = isHorizontal
? rect.left + rect.width / 2
: rect.top + rect.height / 2;
const pos = isHorizontal ? e.clientX : e.clientY;
// Clear other indicators
list.querySelectorAll('[data-over]').forEach((el) => {
if (el !== item) el.removeAttribute('data-over');
});
item.setAttribute('data-over', pos < midpoint ? 'before' : 'after');
});
item.addEventListener('dragleave', () => {
item.removeAttribute('data-over');
});
item.addEventListener('drop', (e) => {
e.preventDefault();
const position = item.getAttribute('data-over');
item.removeAttribute('data-over');
if (!dragged || dragged === item) return;
if (position === 'before') {
list.insertBefore(dragged, item);
} else {
list.insertBefore(dragged, item.nextSibling);
}
const items = getItems();
const newIndex = items.indexOf(dragged);
announce(`${getItemLabel(dragged)}, moved to position ${newIndex + 1} of ${items.length}`);
setActive(dragged);
list.dispatchEvent(new CustomEvent('sortable-change', {
bubbles: true,
detail: { item: dragged, index: newIndex }
}));
});
});
// -- Keyboard navigation --
list.addEventListener('keydown', (e) => {
const active = getActiveItem() || list.querySelector('.sortable-item[tabindex="0"]');
if (!active) return;
const items = getItems();
const idx = items.indexOf(active);
// Arrow navigation
if (e.key === NEXT_KEY && !e.altKey) {
e.preventDefault();
const next = items[idx + 1];
if (next) setActive(next);
} else if (e.key === PREV_KEY && !e.altKey) {
e.preventDefault();
const prev = items[idx - 1];
if (prev) setActive(prev);
} else if (e.key === 'Home') {
e.preventDefault();
if (items.length) setActive(items[0]);
} else if (e.key === 'End') {
e.preventDefault();
if (items.length) setActive(items[items.length - 1]);
// Alt+Arrow reorders
} else if (e.key === NEXT_KEY && e.altKey) {
e.preventDefault();
if (idx < items.length - 1) {
const sibling = items[idx + 1];
list.insertBefore(active, sibling.nextSibling);
const newItems = getItems();
const newIdx = newItems.indexOf(active);
announce(`${getItemLabel(active)}, moved to position ${newIdx + 1} of ${newItems.length}`);
setActive(active);
list.dispatchEvent(new CustomEvent('sortable-change', {
bubbles: true,
detail: { item: active, index: newIdx }
}));
}
} else if (e.key === PREV_KEY && e.altKey) {
e.preventDefault();
if (idx > 0) {
const sibling = items[idx - 1];
list.insertBefore(active, sibling);
const newItems = getItems();
const newIdx = newItems.indexOf(active);
announce(`${getItemLabel(active)}, moved to position ${newIdx + 1} of ${newItems.length}`);
setActive(active);
list.dispatchEvent(new CustomEvent('sortable-change', {
bubbles: true,
detail: { item: active, index: newIdx }
}));
}
}
});
// -- Focus management --
list.addEventListener('focusin', (e) => {
const item = e.target.closest('.sortable-item');
if (item && list.contains(item)) setActive(item);
});
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });