Drag Group

Move items within and between lists by dragging or with Alt and the arrow keys.

derekr <sb-drag-group> drag and drop lists move reorder kanban keyboard pd rockets Since 2026-10-06 Beerware licence 8.4 kB0Open in playground Source Edit on GitHub

From PD rockets by derekr, where it is pd-drag-group, with its names in Starbase's sb- prefix. It carries the patches in patches/pd-rockets, which for this surface keep Alt and an arrow key on an item from reaching the browser, let the arrow keys reach empty lists, list the move event in the manifest, let it nest in other PD rockets components, and skip the move animation when the reader prefers reduced motion.

Several lists whose items the server renders and arranges. Drag an item within its list or into another one, or focus it and hold Alt while you press the arrow keys. The component shows where the item would land, then emits sb-drag-group-move with the item, the list it comes from, the list it goes to and the item it goes before. It moves nothing itself: the server applies the move and sends the lists back, and the morph puts each item in its place with a short animation.

Examples

Sort the bodies into planets and dwarf planets

Ceres and Venus start in the wrong list. The server renders both lists with their contents in data-state. A move sends that arrangement with the event's detail to /demo/arrange/drag-group, which answers with the lists rearranged. Nothing is stored: try it in two tabs.

Planets
馃獝 Earth
馃 Ceres
馃獝 Mars
馃獝 Jupiter
Dwarf planets
馃 Pluto
馃獝 Venus
<style>
  .demo-group { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 12px; inline-size: min(100%, 26rem); }
  .demo-group [data-drop-list] {
    position: relative;
    display: grid;
    align-content: start;
    gap: 6px;
    min-block-size: 10rem;
    padding: 0.5rem;
    border: 1px dashed var(--sb-border);
    background: var(--sb-surface-inset);
  }
  .demo-group [data-drop-active] { border-color: var(--sb-brand); }
  .demo-group__title { color: var(--sb-text-2); font-size: 0.75rem; font-weight: 600; }
  .demo-group [data-drag-item], [data-drag-preview][data-drag-item] {
    padding: 0.5rem 0.75rem;
    border: 1px solid var(--sb-border);
    background: var(--sb-surface-card);
    color: var(--sb-text-1);
    cursor: grab;
    touch-action: none;
    user-select: none;
  }
  .demo-group [data-drag-item] { position: relative; }
  .demo-group :is([data-drag-item], [data-drop-list]):focus-visible { outline: 2px solid var(--sb-brand-light); outline-offset: 2px; }
  .demo-group [data-dragging] { opacity: 0.35; }
  .demo-group[data-key-staging] [data-drag-item]:focus { border-color: var(--sb-brand); }
  .demo-group [data-drop-before]::before, .demo-group [data-drop-end]::after {
    content: "";
    display: block;
    block-size: 3px;
    background: var(--sb-brand);
  }
  .demo-group [data-drop-before]::before { position: absolute; inset: -5px 0 auto; }
  [data-drag-preview][data-drag-item] { box-shadow: var(--sb-shadow-overlay, 0 8px 16px rgb(0 0 0 / 0.35)); cursor: grabbing; }
  @media (forced-colors: active) {
    .demo-group [data-drop-active], .demo-group[data-key-staging] [data-drag-item]:focus, [data-drag-preview][data-drag-item] { border-color: Highlight; }
    .demo-group [data-dragging] { border: 1px dashed CanvasText; }
    .demo-group [data-drop-before]::before, .demo-group [data-drop-end]::after { background: Highlight; }
    .demo-group :is([data-drag-item], [data-drop-list]):focus-visible { outline-color: Highlight; }
  }
</style>
<sb-drag-group id="sort-bodies" data-ignore-morph class="demo-group" data-state="planets=earth,ceres,mars,jupiter dwarfs=pluto,venus"
	data-on:sb-drag-group-move="@get('/demo/arrange/drag-group', {payload: {id: el.id, state: el.dataset.state, move: evt.detail}})">
	<p id="sort-bodies-keys" hidden>To move it, hold Alt and press the arrow keys.</p>
	<section data-drop-list="planets" tabindex="-1" aria-label="Planets">
		<span class="demo-group__title">Planets</span>
		<div id="sort-bodies-earth" data-drag-item="earth" tabindex="0" aria-describedby="sort-bodies-keys">馃獝 Earth</div>
		<div id="sort-bodies-ceres" data-drag-item="ceres" tabindex="0" aria-describedby="sort-bodies-keys">馃 Ceres</div>
		<div id="sort-bodies-mars" data-drag-item="mars" tabindex="0" aria-describedby="sort-bodies-keys">馃獝 Mars</div>
		<div id="sort-bodies-jupiter" data-drag-item="jupiter" tabindex="0" aria-describedby="sort-bodies-keys">馃獝 Jupiter</div>
	</section>
	<section data-drop-list="dwarfs" tabindex="-1" aria-label="Dwarf planets">
		<span class="demo-group__title">Dwarf planets</span>
		<div id="sort-bodies-pluto" data-drag-item="pluto" tabindex="0" aria-describedby="sort-bodies-keys">馃 Pluto</div>
		<div id="sort-bodies-venus" data-drag-item="venus" tabindex="0" aria-describedby="sort-bodies-keys">馃獝 Venus</div>
	</section>
</sb-drag-group>

Markup and events

Every element with data-drop-list="<id>" inside the group is a list, and every element with data-drag-item="<id>" inside a list is an item. The ids are what the event reports: list ids name the server's destinations, and item ids must be unique within the group. Items can hold anything, and they need tabindex="0" to take the keyboard focus. Other content in a list, such as a title, is left alone.

The example also gives each item an id, so the morph moves the element, focus and all; without one, the morph rewrites the element in the old place, and the focus stays there, on another item. Each list has tabindex="-1", so the arrow keys can focus it while it is empty without adding a Tab stop; they pass over an empty list that has no tabindex.

Event Detail When
sb-drag-group-move { itemId, fromList, toList, before } An item was dropped, or a keyboard move was committed. fromList and toList are list ids (the same one for a move within a list); before is the id of the item it now precedes in toList, or "" for the end.

Groups are independent: an item never leaves its group. A sortable list or another drag group inside an item handles its own items, since the nearest component owns each gesture. The event bubbles, so a handler that nested components reach should check evt.target.

Keyboard

Keys Action
Arrow Down / Arrow Up, or j / k Focus the next / previous item in the list
Arrow Left / Arrow Right, or h / l Focus the item at the same place in the next list to the left / right that has items, or an empty list on the way that has a tabindex
Home / End Focus the first / last item in the list
Alt + Arrow Down / Up, or Alt + j / k Move the focused item down / up its list
Alt + Arrow Left / Right, or Alt + h / l Move the focused item to the end of the list to the left / right
Escape Cancel a move before Alt is released

Moves add up while Alt is held: Alt + Arrow Right and then Alt + Arrow Up places the item above the last one of the next list. Releasing Alt sends the move. On macOS, Ctrl + n and Ctrl + p also move the focus. Each action takes its keys from a data-key-<action> attribute on the group, a space-separated list such as data-key-focus-next="ArrowDown n"; an empty value turns the action off. A key with modifiers names them with +, as in Alt+ArrowUp or Ctrl+n. Give the move keys one, such as data-key-move-up="Alt+ArrowUp Alt+w": its release sends the move, so a move key without a modifier sends each step on its own. The actions are focus-next, focus-previous, focus-left, focus-right, focus-first, focus-last, move-up, move-down, move-left, move-right and cancel.

On the server

The server owns the lists. A handler applies the move and renders them again; this is the demo's, which keeps the arrangement in the markup instead of a database:

