Zum Inhalt springen
UI Kit
Accent

Sortierbare Liste

Dinge in Reihenfolge bringen, per Ziehen, Tastatur oder Knöpfen. Die anderen gleiten zur Seite, jede Bewegung wird für Screenreader angesagt.

Eine von 4 Components in Listen, Bäume und Timelines

4 Variants

Variants

Liste

Zeilen mit Griff. Ziehen, oder Leertaste drücken, mit Pfeilen bewegen, mit Leertaste ablegen, Escape stellt zurück. Eine kleine Notiz bestätigt jede neue Reihenfolge.

Im Studio öffnen

Rangfolge

Gereihte Karten für Umfragen und Prioritäten. Die Zahlen rollen an ihren neuen Platz, Knöpfe für hoch und runter stehen neben dem Griff für alle, die lieber klicken.

Im Studio öffnen

Fotoraster

Bilder als Kacheln, das erste ist das Titelbild. Eine Kachel am Griff ziehen oder mit den Pfeilen in alle vier Richtungen bewegen.

Im Studio öffnen

Zwei Spalten

Einträge zwischen zwei Listen verschieben, per Ziehen, per Pfeilknopf je Zeile oder mit links und rechts auf der Tastatur. Anzahlen folgen, eine leere Liste zeigt, wohin.

Im Studio öffnen

Details

Was Sortierbare Liste mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/sortable-list.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 13 eigene Texte

Props

Die Schnittstelle SortableListProps aus components/ui/sortable-list.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Sortierbare Liste selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 13 Texte anzeigen
gripEnglisch MoveDeutsch Verschieben
pickedEnglisch picked up. Use the arrow keys to move, Space to drop, Escape to cancel.Deutsch aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.
movedEnglisch moved to positionDeutsch jetzt an Position
ofEnglisch ofDeutsch von
droppedEnglisch dropped at positionDeutsch abgelegt an Position
cancelledEnglisch Move cancelled.Deutsch Verschieben abgebrochen.
instructionsEnglisch Press Space to pick up. Arrow keys move, Space drops, Escape cancels.Deutsch Leertaste nimmt auf. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.
upEnglisch Move upDeutsch Nach oben
downEnglisch Move downDeutsch Nach unten
coverEnglisch CoverDeutsch Titelbild
toEnglisch toDeutsch nach
moveToEnglisch Move toDeutsch Verschieben nach
emptyEnglisch Drop items hereDeutsch Hierher ziehen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Sortierbare Liste reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Tab und Leertaste.
  • Setzt aria-describedby, aria-label, aria-labelledby, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 467 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Sortierbare Liste?

Mit aiw add @ai-website/sortable-list. Der Befehl legt die Datei components/ui/sortable-list.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Sortierbare Liste?

4 Varianten, Liste, Rangfolge, Fotoraster und Zwei Spalten. Du wählst sie mit der Prop variant, zum Beispiel variant="grid".

Kann ich die Texte von Sortierbare Liste übersetzen?

Ja. Sortierbare Liste bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Verschieben“, „aufgenommen. Pfeiltasten verschieben, Leertaste legt ab, Escape bricht ab.“ und „jetzt an Position“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Sortierbare Liste einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Sortierbare Liste mit aiw_components und installieren sie mit aiw_add @ai-website/sortable-list. Nenn im Prompt die Variante, etwa variant="cards", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Sortierbare Liste ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SortableList-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert