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 });