// arrangeDragGroup applies an sb-drag-group-move ({itemId, fromList,
// toList, before}) to lists of bodies.
func arrangeDragGroup(state string, move json.RawMessage) (string, error) {
	g, err := parseGroup(state)
	if err != nil {
		return "", err
	}
	var m struct {
		ItemID   string `json:"itemId"`
		FromList string `json:"fromList"`
		ToList   string `json:"toList"`
		Before   string `json:"before"`
	}
	if err := json.Unmarshal(move, &m); err != nil {
		return "", err
	}
	from, to := g.items[m.FromList], g.items[m.ToList]
	if !slices.Contains(g.names, m.ToList) || !slices.Contains(from, m.ItemID) {
		return "", fmt.Errorf("%q is not in %q, or %q is not a list", m.ItemID, m.FromList, m.ToList)
	}
	if m.FromList != m.ToList {
		g.items[m.FromList] = slices.DeleteFunc(slices.Clone(from), func(id string) bool { return id == m.ItemID })
		to = append(slices.Clone(to), m.ItemID)
	}
	if g.items[m.ToList], err = moveBefore(to, m.ItemID, m.Before); err != nil {
		return "", err
	}
	return g.String(), nil
}

// renderDragGroup is the group's markup: the host the morph replaces, with
// the arrangement in data-state, and a list per name with an item per body.
// Item ids make the morph move an item, focus and all; tabindex="-1" lets
// the arrow keys focus an empty list.
func renderDragGroup(id, state string) string {
	g, _ := parseGroup(state)
	var b strings.Builder
	fmt.Fprintf(&b, "<sb-drag-group id=\"%s\" data-ignore-morph class=\"demo-group\" data-state=\"%s\"\n\tdata-on:sb-drag-group-move=\"%s\">\n", id, state, arrangeOn("drag-group"))
	fmt.Fprintf(&b, "\t<p id=\"%s-keys\" hidden>To move it, hold Alt and press the arrow keys.</p>\n", id)
	for _, name := range g.names {
		fmt.Fprintf(&b, "\t<section data-drop-list=\"%s\" tabindex=\"-1\" aria-label=\"%s\">\n\t\t<span class=\"demo-group__title\">%s</span>\n", name, groupLists[name], groupLists[name])
		for _, item := range g.items[name] {
			fmt.Fprintf(&b, "\t\t<div id=\"%s-%s\" data-drag-item=\"%s\" tabindex=\"0\" aria-describedby=\"%s-keys\">%s</div>\n", id, item, item, id, label(item))
		}
		b.WriteString("\t</section>\n")
	}
	b.WriteString("</sb-drag-group>")
	return b.String()
}

Styling

The component adds no styles: the lists and their items are your page's markup, styled by your page's CSS. While an item moves, it marks the elements involved:

  • data-drag-active on the group and data-dragging on the item while you drag (the copy under the pointer has it too, so scope the rule to the group).
  • data-drop-active on the list the item would land in, with data-drop-before on the item it would land before, or data-drop-end on the list when it would land at the end.
  • data-key-staging on the group while a keyboard move waits for Alt to be released.
  • The item under the pointer is a copy of the item in <body>, marked data-drag-preview, with the original's size in --sb-source-width and --sb-source-height. A <template data-sb-preview> that is a direct child of an item replaces the copy.
  • A <template data-sb-target="before"> (or "end", or "" for both) that is a direct child of the group is copied into the landing place as a drop marker, marked data-sb-target-indicator.

The example above shows one way to draw them.

Accessibility

Lists and items are elements of your page, so their roles and names are yours: the example names each list with aria-label, and points every item's aria-describedby at a hidden paragraph that tells how to move it. Keyboard moves need no pointer. The component announces nothing itself: when the server sends the new arrangement, the moved item keeps the focus.

Licence

