Skip to content
UI Kit
Accent color

Sortable list

Put things in order by dragging, by keyboard or with buttons. The others glide out of the way, every move is announced to screen readers.

One of 4 components in Lists, trees and timelines

4 variants

Variants

List

Rows with a grip. Drag it, or press Space, move with the arrows and drop with Space, Escape puts it back. A small note confirms each new order.

Open in Studio

Ranking

Ranked cards for surveys and priorities. The numbers roll to their new place, up and down buttons sit next to the grip for anyone who prefers to click.

Open in Studio

Photo grid

Pictures as tiles, the first one is the cover. Drag a tile by its grip, or move it with the arrows in all four directions.

Open in Studio

Two columns

Move items between two lists by drag, by the arrow button on each row, or by keyboard with left and right. Counts follow, an empty list shows where to drop.

Open in Studio

Details

What Sortable list brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/sortable-list.tsx
Brings along
utilskiticon
npm packages
None
Languages
English and German, 13 texts of its own

Props

The SortableListProps interface from components/ui/sortable-list.tsx, with the comments from the code.

  • variantoptional
    SortableListVariant

    listcardsgridcolumns

  • itemsoptional
    SortableItem[]

    list, cards, grid: the items in their start order

  • columnsoptional
    SortableColumn[]

    columns: two or more lists to move between

  • onChangeoptional
    (order: string[] | Record<string, string[]>) => void

    Called after every drop with the new order (columns: every list)

  • labeloptional
    string

    Accessible name of the list

  • buttonsoptional
    boolean

    cards: show up and down buttons as well, for touch and pointer users

Built-in texts

Sortable list writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 13 texts
gripEnglish MoveGerman Verschieben
pickedEnglish picked up. Use the arrow keys to move, Space to drop, Escape to cancel.German aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.
movedEnglish moved to positionGerman jetzt an Position
ofEnglish ofGerman von
droppedEnglish dropped at positionGerman abgelegt an Position
cancelledEnglish Move cancelled.German Verschieben abgebrochen.
instructionsEnglish Press Space to pick up. Arrow keys move, Space drops, Escape cancels.German Leertaste nimmt auf. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.
upEnglish Move upGerman Nach oben
downEnglish Move downGerman Nach unten
coverEnglish CoverGerman Titelbild
toEnglish toGerman nach
moveToEnglish Move toGerman Verschieben nach
emptyEnglish Drop items hereGerman Hierher ziehen

Accessibility

Read from the source of the component.

  • Sortable list handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Tab and Space itself.
  • Sets aria-describedby, aria-label, aria-labelledby, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 467 lines
components/ui/sortable-list.tsx
'use client';

import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

/*
 * Put things in order by dragging, by keyboard or with the arrow buttons.
 * Pointer: hold the grip and drag, the others make room with a short glide.
 * Keyboard: Space or Enter on the grip picks the item up, the arrows move it
 * (left and right between columns), Space drops it, Escape puts it back.
 * Every move is announced in a live region, so a screen reader follows.
 * Four looks: a plain list, ranked cards, a grid of pictures with a cover,
 * and two columns to move items between.
 */

const TEXTS = {
  en: {
    grip: 'Move', picked: 'picked up. Use the arrow keys to move, Space to drop, Escape to cancel.', moved: 'moved to position', of: 'of', dropped: 'dropped at position', cancelled: 'Move cancelled.',
    instructions: 'Press Space to pick up. Arrow keys move, Space drops, Escape cancels.', up: 'Move up', down: 'Move down', cover: 'Cover', to: 'to', moveTo: 'Move to', empty: 'Drop items here',
  },
  de: {
    grip: 'Verschieben', picked: 'aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.', moved: 'jetzt an Position', of: 'von', dropped: 'abgelegt an Position', cancelled: 'Verschieben abgebrochen.',
    instructions: 'Leertaste nimmt auf. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.', up: 'Nach oben', down: 'Nach unten', cover: 'Titelbild', to: 'nach', moveTo: 'Verschieben nach', empty: 'Hierher ziehen',
  },
};
type Texts = (typeof TEXTS)['en'];

export interface SortableItem {
  id: string;
  label: string;
  description?: string;
  icon?: IconName;
  /** Quiet text at the end, e.g. a count or a duration */
  meta?: string;
  /** grid: the picture of the tile */
  media?: React.ReactNode;
}

export interface SortableColumn {
  id: string;
  title: string;
  items: SortableItem[];
}

export type SortableListVariant = 'list' | 'cards' | 'grid' | 'columns';

export interface SortableListProps {
  variant?: SortableListVariant;
  /** list, cards, grid: the items in their start order */
  items?: SortableItem[];
  /** columns: two or more lists to move between */
  columns?: SortableColumn[];
  /** Called after every drop with the new order (columns: every list) */
  onChange?: (order: string[] | Record<string, string[]>) => void;
  /** Accessible name of the list */
  label?: string;
  /** cards: show up and down buttons as well, for touch and pointer users */
  buttons?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

/** The order lives as ids, the items themselves always come fresh from the props */
type Lists = Record<string, string[]>;

/** Glides elements from their old to their new place after a reorder (FLIP) */
function useFlip(root: React.RefObject<HTMLElement | null>, key: string, skip: string | null) {
  const last = useRef(new Map<string, DOMRect>());
  useLayoutEffect(() => {
    const el = root.current;
    if (!el) return;
    const nodes = Array.from(el.querySelectorAll<HTMLElement>('[data-flip]'));
    const reduce = prefersReducedMotion();
    for (const n of nodes) {
      const id = n.dataset.flip!;
      const now = n.getBoundingClientRect();
      const was = last.current.get(id);
      last.current.set(id, now);
      if (!was || reduce || id === skip || typeof n.animate !== 'function') continue;
      const dx = was.left - now.left;
      const dy = was.top - now.top;
      if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
      n.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }], { duration: 280, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
    }
  }, [root, key, skip]);
}

const GRIP =
  'grid shrink-0 cursor-grab touch-none place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color] duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] active:cursor-grabbing';

/**
 * Put items in order by drag, keyboard or buttons, with live announcements.
 *   <SortableList label="Menu order" items={items} onChange={setOrder} />
 *   <SortableList variant="cards" buttons items={features} />
 *   <SortableList variant="grid" items={photos} />
 *   <SortableList variant="columns" columns={[{ id: 'shown', title: 'Shown', items }, { id: 'hidden', title: 'Hidden', items: [] }]} />
 */
