Skip to content
On this page How it behaves

Foundation

Drag and drop

Sort a list, move items between lists and drop onto zones with mouse, touch and keyboard, announced to screen readers and calm under reduced motion.

useDnd() is the drag primitive behind reorderable lists, boards and drop targets. Call it once while a component initializes, render <DndLayer {dnd} /> once next to your lists (it holds the keyboard instructions and the drag styles, so apps that never drag do not pay for them) and attach the parts useDnd hands back to your own markup:

<script>
  import { applyMove, DndLayer, useDnd } from "@codeatlasdev/ui";

  let items = $state([{ id: "a" }, { id: "b" }, { id: "c" }]);
  const dnd = useDnd();
  const onDrop = (e) => (items = applyMove({ list: items }, e, (i) => i.id).list);
</script>

<DndLayer {dnd} />
<ul {@attach dnd.sortable(() => ({ id: "list", getItems: () => items.map((i) => i.id), onDrop }))}>
  {#each items as item (item.id)}
    <li data-dnd-item={item.id}>
      <button aria-describedby={dnd.instructionsId}>{item.id}</button>
    </li>
  {/each}
</ul>

How it behaves

The engine measures the item boxes once when a drag starts and finds the target from those cached rectangles on every frame. While you drag, only three things move: a clone of the item that follows the pointer, a one-line drop indicator and data-dnd-* attributes. Your list does not re-render until onDrop fires, so a board with hundreds of cards stays smooth. onDrop is the only place you change state: { id, data, from, to }, where from and to are { container, index }.

Input How it works
Mouse and pen Press and move 4px. A press that does not move stays a click.
Touch Press and hold for 350ms, then drag. Moving before the hold ends is a scroll, so lists inside a scrolling page keep scrolling.
Keyboard Focus the item (or its handle) and press Space to lift. Arrows move it, Home and End jump to the ends, Enter drops, Escape cancels. Left and right (up and down in a horizontal list) move it to the next list or zone.
Screen reader Every step is spoken through announce(): “Lifted Write the release notes, position 2 of 4”, “…position 3 of 4”, “Dropped…”, “Cancelled…”. The text follows the active locale (English and Portuguese built in); pass useDnd({ messages }) for another language.
Edges Holding the pointer near the edge of a scrolling container scrolls it, faster the closer you are.
Reduced motion The cancel animation and the press scale are removed.

Put aria-describedby={dnd.instructionsId} on the focusable handle so a screen reader tells people how to lift it. Pass handle: ".grip" when only a grip may start a drag (a row that also holds links or inputs).

Sort a list

#430

Sortable list

Drag the grip, press and hold on a touch screen, or focus the grip and press Space.

Press Space to lift, the arrow keys to move, Enter to drop and Escape to cancel. Touch: press and hold, then drag.

  • Review the pull request
  • Write the release notes
  • Update the changelog
  • Tag the release

Reference #430 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { applyMove, DndLayer, useDnd, type DndDropEvent } from '@codeatlasdev/ui';
  import { GripVertical } from '@lucide/svelte';

  type Task = { id: string; title: string };
  let tasks = $state<Task[]>([
    { id: 't1', title: 'Review the pull request' },
    { id: 't2', title: 'Write the release notes' },
    { id: 't3', title: 'Update the changelog' },
    { id: 't4', title: 'Tag the release' },
  ]);

  const dnd = useDnd();

  function onDrop(e: DndDropEvent) {
    tasks = applyMove({ list: tasks }, e, (t) => t.id).list;
  }
</script>

<DndLayer {dnd} />
<ul
  class="sl"
  aria-label="Release checklist"
  {@attach dnd.sortable(() => ({ id: 'list', label: 'Release checklist', handle: '.sl__grip', getItems: () => tasks.map((t) => t.id), getLabel: (id) => tasks.find((t) => t.id === id)?.title ?? id, onDrop }))}
>
  {#each tasks as task (task.id)}
    <li class="sl__row" data-dnd-item={task.id}>
      <button class="sl__grip" type="button" aria-label="Move {task.title}" aria-describedby={dnd.instructionsId}>
        <GripVertical size={16} aria-hidden="true" />
      </button>
      <span>{task.title}</span>
    </li>
  {/each}
</ul>

<style>
  .sl { width: 100%; max-width: 360px; margin: 0; padding: 0; list-style: none; display: grid; gap: var(--ca-space-2); }
  .sl__row {
    display: flex; align-items: center; gap: var(--ca-space-2);
    padding: var(--ca-space-2) var(--ca-space-3);
    background: var(--ca-surface-raised); border: var(--ca-border-width) solid var(--ca-outline-subtle);
    border-radius: var(--ca-radius-md);
  }
  .sl__grip {
    display: grid; place-items: center; padding: var(--ca-space-1); min-width: var(--ca-touch-target); min-height: var(--ca-touch-target);
    color: var(--ca-text-secondary); background: none; border: 0; border-radius: var(--ca-radius-sm);
    cursor: grab; touch-action: none;
  }
</style>

Move between lists

Lists that share a group accept each other’s items. applyMove applies a drop event to a record of arrays (reorder inside one list, move between two); use your own reducer when the data lives in a store.

#431

Board

Cards reorder inside a column and move between columns. With the keyboard, Space lifts, the arrows move and Enter drops.

Press Space to lift, the arrow keys to move, Enter to drop and Escape to cancel. Touch: press and hold, then drag.

To do 2

In progress 1

Done 1

Reference #431 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { applyMove, DndLayer, useDnd, type DndDropEvent } from '@codeatlasdev/ui';

  type Card = { id: string; title: string };
  const names: Record<string, string> = { todo: 'To do', doing: 'In progress', done: 'Done' };
  let lists = $state<Record<string, Card[]>>({
    todo: [{ id: 'c1', title: 'Design the empty state' }, { id: 'c2', title: 'Audit contrast' }],
    doing: [{ id: 'c3', title: 'Wire the export menu' }],
    done: [{ id: 'c4', title: 'Ship dark mode' }],
  });

  const dnd = useDnd();

  function onDrop(e: DndDropEvent) {
    lists = applyMove(lists, e, (c) => c.id);
  }
  const title = (id: string) => Object.values(lists).flat().find((c) => c.id === id)?.title ?? id;
</script>

<DndLayer {dnd} />
<div class="bd">
  {#each Object.keys(lists) as col (col)}
    <section class="bd__col" aria-labelledby="bd-{col}">
      <h4 id="bd-{col}" class="bd__h">{names[col]} <span>{lists[col].length}</span></h4>
      <ul
        class="bd__list"
        aria-labelledby="bd-{col}"
        {@attach dnd.sortable(() => ({ id: col, group: 'board', label: names[col], getItems: () => lists[col].map((c) => c.id), getLabel: title, onDrop }))}
      >
        {#each lists[col] as card (card.id)}
          <li data-dnd-item={card.id} class="bd__card">
            <button type="button" class="bd__btn" aria-describedby={dnd.instructionsId}>{card.title}</button>
          </li>
        {/each}
      </ul>
    </section>
  {/each}
</div>

<style>
  .bd { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--ca-space-3); width: 100%; }
  .bd__col { display: grid; align-content: start; gap: var(--ca-space-2); }
  .bd__h { margin: 0; font-size: var(--ca-font-size-label); color: var(--ca-text-secondary); display: flex; justify-content: space-between; }
  .bd__list { margin: 0; padding: var(--ca-space-2); list-style: none; display: grid; gap: var(--ca-space-2); align-content: start; min-height: 8rem; background: var(--ca-surface-sunken, transparent); border: var(--ca-border-width) dashed var(--ca-outline-subtle); border-radius: var(--ca-radius-md); }
  .bd__card { background: var(--ca-surface-raised); border: var(--ca-border-width) solid var(--ca-outline-subtle); border-radius: var(--ca-radius-md); }
  .bd__btn { all: unset; box-sizing: border-box; min-height: var(--ca-touch-target); display: flex; align-items: center; width: 100%; padding: var(--ca-space-2) var(--ca-space-3); cursor: grab; touch-action: manipulation; border-radius: var(--ca-radius-md); }
  .bd__btn:focus-visible { outline: 2px solid var(--ca-focus-ring); outline-offset: 2px; }
  @media (max-width: 599.98px) { .bd { grid-template-columns: minmax(0, 1fr); } }
</style>

Drop targets

A drop zone is a target that is not a sortable list: a calendar day, a folder, a trash can. A standalone item uses dnd.draggable; the zone gets data-dnd-over while an item is above it. Return false from accepts to refuse an item.

#432

Drop zones

Drag a chip onto a day, or lift it with Space and pick the day with the arrows.

Press Space to lift, the arrow keys to move, Enter to drop and Escape to cancel. Touch: press and hold, then drag.

Mon
Tue
Wed
Thu

Reference #432 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { DndLayer, useDnd, type DndDropEvent } from '@codeatlasdev/ui';

  const days = ['Mon', 'Tue', 'Wed', 'Thu'];
  const tasks = [
    { id: 'standup', title: 'Stand-up' },
    { id: 'review', title: 'Design review' },
    { id: 'demo', title: 'Demo prep' },
  ];
  let placed = $state<Record<string, string>>({});

  const dnd = useDnd();

  function drop(day: string, e: DndDropEvent) {
    placed = { ...placed, [e.id]: day };
  }
  const unplaced = $derived(tasks.filter((t) => !placed[t.id]));
</script>

<DndLayer {dnd} />
<div class="dz">
  <ul class="dz__tray" aria-label="Unscheduled">
    {#each unplaced as t (t.id)}
      <li class="dz__chip" data-dnd-item={t.id} {@attach dnd.draggable(() => ({ id: t.id, label: t.title }))}>
        <button type="button" class="dz__btn" aria-describedby={dnd.instructionsId}>{t.title}</button>
      </li>
    {/each}
    {#if unplaced.length === 0}<li class="dz__empty">Everything is scheduled</li>{/if}
  </ul>
  <div class="dz__days">
    {#each days as day (day)}
      <div class="dz__day" role="group" aria-label={day} {@attach dnd.dropzone(() => ({ id: day, label: day, onDrop: (e) => drop(day, e) }))}>
        <strong>{day}</strong>
        <ul class="dz__placed">
          {#each tasks.filter((t) => placed[t.id] === day) as t (t.id)}<li>{t.title}</li>{/each}
        </ul>
      </div>
    {/each}
  </div>
</div>

<style>
  .dz { display: grid; gap: var(--ca-space-4); width: 100%; }
  .dz__tray { margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: var(--ca-space-2); min-height: 2.75rem; }
  .dz__chip { background: var(--ca-brand-fill); color: var(--ca-brand-contrast); border-radius: var(--ca-radius-pill); }
  .dz__btn { all: unset; box-sizing: border-box; min-height: var(--ca-touch-target); display: flex; align-items: center; padding: var(--ca-space-2) var(--ca-space-4); cursor: grab; touch-action: manipulation; border-radius: var(--ca-radius-pill); }
  .dz__btn:focus-visible { outline: 2px solid var(--ca-focus-ring); outline-offset: 2px; }
  .dz__empty { color: var(--ca-text-secondary); align-self: center; }
  .dz__days { display: grid; grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); gap: var(--ca-space-2); }
  .dz__day { min-height: 6rem; padding: var(--ca-space-2); background: var(--ca-surface-raised); border: var(--ca-border-width) solid var(--ca-outline-subtle); border-radius: var(--ca-radius-md); display: grid; align-content: start; gap: var(--ca-space-1); }
  .dz__placed { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--ca-space-1); font-size: var(--ca-font-size-body2); }
</style>

API

useDnd(options?) returns { sortable, draggable, dropzone, instructionsId, instructions, active, cancel }. <DndLayer dnd={dnd} /> renders instructions under instructionsId and carries the styles. sortable, draggable and dropzone take a getter that returns the options and return an attachment ({@attach …}), so reactive state flows in without recreating anything.

Option Applies to Description
id all Container or item id. Required.
group sortable Lists with the same group exchange items. Default: the list id (no exchange).
getItems() sortable Ids of the items, in order.
getLabel(id) sortable Name of an item for announcements. Default: its text.
label all Name of the list, zone or item for announcements. Default: the id.
axis sortable "y" (default) or "x".
drop sortable "position" (default): the drop lands between items and a line shows where. "container": the whole list is the target, highlighted, no line, to.index is always 0 (a list ordered by a server-side rule).
handle sortable, draggable CSS selector of the element that starts a drag. Default: the whole item.
disabled all Turns the container or item off.
accepts(id, data) dropzone Return false to refuse an item.
data draggable Anything you want back in onDrop.
onDrop(e) sortable, dropzone { id, data, from, to }. The only place to change state.

dnd.scroller(() => ({ axis, edge, maxSpeed, disabled })) marks a board as a scroll region: while a drag holds the pointer near its edge it scrolls the board, faster the closer the pointer is. For a ready-made board see Kanban.

useDnd also takes { messages, threshold, holdMs, edge } to override the screen reader strings and the timings.

Styling hooks

The engine sets attributes you can style; the package ships defaults for them.

Hook Where Meaning
data-dnd-item item You set it to the item id.
data-dnd-dragging item The item being dragged (its slot).
data-dnd-pressing item Touch press, before the hold completes.
data-dnd-over list or zone The pointer or keyboard target.
data-dnd-zone zone Set on every drop zone.
html[data-dnd-active] <html> A drag is running.
.ca-dnd-overlay, .ca-dnd-indicator <body> The clone and the drop line.

Under forced-colors: active the indicator and the outlines use system colors.