The code is PD rockets', under its Beer-Ware licence, in LICENSE-pd-rockets.txt next to it. go run ./cmd/vendorpd brings in a newer release.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<sb-drag-group data-on:sb-drag-group-move="@post('/cards/move', {payload: evt.detail})">
  <section data-drop-list="todo">
    <div data-drag-item="a" tabindex="0">Write the docs</div>
  </section>
  <section data-drop-list="done">
    <div data-drag-item="b" tabindex="0">Ship it</div>
  </section>
</sb-drag-group>

No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.

Load just this component, pinned to this version: the minified module with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/drag-group.min.js": "sha384-882hpsSzMHydjovxNG9NnFO2fXJBo6kBpHZk3Bj+3yzRseUUMCUm/ummVZx3eu38",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/contracts/drag-group.min.js": "sha384-WkLIBza47j//QihB3w5N4Vgb8ZGx+D9ZnzFx2SLHX7UF+23394BSLLdz8c3jTVdw",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/drag-state.min.js": "sha384-5Y+afnEzy5l/9zFkAh+LOmom6361otYZ1mfJztIuXD7yOlTGx+TVHk1h9biAKgyX",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/flip.min.js": "sha384-1lsCQwc6EMFn4yzzI9FZbnF3Zbw6G59SB/cMrp533rqQ2gnI5eehw/RCrds3YJfr",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/focus-recovery.min.js": "sha384-JHh8h56l3OTHOT2hMP71pjVnh+AhoEBm41Fi2GetKZB35rHk5hVh0l+HXr92ZFqH",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/insertion-target.min.js": "sha384-WdOMwKgasfqQ4iOd9LI7wcmw3a4pAIkNdBRA63gnNUsjzw5NmDiyBKyf8jWkOB27",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/keyboard-staging.min.js": "sha384-WnYZ2saImC9lL8d4IfSLm3FT5TEzIPPsMviBcJe5NTuVh/vFpZm6/d0oMDYs8s8E",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/keyboard.min.js": "sha384-5AVhHD8gnbU5fkXCucowCxLCa0MxEQ7d2mQ9dRh+5eXhHEkfV8py2h6jinGH4PDY",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/ownership.min.js": "sha384-HhT3ZO2nxuGm6d66kwqQaE7Q9YADIW0cQqCQVTx5hKVW0D9PNvRBAqDxI26f8xBo",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/pointer-drag.min.js": "sha384-yslRfcHaD24bjpam90FqtgQVDHZXb4QT/GOfuQElOyb8qqgacm6PaesXPD9CbYa6",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/visual-outlets.min.js": "sha384-+AzW6If/d7MsA7+32XnB3SaY6VIbxTTzS8jc6fHLwSem6VyO3eH8ADPyLfTiSc3D"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/drag-group.min.js" integrity="sha384-882hpsSzMHydjovxNG9NnFO2fXJBo6kBpHZk3Bj+3yzRseUUMCUm/ummVZx3eu38"></script>

<sb-drag-group data-on:sb-drag-group-move="@post('/cards/move', {payload: evt.detail})">
  <section data-drop-list="todo">
    <div data-drag-item="a" tabindex="0">Write the docs</div>
  </section>
  <section data-drop-list="done">
    <div data-drag-item="b" tabindex="0">Ship it</div>
  </section>
</sb-drag-group>