export function SortableList({ variant = 'list', items = [], columns, onChange, label, buttons = variant === 'cards', labels, className }: SortableListProps) {
  const t = useTexts(TEXTS, labels);
  const base = useId();
  const source: Lists = variant === 'columns' && columns ? Object.fromEntries(columns.map((c) => [c.id, c.items.map((i) => i.id)])) : { main: items.map((i) => i.id) };
  const [lists, setLists] = useState<Lists>(source);
  const byId = useMemo(() => new Map([...items, ...(columns ?? []).flatMap((c) => c.items)].map((i) => [i.id, i])), [items, columns]);
  const itemsOf = (k: string) => (lists[k] ?? []).map((id) => byId.get(id)).filter(Boolean) as SortableItem[];
  // New or removed items in the props: keep the order the person made, add new ones at the end
  const sourceKey = JSON.stringify(source);
  useEffect(() => {
    setLists((cur) => {
      const known = new Set(Object.values(source).flat());
      const next: Lists = {};
      for (const k of Object.keys(source)) next[k] = (cur[k] ?? []).filter((id) => known.has(id));
      const placed = new Set(Object.values(next).flat());
      for (const k of Object.keys(source)) for (const id of source[k]) if (!placed.has(id)) next[k].push(id);
      return JSON.stringify(next) === JSON.stringify(cur) ? cur : next;
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sourceKey]);
  const order = variant === 'columns' && columns ? columns.map((c) => c.id) : ['main'];
  const titles: Record<string, string> = Object.fromEntries((columns ?? []).map((c) => [c.id, c.title]));
  const [grabbed, setGrabbed] = useState<{ id: string; from: Lists; mode: 'key' | 'pointer' } | null>(null);
  const [said, setSaid] = useState('');
  const rootRef = useRef<HTMLDivElement>(null);
  const drag = useRef<{ id: string; pointerId: number; x: number; y: number; el: HTMLElement; startRect: DOMRect } | null>(null);
  const [offset, setOffset] = useState({ x: 0, y: 0 });
  // The latest lists for pointer moves that arrive before the next render
  const listsRef = useRef(lists);
  listsRef.current = lists;
  // React moves the row in the DOM on a reorder, which blurs the grip: that blur is not a cancel
  const reordering = useRef(false);
  const flipKey = order.map((k) => (lists[k] ?? []).join(',')).join('|');
  useFlip(rootRef, flipKey, grabbed?.mode === 'pointer' ? grabbed.id : null);

  const where = (l: Lists, id: string) => {
    for (const k of Object.keys(l)) {
      const i = l[k].indexOf(id);
      if (i >= 0) return { list: k, index: i };
    }
    return null;
  };
  const labelOf = (id: string) => byId.get(id)?.label ?? '';
  const commit = (next: Lists) => {
    if (variant === 'columns') onChange?.({ ...next });
    else onChange?.([...next.main]);
  };
  const move = useCallback((l: Lists, id: string, toList: string, toIndex: number): Lists => {
    const at = where(l, id);
    if (!at) return l;
    const item = l[at.list][at.index];
    const next: Lists = { ...l, [at.list]: l[at.list].filter((x) => x !== id) };
    const target = [...(next[toList] ?? [])];
    target.splice(Math.max(0, Math.min(toIndex, target.length)), 0, item);
    next[toList] = target;
    return next;
  }, []);
  const announce = (id: string, l: Lists, word: string) => {
    const at = where(l, id);
    if (!at) return;
    const inList = variant === 'columns' ? ` ${t.to} ${titles[at.list]}` : '';
    setSaid(`${labelOf(id)} ${word} ${at.index + 1} ${t.of} ${l[at.list].length}${inList}.`);
  };

  /* ---------- keyboard ---------- */
  const onGripKey = (e: React.KeyboardEvent, id: string) => {
    const k = e.key;
    if (!grabbed || grabbed.id !== id) {
      if (k === ' ' || k === 'Enter') {
        e.preventDefault();
        setGrabbed({ id, from: lists, mode: 'key' });
        setSaid(`${labelOf(id)} ${t.picked}`);
      }
      return;
    }
    const at = where(lists, id)!;
    const cols = variant === 'grid' ? gridColumns() : 1;
    let next: Lists | null = null;
    if (k === 'ArrowUp' || (k === 'ArrowLeft' && variant !== 'columns')) next = move(lists, id, at.list, at.index - (k === 'ArrowUp' && variant === 'grid' ? cols : 1));
    else if (k === 'ArrowDown' || (k === 'ArrowRight' && variant !== 'columns')) next = move(lists, id, at.list, at.index + (k === 'ArrowDown' && variant === 'grid' ? cols : 1));
    else if ((k === 'ArrowLeft' || k === 'ArrowRight') && variant === 'columns') {
      const li = order.indexOf(at.list) + (k === 'ArrowRight' ? 1 : -1);
      if (li >= 0 && li < order.length) next = move(lists, id, order[li], at.index);
    } else if (k === 'Home') next = move(lists, id, at.list, 0);
    else if (k === 'End') next = move(lists, id, at.list, lists[at.list].length);
    else if (k === ' ' || k === 'Enter') {
      e.preventDefault();
      setGrabbed(null);
      announce(id, lists, t.dropped);
      commit(lists);
      return;
    } else if (k === 'Escape' || k === 'Tab') {
      if (k === 'Escape') e.preventDefault();
      setLists(grabbed.from);
      setGrabbed(null);
      setSaid(t.cancelled);
      return;
    }
    if (next) {
      e.preventDefault();
      reordering.current = true;
      setLists(next);
      announce(id, next, t.moved);
      // The grip keeps the focus after the item moved
      requestAnimationFrame(() => {
        document.getElementById(`${base}-grip-${id}`)?.focus();
        reordering.current = false;
      });
    }
  };
  const gridColumns = () => {
    const el = rootRef.current?.querySelector<HTMLElement>('[data-grid]');
    return el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1;
  };

  /* ---------- pointer ---------- */
  const onGripDown = (e: React.PointerEvent, id: string) => {
    if (e.button !== 0) return;
    const el = (e.currentTarget as HTMLElement).closest<HTMLElement>('[data-flip]');
    if (!el) return;
    e.preventDefault();
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
    drag.current = { id, pointerId: e.pointerId, x: e.clientX, y: e.clientY, el, startRect: el.getBoundingClientRect() };
    setGrabbed({ id, from: lists, mode: 'pointer' });
    setOffset({ x: 0, y: 0 });
  };
  const onGripMove = (e: React.PointerEvent) => {
    const d = drag.current;
    if (!d || d.pointerId !== e.pointerId) return;
    // The item under the pointer decides the new place; the dragged one follows the pointer
    const root = rootRef.current!;
    const lists = listsRef.current;
    const nodes = Array.from(root.querySelectorAll<HTMLElement>('[data-flip]')).filter((n) => n.dataset.flip !== d.id);
    let target: { list: string; index: number } | null = null;
    for (const n of nodes) {
      const r = n.getBoundingClientRect();
      const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
      if (!inside) continue;
      const at = where(lists, n.dataset.flip!)!;
      const after = variant === 'grid' ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2;
      target = { list: at.list, index: at.index + (after ? 1 : 0) };
      break;
    }
    // Over an empty column, or below the last item of a column
    if (!target && variant === 'columns') {
      for (const k of order) {
        const col = root.querySelector<HTMLElement>(`[data-col="${k}"]`);
        const r = col?.getBoundingClientRect();
        if (r && e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) target = { list: k, index: lists[k].filter((x) => x !== d.id).length };
      }
    }
    let next = lists;
    if (target) {
      const at = where(lists, d.id)!;
      const idx = target.list === at.list && target.index > at.index ? target.index - 1 : target.index;
      if (target.list !== at.list || idx !== at.index) {
        next = move(lists, d.id, target.list, idx);
        listsRef.current = next;
        setLists(next);
      }
    }
    // Translate from where the item sits now in the layout to the pointer
    requestAnimationFrame(() => {
      const now = root.querySelector<HTMLElement>(`[data-flip="${d.id}"]`);
      if (!now || !drag.current) return;
      const prevTransform = now.style.transform;
      now.style.transform = 'none';
      const r = now.getBoundingClientRect();
      now.style.transform = prevTransform;
      setOffset({ x: d.startRect.left + (e.clientX - d.x) - r.left, y: d.startRect.top + (e.clientY - d.y) - r.top });
    });
  };
  const onGripUp = (e: React.PointerEvent) => {
    const d = drag.current;
    if (!d || d.pointerId !== e.pointerId) return;
    drag.current = null;
    const now = listsRef.current;
    const moved = grabbed && JSON.stringify(grabbed.from) !== JSON.stringify(now);
    setGrabbed(null);
    setOffset({ x: 0, y: 0 });
    if (moved) {
      announce(d.id, now, t.dropped);
      commit(now);
    }
  };

  const buttonMove = (id: string, dir: -1 | 1) => {
    const at = where(lists, id)!;
    const next = move(lists, id, at.list, at.index + dir);
    setLists(next);
    announce(id, next, t.moved);
    commit(next);
  };
  const moveToColumn = (id: string, toList: string) => {
    const next = move(lists, id, toList, lists[toList].length);
    setLists(next);
    announce(id, next, t.moved);
    commit(next);
  };

  /* ---------- one item ---------- */
  const grip = (item: SortableItem, cls?: string) => (
    <button
      type="button"
      id={`${base}-grip-${item.id}`}
      aria-label={`${t.grip}: ${item.label}`}
      aria-describedby={`${base}-help`}
      aria-pressed={grabbed?.id === item.id}
      onKeyDown={(e) => onGripKey(e, item.id)}
      onPointerDown={(e) => onGripDown(e, item.id)}
      onPointerMove={onGripMove}
      onPointerUp={onGripUp}
      onPointerCancel={onGripUp}
      onBlur={() => !reordering.current && grabbed?.mode === 'key' && grabbed.id === item.id && onGripKey({ key: 'Tab', preventDefault() {} } as React.KeyboardEvent, item.id)}
      className={cn(GRIP, cls)}
    >
      <Icon name="grip" size={16} />
    </button>
  );
  const lifted = (id: string) => grabbed?.id === id;
  const liftStyle = (id: string): React.CSSProperties | undefined =>
    lifted(id) && grabbed?.mode === 'pointer' ? { transform: `translate(${Math.round(offset.x)}px, ${Math.round(offset.y)}px) scale(1.02)`, zIndex: 20 } : undefined;
  const liftClass = (id: string) =>
    lifted(id)
      ? 'relative z-20 shadow-[var(--highlight),var(--shadow-lg)] ring-1 ring-[color-mix(in_oklab,var(--primary)_45%,transparent)]'
      : 'relative';

  const help = (
    <>
      <p id={`${base}-help`} className="sr-only">{t.instructions}</p>
      <span aria-live="assertive" className="sr-only">{said}</span>
    </>
  );

  /* ---------- grid ---------- */
  if (variant === 'grid') {
    const list = itemsOf('main');
    return (
      <div ref={rootRef} className={cn('w-full', className)}>
        <ul data-grid aria-label={label} className="grid grid-cols-2 gap-3 sm:grid-cols-3">
          {list.map((item, i) => (
            <li
              key={item.id}
              data-flip={item.id}
              style={liftStyle(item.id)}
              className={cn('group/tile overflow-hidden rounded-xl border border-border bg-card transition-[box-shadow] duration-200', liftClass(item.id), grabbed?.mode !== 'pointer' && 'transition-[box-shadow,transform]')}
            >
              <div className="relative aspect-[4/3] overflow-hidden bg-muted">
                <div className="absolute inset-0 transition-[scale] duration-500 ease-[var(--ease-kit)] group-hover/tile:scale-[1.04] [&>*]:size-full">{item.media}</div>
                {i === 0 && (
                  <span className="absolute start-2 top-2 rounded-md bg-background/90 px-2 py-0.5 text-[11px] font-medium text-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-[opacity,scale] duration-300 starting:scale-75 starting:opacity-0">
                    {t.cover}
                  </span>
                )}
                {grip(item, 'absolute end-2 top-2 size-11 bg-background/85 text-foreground shadow-[var(--shadow-sm)] backdrop-blur sm:size-8')}
              </div>
              <div className="flex items-center justify-between gap-2 px-3 py-2.5">
                <span className="truncate text-[13px] font-medium text-foreground">{item.label}</span>
                <span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{i + 1}</span>
              </div>
            </li>
          ))}
        </ul>
        {help}
      </div>
    );
  }

  /* ---------- columns ---------- */
  if (variant === 'columns') {
    return (
      <div ref={rootRef} className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2', className)}>
        {order.map((k, ci) => (
          <section key={k} aria-labelledby={`${base}-${k}`} className="flex flex-col rounded-xl border border-border bg-card">
            <h4 id={`${base}-${k}`} className="flex items-center justify-between border-b border-border px-4 py-3 text-sm font-semibold text-foreground">
              {titles[k]}
              <span key={itemsOf(k).length} className="rounded-md bg-muted px-1.5 py-0.5 text-[12px] font-medium tabular-nums text-muted-foreground transition-[scale] duration-300 starting:scale-125">
                {itemsOf(k).length}
              </span>
            </h4>
            <ul data-col={k} className="flex min-h-[8rem] flex-1 flex-col gap-1.5 p-2">
              {itemsOf(k).map((item) => (
                <li key={item.id} data-flip={item.id} style={liftStyle(item.id)} className={cn('flex min-h-11 items-center gap-2 rounded-lg border border-border bg-background pe-1.5 ps-1', liftClass(item.id))}>
                  {grip(item, 'size-11 sm:size-8')}
                  {item.icon && <Icon name={item.icon} size={16} className="shrink-0 text-muted-foreground" />}
                  <span className="min-w-0 flex-1 truncate text-sm text-foreground">{item.label}</span>
                  {order.length === 2 && (
                    <button
                      type="button"
                      aria-label={`${t.moveTo} ${titles[order[1 - ci]]}: ${item.label}`}
                      onClick={() => moveToColumn(item.id, order[1 - ci])}
                      className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:size-8"
                    >
                      <Icon name={ci === 0 ? 'arrow-right' : 'arrow-left'} size={15} className="max-sm:rotate-90" />
                    </button>
                  )}
                </li>
              ))}
              {itemsOf(k).length === 0 && <li className="grid flex-1 place-items-center rounded-lg border border-dashed border-border-strong text-[13px] text-subtle-foreground">{t.empty}</li>}
            </ul>
          </section>
        ))}
        {help}
      </div>
    );
  }

  /* ---------- cards: ranked ---------- */
  if (variant === 'cards') {
    const list = itemsOf('main');
    return (
      <div ref={rootRef} className={cn('w-full', className)}>
        <ol aria-label={label} className="grid gap-2.5">
          {list.map((item, i) => (
            <li key={item.id} data-flip={item.id} style={liftStyle(item.id)} className={cn('flex items-center gap-3 rounded-xl border border-border bg-card p-3 pe-2 sm:gap-4 sm:p-4 sm:pe-3', liftClass(item.id))}>
              {/* The rank changes with a short roll when the order changes */}
              <span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-sm font-semibold tabular-nums text-primary">
                <span key={i} className="transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none">{i + 1}</span>
              </span>
              {item.icon && <Icon name={item.icon} size={18} className="shrink-0 text-muted-foreground max-sm:hidden" />}
              <span className="min-w-0 flex-1">
                <span className="block text-sm font-medium text-foreground">{item.label}</span>
                {item.description && <span className="mt-0.5 block text-[13px] text-muted-foreground">{item.description}</span>}
              </span>
              {buttons && (
                <span className="flex shrink-0 flex-col max-sm:hidden">
                  <button type="button" aria-label={`${t.up}: ${item.label}`} disabled={i === 0} onClick={() => buttonMove(item.id, -1)} className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:opacity-30">
                    <Icon name="chevron-up" size={15} />
                  </button>
                  <button type="button" aria-label={`${t.down}: ${item.label}`} disabled={i === list.length - 1} onClick={() => buttonMove(item.id, 1)} className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:opacity-30">
                    <Icon name="chevron-down" size={15} />
                  </button>
                </span>
              )}
              {grip(item, 'size-11 sm:h-14 sm:w-8')}
            </li>
          ))}
        </ol>
        {help}
      </div>
    );
  }

  /* ---------- list ---------- */
  const list = itemsOf('main');
  return (
    <div ref={rootRef} className={cn('w-full', className)}>
      <ul aria-label={label} className="rounded-xl border border-border bg-card">
        {list.map((item, i) => (
          <li
            key={item.id}
            data-flip={item.id}
            style={liftStyle(item.id)}
            className={cn('flex min-h-14 items-center gap-3 bg-card pe-4 ps-1.5 first:rounded-t-[inherit] last:rounded-b-[inherit]', i > 0 && 'border-t border-border', liftClass(item.id), lifted(item.id) && 'rounded-lg')}
          >
            {grip(item, 'size-11 sm:h-10 sm:w-8')}
            {item.icon && <Icon name={item.icon} size={17} className="shrink-0 text-muted-foreground" />}
            <span className="min-w-0 flex-1 py-2.5">
              <span className="block text-pretty text-sm font-medium text-foreground">{item.label}</span>
              {item.description && <span className="line-clamp-2 block text-[13px] text-muted-foreground sm:truncate">{item.description}</span>}
            </span>
            {item.meta && <span className="shrink-0 text-[13px] tabular-nums text-subtle-foreground">{item.meta}</span>}
          </li>
        ))}
      </ul>
      {help}
    </div>
  );
}

Frequent questions

How do I install Sortable list?

With aiw add @ai-website/sortable-list. The command puts components/ui/sortable-list.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Sortable list have?

4 variants, List, Ranking, Photo grid and Two columns. You pick one with the variant prop, for example variant="grid".

Can I translate the texts of Sortable list?

Yes. Sortable list carries 13 texts of its own in English and German, such as “Move”, “picked up. Use the arrow keys to move, Space to drop, Escape to cancel.” and “moved to position”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Sortable list?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sortable list with aiw_components and install it with aiw_add @ai-website/sortable-list. Name the variant in your prompt, for example variant="cards", and the assistant writes your content straight into the props.

Can I adjust Sortable list without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching SortableList code to copy.

Often searched

Jump to

Copied to the clipboard