In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/drag-group.min.js": "sha384-882hpsSzMHydjovxNG9NnFO2fXJBo6kBpHZk3Bj+3yzRseUUMCUm/ummVZx3eu38",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/contracts/drag-group.min.js": "sha384-WkLIBza47j//QihB3w5N4Vgb8ZGx+D9ZnzFx2SLHX7UF+23394BSLLdz8c3jTVdw",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/drag-state.min.js": "sha384-5Y+afnEzy5l/9zFkAh+LOmom6361otYZ1mfJztIuXD7yOlTGx+TVHk1h9biAKgyX",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/flip.min.js": "sha384-1lsCQwc6EMFn4yzzI9FZbnF3Zbw6G59SB/cMrp533rqQ2gnI5eehw/RCrds3YJfr",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/focus-recovery.min.js": "sha384-JHh8h56l3OTHOT2hMP71pjVnh+AhoEBm41Fi2GetKZB35rHk5hVh0l+HXr92ZFqH",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/insertion-target.min.js": "sha384-WdOMwKgasfqQ4iOd9LI7wcmw3a4pAIkNdBRA63gnNUsjzw5NmDiyBKyf8jWkOB27",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/keyboard-staging.min.js": "sha384-WnYZ2saImC9lL8d4IfSLm3FT5TEzIPPsMviBcJe5NTuVh/vFpZm6/d0oMDYs8s8E",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/keyboard.min.js": "sha384-5AVhHD8gnbU5fkXCucowCxLCa0MxEQ7d2mQ9dRh+5eXhHEkfV8py2h6jinGH4PDY",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/ownership.min.js": "sha384-HhT3ZO2nxuGm6d66kwqQaE7Q9YADIW0cQqCQVTx5hKVW0D9PNvRBAqDxI26f8xBo",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/pointer-drag.min.js": "sha384-yslRfcHaD24bjpam90FqtgQVDHZXb4QT/GOfuQElOyb8qqgacm6PaesXPD9CbYa6",
      "https://starbase.zweiundeins.gmbh/c/drag-group@368d0df910cd/core/visual-outlets.min.js": "sha384-+AzW6If/d7MsA7+32XnB3SaY6VIbxTTzS8jc6fHLwSem6VyO3eH8ADPyLfTiSc3D"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@a7896c28d7cd/autoloader.js" integrity="sha384-avgqrgW9PG1hl/YoLOqUcsiTNPl4fikGkS1FmaQ01yZWKg402iP0IJLdzzl76811"></script>

<sb-drag-group data-on:sb-drag-group-move="@post('/cards/move', {payload: evt.detail})">
  <section data-drop-list="todo">
    <div data-drag-item="a" tabindex="0">Write the docs</div>
  </section>
  <section data-drop-list="done">
    <div data-drag-item="b" tabindex="0">Ship it</div>
  </section>
</sb-drag-group>

Components import only datastar and their own files, so copying the folder is enough: save these files, keeping each component's folder, and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/drag-group/drag-group.min.js"></script>

<sb-drag-group data-on:sb-drag-group-move="@post('/cards/move', {payload: evt.detail})">
  <section data-drop-list="todo">
    <div data-drag-item="a" tabindex="0">Write the docs</div>
  </section>
  <section data-drop-list="done">
    <div data-drag-item="b" tabindex="0">Ship it</div>
  </section>
</sb-drag-group>

Size

Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotliminified
drag-group.js 10.4 kB2.8 kB2.5 kB1.8 kB
contracts/drag-group.js 745 B470 B390 B347 B
core/drag-state.js 1.4 kB641 B530 B489 B
core/flip.js 3.1 kB1.2 kB1.1 kB800 B
core/focus-recovery.js 2.2 kB908 B733 B579 B
core/insertion-target.js 798 B502 B430 B324 B
core/keyboard-staging.js 2.8 kB969 B822 B616 B
core/keyboard.js 4.3 kB1.7 kB1.5 kB1.1 kB
core/ownership.js 1.2 kB677 B549 B403 B
core/pointer-drag.js 7.1 kB2.0 kB1.8 kB1.4 kB
core/visual-outlets.js 2.0 kB891 B715 B534 B
Total36.1 kB12.9 kB11.0 kB8.4 kB

API reference

Props

This component takes no props.

Events

NameDescription
sb-drag-group-moveAn item was dropped, or a keyboard move was committed. detail: { itemId, fromList, toList, before }: the item, the ids of the lists it leaves and joins (the same for a move within a list), and the item it now precedes in toList, or an empty string for the end.