Open demo

CSS code

HTML
<div><span class="lbl">Native sortable reading list</span>
<section class="nv" data-dd aria-labelledby="nv-read-t">
  <div class="nv-head"><h2 id="nv-read-t">Weekend reading</h2><p id="nv-hint1">Drag an article with the mouse or by its handle on touch screens, or focus a handle, press Space and use the arrow keys. Drag one out to another app to share its link.</p></div>
  <ol class="nv-list" data-dd-list="reading" aria-label="Weekend reading">
    <li class="nv-item" draggable="true" data-dd-item data-url="https://inkwell.example/slower-software"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text"><b data-dd-name>The case for slower software</b><small>inkwell.example · 8 min</small></span></li>
    <li class="nv-item" draggable="true" data-dd-item data-url="https://fieldnotes.example/calm-notifications"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text"><b data-dd-name>Designing calm notifications</b><small>fieldnotes.example · 6 min</small></span></li>
    <li class="nv-item" draggable="true" data-dd-item data-url="https://gridline.example/anchor-positioning"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text"><b data-dd-name>A field guide to CSS anchor positioning</b><small>gridline.example · 12 min</small></span></li>
    <li class="nv-item" draggable="true" data-dd-item data-url="https://northstar.example/writing-culture"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text"><b data-dd-name>Why your team needs a writing culture</b><small>northstar.example · 9 min</small></span></li>
    <li class="nv-item" draggable="true" data-dd-item data-url="https://tabindex.example/keyboard-first"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text"><b data-dd-name>Notes on keyboard-first interfaces</b><small>tabindex.example · 5 min</small></span></li>
  </ol>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>

<div><span class="lbl">Link shelf that accepts links from other tabs</span>
<section class="nv" data-dd aria-labelledby="nv-shelf-t">
  <div class="nv-head"><h2 id="nv-shelf-t">Project links</h2><p id="nv-hint2">Move links between Pinned and Saved, or drop a link from another tab or the address bar onto Saved. Left and Right switch lists from the keyboard.</p></div>
  <div class="nv-cols">
    <div data-dd-zone><h3>Pinned · <span data-dd-count="nv-pinned">2</span></h3><ul class="nv-list" id="nv-pinned" data-dd-list="links" aria-label="Pinned">
      <li class="nv-item" draggable="true" data-dd-item data-url="https://handbook.example/"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint2"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter" aria-hidden="true" data-letter>H</span><a href="https://handbook.example/" draggable="false" data-dd-name>Team handbook</a></li>
      <li class="nv-item" draggable="true" data-dd-item data-url="https://boards.example/sprint-15"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint2"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter" aria-hidden="true" data-letter>S</span><a href="https://boards.example/sprint-15" draggable="false" data-dd-name>Sprint board</a></li>
    </ul></div>
    <div data-dd-zone><h3>Saved · <span data-dd-count="nv-saved">3</span></h3><ul class="nv-list" id="nv-saved" data-dd-list="links" data-dd-accept aria-label="Saved">
      <li class="nv-item" draggable="true" data-dd-item data-url="https://tokens.example/brand"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint2"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter" aria-hidden="true" data-letter>B</span><a href="https://tokens.example/brand" draggable="false" data-dd-name>Brand colour tokens</a></li>
      <li class="nv-item" draggable="true" data-dd-item data-url="https://docs.example/release"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint2"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter" aria-hidden="true" data-letter>R</span><a href="https://docs.example/release" draggable="false" data-dd-name>Release checklist</a></li>
      <li class="nv-item" draggable="true" data-dd-item data-url="https://research.example/interviews"><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint2"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter" aria-hidden="true" data-letter>C</span><a href="https://research.example/interviews" draggable="false" data-dd-name>Customer interviews</a></li>
    </ul></div>
  </div>
  <template><li class="nv-item" draggable="true" data-dd-item><button class="nv-handle" type="button" data-dd-handle aria-describedby="nv-hint2"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter" aria-hidden="true" data-letter></span><a href="#" draggable="false" data-dd-name></a></li></template>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
/* Native drag and drop: draggable="true" items, a handle button for touch and keyboard, and data attributes
   (data-dragging, data-over, data-grabbed, data-empty) that the script sets for these styles. */
.nv{--nv-accent:#0369a1;--nv-tint:#e8f4fb;--nv-line:#e2e6ec;--nv-muted:#5d6679;--nv-ink:#0f172a;
  background:#fff;border:1px solid var(--nv-line);border-radius:16px;padding:16px;color:var(--nv-ink);box-shadow:0 1px 2px rgba(15,23,42,.05),0 12px 32px -18px rgba(15,23,42,.25)}
.nv-head{display:grid;gap:2px;margin:2px 4px 12px}
.nv-head h2{font-size:1rem;letter-spacing:-.01em}
.nv-head p{font-size:.8rem;color:var(--nv-muted)}
.nv-list{list-style:none;display:grid;gap:6px;min-height:58px;padding:4px;border-radius:12px;outline:2px dashed transparent;outline-offset:-2px;transition:background-color .15s,outline-color .15s}
.nv[data-dd-active] .nv-list{outline-color:#b8c2cf}
.nv .nv-list[data-over]{outline-color:var(--nv-accent);background:var(--nv-tint)}
.nv-list[data-empty]::after{content:"Drop links here";display:grid;place-items:center;min-height:48px;font-size:.8rem;color:var(--nv-muted)}
.nv-item{display:flex;align-items:center;gap:10px;padding:8px 12px 8px 4px;background:#fff;border:1px solid var(--nv-line);border-radius:12px;cursor:grab;transition:opacity .2s,translate .2s}
@starting-style{.nv-item{opacity:0;translate:0 -6px}}
.nv-handle{flex:none;display:grid;place-items:center;width:30px;height:34px;border:0;border-radius:8px;background:none;color:#667085;cursor:grab;touch-action:none}
.nv-handle:hover{background:#f1f4f8;color:var(--nv-ink)}
.nv-handle:focus-visible,.nv-item a:focus-visible{outline:2px solid var(--nv-accent);outline-offset:2px}
.nv-handle svg{width:18px;height:18px}
.nv-text{flex:1;min-width:0;display:grid}
.nv-text b{font-size:.9rem;font-weight:600}
.nv-text small{font-size:.76rem;color:var(--nv-muted)}
.nv-cols{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.nv-cols h3{font-size:.8rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--nv-muted);margin:0 6px 6px}
.nv-letter{flex:none;display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:var(--nv-tint);color:var(--nv-accent);font-size:.75rem;font-weight:800}
.nv-item a{flex:1;min-width:0;font-size:.86rem;font-weight:600;color:var(--nv-ink);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-radius:4px}
.nv-item a:hover{color:var(--nv-accent);text-decoration:underline}
/* states set by the script */
.nv-item[data-dragging]{background:var(--nv-tint);border:2px dashed #7db4d8}
.nv-item[data-dragging]>*{visibility:hidden}
.nv-item[data-dd-ghost]{box-shadow:0 16px 36px -12px rgba(15,23,42,.4);rotate:-1deg}
.nv-item[data-grabbed]{border-color:var(--nv-accent);box-shadow:0 0 0 2px var(--nv-accent)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media (max-width:560px){.nv-cols{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.nv-list,.nv-item{transition:none}.nv-item[data-dd-ghost]{rotate:none}}
JavaScript
// Link shelf: turn a link dropped from outside into a new item, built from the <template>.
document.addEventListener('dd:external', e => {
  const { list, url } = e.detail, u = new URL(url), host = u.hostname.replace(/^www\./, '');
  const li = e.target.querySelector('template').content.firstElementChild.cloneNode(true);
  e.detail.name = host + (u.pathname.length > 1 ? u.pathname.replace(/\/$/, '') : '');
  li.dataset.url = url; li.querySelector('a').href = url;
  li.querySelector('[data-dd-name]').textContent = e.detail.name;
  li.querySelector('[data-letter]').textContent = host[0].toUpperCase();
  list.prepend(li);
});
// No library: the native HTML Drag and Drop API (draggable="true", dragstart / dragover / drop, dataTransfer) for the mouse,
// pointer events for touch and pen (mobile browsers start native drags only after a long press, and not all of them),
// and a keyboard mode on every handle: Space or Enter picks up, arrow keys move, Space or Enter drops, Escape cancels.
document.querySelectorAll('[data-dd]').forEach(root => {
  const live = root.querySelector('[data-dd-live]');
  const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
  const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
  const name = el => el.querySelector('[data-dd-name]').textContent.trim();
  const label = l => l.getAttribute('aria-label');
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? label(l) + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const over = l => root.querySelectorAll('[data-dd-list]').forEach(x => x.toggleAttribute('data-over', x === l));
  const changed = () => {
    root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`); l.toggleAttribute('data-empty', !n); if (c) c.textContent = n; });
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
  };
  const commit = () => { changed(); root.dispatchEvent(new Event('dd:change', { bubbles: true })); }; // after every finished move
  const target = (hit, group) => { const sel = group ? `[data-dd-list="${group}"]` : '[data-dd-list]';
    const l = hit && hit.closest && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); return l && root.contains(l) ? l : null; };
  // Put the dragged item before or after the item under the pointer, or at the end of an empty list.
  const place = (list, hit, y) => {
    const o = items(list).find(c => c !== drag && c.contains(hit));
    if (o) { const r = o.getBoundingClientRect(), ref = y > r.top + r.height / 2 ? o.nextSibling : o; if (ref !== drag && ref !== drag.nextSibling) list.insertBefore(drag, ref); }
    else if (list !== drag.parentElement && !drag.contains(hit)) list.append(drag);
    changed();
  };
  let drag, home, ghost, kb, kbh, pid, sx, sy, oy, ox, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);
  const start = el => { drag = el; home = [el.parentElement, el.nextSibling]; root.setAttribute('data-dd-active', ''); };
  const stop = () => { drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); over(null); commit(); drag = null; };

  // 1. Mouse: native drag and drop. dataTransfer also carries the item out to other apps as text and a link.
  root.addEventListener('dragstart', e => {
    const item = e.target.closest && e.target.closest('[data-dd-item]');
    if (!item || kb || ghost) return e.preventDefault();
    start(item);
    e.dataTransfer.effectAllowed = 'copyMove';
    e.dataTransfer.setData('text/plain', item.dataset.url ? name(item) + ' ' + item.dataset.url : name(item));
    if (item.dataset.url) e.dataTransfer.setData('text/uri-list', item.dataset.url);
    requestAnimationFrame(() => drag && drag.setAttribute('data-dragging', '')); // after the browser has taken its drag image
  });
  root.addEventListener('dragover', e => {
    const list = target(e.target, drag && home[0].dataset.ddList);
    if (drag && !list) return e.preventDefault(); // between lists: keep the item where it last landed
    if (!list || (!drag && !('ddAccept' in list.dataset))) return; // outside drops only where data-dd-accept allows them
    e.preventDefault(); e.dataTransfer.dropEffect = drag ? 'move' : 'copy'; over(list);
    if (drag) place(list, e.target, e.clientY);
  });
  root.addEventListener('dragleave', e => { if (!root.contains(e.relatedTarget)) over(null); });
  root.addEventListener('drop', e => {
    if (drag) { e.preventDefault(); return say(name(drag) + ' moved to ' + pos(drag) + '.'); }
    const list = target(e.target); if (!list || !('ddAccept' in list.dataset)) return;
    e.preventDefault();
    const url = (e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain')).split(/\s+/).find(s => /^https?:\/\//.test(s));
    over(null);
    if (!url) return say('Only links can be dropped here.');
    const detail = { list, url, name: '' }; root.dispatchEvent(new CustomEvent('dd:external', { detail, bubbles: true })); commit();
    say('Added ' + (detail.name || 'a link') + ' to ' + label(list) + '.');
  });
  root.addEventListener('dragend', e => { if (!drag) return; if (e.dataTransfer.dropEffect === 'none') { putBack(drag); say('Move cancelled.'); } stop(); });

  // 2. Touch and pen: pointer events from the handle, with a floating copy of the item under the finger.
  root.addEventListener('pointerdown', e => {
    const h = e.target.closest('[data-dd-handle]');
    if (e.pointerType === 'mouse' || !h || kb) return;
    pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null; start(h.closest('[data-dd-item]'));
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) {
      if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return;
      const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; root.setPointerCapture(pid);
      ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
      Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
      drag.after(ghost); drag.setAttribute('data-dragging', '');
    }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    const hit = document.elementFromPoint(e.clientX, e.clientY), list = target(hit, home[0].dataset.ddList);
    over(list); if (list) place(list, hit, e.clientY);
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) { ghost.remove(); ghost = null; if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.'); }
    stop();
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);

  // 3. Keyboard and screen readers: the handle is a toggle button (aria-pressed) that picks the item up.
  const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); over(null); kb = null; commit(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); over(kb.parentElement); commit(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h) return;
    const item = h.closest('[data-dd-item]');
    if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
    if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
    kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
    item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); over(item.parentElement); h.focus();
    say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
  });
  root.addEventListener('keydown', e => {
    if (!kb || e.target !== kbh) return;
    if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
    const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
    e.preventDefault();
    const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l);
    if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + label(l) + '.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    if (!all[i + d]) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, d > 0 ? all[i + d].nextSibling : all[i + d]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  commit();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Native sortable reading list</span>
<section class="nv card" data-dd aria-labelledby="nv-read-t">
  <p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
  <div class="nv-head d-grid gap-1 mx-1 mb-3"><h2 class="fw-bold mb-0" id="nv-read-t">Weekend reading</h2><p class="text-body-secondary mb-0" id="nv-hint1">Drag an article with the mouse or by its handle on touch screens, or focus a handle, press Space and use the arrow keys. Drag one out to another app to share its link.</p></div>
  <ol class="list-group" data-dd-list="reading" aria-label="Weekend reading">
    <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://inkwell.example/slower-software"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text d-grid flex-grow-1"><b data-dd-name>The case for slower software</b><small class="text-body-secondary">inkwell.example · 8 min</small></span></li>
    <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://fieldnotes.example/calm-notifications"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text d-grid flex-grow-1"><b data-dd-name>Designing calm notifications</b><small class="text-body-secondary">fieldnotes.example · 6 min</small></span></li>
    <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://gridline.example/anchor-positioning"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text d-grid flex-grow-1"><b data-dd-name>A field guide to CSS anchor positioning</b><small class="text-body-secondary">gridline.example · 12 min</small></span></li>
    <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://northstar.example/writing-culture"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text d-grid flex-grow-1"><b data-dd-name>Why your team needs a writing culture</b><small class="text-body-secondary">northstar.example · 9 min</small></span></li>
    <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://tabindex.example/keyboard-first"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-text d-grid flex-grow-1"><b data-dd-name>Notes on keyboard-first interfaces</b><small class="text-body-secondary">tabindex.example · 5 min</small></span></li>
  </ol>
</section></div>

<div><span class="lbl">Link shelf that accepts links from other tabs</span>
<section class="nv card" data-dd aria-labelledby="nv-shelf-t">
  <p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
  <div class="nv-head d-grid gap-1 mx-1 mb-3"><h2 class="fw-bold mb-0" id="nv-shelf-t">Project links</h2><p class="text-body-secondary mb-0" id="nv-hint2">Move links between Pinned and Saved, or drop a link from another tab or the address bar onto Saved. Left and Right switch lists from the keyboard.</p></div>
  <div class="row row-cols-1 row-cols-sm-2 g-3">
    <div class="col" data-dd-zone><h3 class="fw-bolder text-uppercase text-body-secondary mx-2 mb-2">Pinned · <span data-dd-count="nv-pinned">2</span></h3><ul class="list-group" id="nv-pinned" data-dd-list="links" aria-label="Pinned">
      <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://handbook.example/"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter rounded-2 d-grid align-content-center text-center fw-bolder flex-shrink-0" aria-hidden="true" data-letter>H</span><a class="link-underline link-underline-opacity-0 link-underline-opacity-100-hover fw-semibold text-truncate flex-grow-1" href="https://handbook.example/" draggable="false" data-dd-name>Team handbook</a></li>
      <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://boards.example/sprint-15"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter rounded-2 d-grid align-content-center text-center fw-bolder flex-shrink-0" aria-hidden="true" data-letter>S</span><a class="link-underline link-underline-opacity-0 link-underline-opacity-100-hover fw-semibold text-truncate flex-grow-1" href="https://boards.example/sprint-15" draggable="false" data-dd-name>Sprint board</a></li>
    </ul></div>
    <div class="col" data-dd-zone><h3 class="fw-bolder text-uppercase text-body-secondary mx-2 mb-2">Saved · <span data-dd-count="nv-saved">3</span></h3><ul class="list-group" id="nv-saved" data-dd-list="links" data-dd-accept aria-label="Saved">
      <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://tokens.example/brand"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter rounded-2 d-grid align-content-center text-center fw-bolder flex-shrink-0" aria-hidden="true" data-letter>B</span><a class="link-underline link-underline-opacity-0 link-underline-opacity-100-hover fw-semibold text-truncate flex-grow-1" href="https://tokens.example/brand" draggable="false" data-dd-name>Brand colour tokens</a></li>
      <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://docs.example/release"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter rounded-2 d-grid align-content-center text-center fw-bolder flex-shrink-0" aria-hidden="true" data-letter>R</span><a class="link-underline link-underline-opacity-0 link-underline-opacity-100-hover fw-semibold text-truncate flex-grow-1" href="https://docs.example/release" draggable="false" data-dd-name>Release checklist</a></li>
      <li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item data-url="https://research.example/interviews"><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter rounded-2 d-grid align-content-center text-center fw-bolder flex-shrink-0" aria-hidden="true" data-letter>C</span><a class="link-underline link-underline-opacity-0 link-underline-opacity-100-hover fw-semibold text-truncate flex-grow-1" href="https://research.example/interviews" draggable="false" data-dd-name>Customer interviews</a></li>
    </ul></div>
  </div>
  <template><li class="list-group-item d-flex align-items-center gap-2" draggable="true" data-dd-item><button class="nv-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="nv-letter rounded-2 d-grid align-content-center text-center fw-bolder flex-shrink-0" aria-hidden="true" data-letter></span><a class="link-underline link-underline-opacity-0 link-underline-opacity-100-hover fw-semibold text-truncate flex-grow-1" href="#" draggable="false" data-dd-name></a></li></template>
</section></div>
CSS
/* Native drag and drop for Bootstrap 5: .card panels, .list-group rows with draggable="true", .btn handles and a .row
   for the two link lists; these rules add the states the script sets and the @starting-style entry for new links. */
.nv{--bs-card-border-color:#e2e6ec;--bs-card-border-radius:16px;--bs-secondary-color:#5d6679;--bs-link-color-rgb:15,23,42;--bs-link-hover-color-rgb:3,105,161;
  --nv-accent:#0369a1;--nv-tint:#e8f4fb;color:#0f172a;padding:16px;box-shadow:0 1px 2px rgba(15,23,42,.05),0 12px 32px -18px rgba(15,23,42,.25)}
.nv-head h2{font-size:1rem;letter-spacing:-.01em}
.nv-head p{font-size:.8rem}
.nv .list-group{--bs-list-group-border-color:#e2e6ec;--bs-list-group-item-padding-x:12px;--bs-list-group-item-padding-y:8px;gap:6px;min-height:58px;padding:4px;border-radius:12px;
  outline:2px dashed transparent;outline-offset:-2px;transition:background-color .15s,outline-color .15s}
.nv[data-dd-active] .list-group{outline-color:#b8c2cf}
.nv .list-group[data-over]{outline-color:var(--nv-accent);background:var(--nv-tint)}
.nv .list-group[data-empty]::after{content:"Drop links here";display:grid;place-items:center;min-height:48px;font-size:.8rem;color:#5d6679}
.nv .list-group-item{border-width:1px!important;border-radius:12px!important;padding-left:4px;cursor:grab;transition:opacity .2s,translate .2s}
@starting-style{.nv .list-group-item{opacity:0;translate:0 -6px}}
.nv-handle{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-color:#667085;--bs-btn-hover-bg:#f1f4f8;--bs-btn-hover-color:#0f172a;--bs-btn-active-bg:#f1f4f8;
  --bs-btn-border-width:0;--bs-btn-border-radius:8px;width:30px;height:34px;cursor:grab;touch-action:none}
.nv-handle:focus-visible,.nv .list-group-item a:focus-visible{outline:2px solid var(--nv-accent);outline-offset:2px;box-shadow:none}
.nv-text b{font-size:.9rem;font-weight:600}
.nv-text small{font-size:.76rem}
.nv h3{font-size:.8rem;letter-spacing:.08em}
.nv-letter{width:28px;height:28px;background:var(--nv-tint);color:var(--nv-accent);font-size:.75rem}
.nv .list-group-item a{font-size:.86rem;border-radius:4px}
.nv .list-group-item[data-dragging]{background:var(--nv-tint);border:2px dashed #7db4d8!important}
.nv .list-group-item[data-dragging]>*{visibility:hidden}
.nv .list-group-item[data-dd-ghost]{box-shadow:0 16px 36px -12px rgba(15,23,42,.4);rotate:-1deg}
.nv .list-group-item[data-grabbed]{border-color:var(--nv-accent)!important;box-shadow:0 0 0 2px var(--nv-accent)}
@media (prefers-reduced-motion:reduce){.nv .list-group,.nv .list-group-item{transition:none}.nv .list-group-item[data-dd-ghost]{rotate:none}}
JavaScript
// Link shelf: turn a link dropped from outside into a new item, built from the <template>.
document.addEventListener('dd:external', e => {
  const { list, url } = e.detail, u = new URL(url), host = u.hostname.replace(/^www\./, '');
  const li = e.target.querySelector('template').content.firstElementChild.cloneNode(true);
  e.detail.name = host + (u.pathname.length > 1 ? u.pathname.replace(/\/$/, '') : '');
  li.dataset.url = url; li.querySelector('a').href = url;
  li.querySelector('[data-dd-name]').textContent = e.detail.name;
  li.querySelector('[data-letter]').textContent = host[0].toUpperCase();
  list.prepend(li);
});
// No library: the native HTML Drag and Drop API (draggable="true", dragstart / dragover / drop, dataTransfer) for the mouse,
// pointer events for touch and pen (mobile browsers start native drags only after a long press, and not all of them),
// and a keyboard mode on every handle: Space or Enter picks up, arrow keys move, Space or Enter drops, Escape cancels.
document.querySelectorAll('[data-dd]').forEach(root => {
  const live = root.querySelector('[data-dd-live]');
  const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
  const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
  const name = el => el.querySelector('[data-dd-name]').textContent.trim();
  const label = l => l.getAttribute('aria-label');
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? label(l) + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const over = l => root.querySelectorAll('[data-dd-list]').forEach(x => x.toggleAttribute('data-over', x === l));
  const changed = () => {
    root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`); l.toggleAttribute('data-empty', !n); if (c) c.textContent = n; });
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
  };
  const commit = () => { changed(); root.dispatchEvent(new Event('dd:change', { bubbles: true })); }; // after every finished move
  const target = (hit, group) => { const sel = group ? `[data-dd-list="${group}"]` : '[data-dd-list]';
    const l = hit && hit.closest && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); return l && root.contains(l) ? l : null; };
  // Put the dragged item before or after the item under the pointer, or at the end of an empty list.
  const place = (list, hit, y) => {
    const o = items(list).find(c => c !== drag && c.contains(hit));
    if (o) { const r = o.getBoundingClientRect(), ref = y > r.top + r.height / 2 ? o.nextSibling : o; if (ref !== drag && ref !== drag.nextSibling) list.insertBefore(drag, ref); }
    else if (list !== drag.parentElement && !drag.contains(hit)) list.append(drag);
    changed();
  };
  let drag, home, ghost, kb, kbh, pid, sx, sy, oy, ox, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);
  const start = el => { drag = el; home = [el.parentElement, el.nextSibling]; root.setAttribute('data-dd-active', ''); };
  const stop = () => { drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); over(null); commit(); drag = null; };

  // 1. Mouse: native drag and drop. dataTransfer also carries the item out to other apps as text and a link.
  root.addEventListener('dragstart', e => {
    const item = e.target.closest && e.target.closest('[data-dd-item]');
    if (!item || kb || ghost) return e.preventDefault();
    start(item);
    e.dataTransfer.effectAllowed = 'copyMove';
    e.dataTransfer.setData('text/plain', item.dataset.url ? name(item) + ' ' + item.dataset.url : name(item));
    if (item.dataset.url) e.dataTransfer.setData('text/uri-list', item.dataset.url);
    requestAnimationFrame(() => drag && drag.setAttribute('data-dragging', '')); // after the browser has taken its drag image
  });
  root.addEventListener('dragover', e => {
    const list = target(e.target, drag && home[0].dataset.ddList);
    if (drag && !list) return e.preventDefault(); // between lists: keep the item where it last landed
    if (!list || (!drag && !('ddAccept' in list.dataset))) return; // outside drops only where data-dd-accept allows them
    e.preventDefault(); e.dataTransfer.dropEffect = drag ? 'move' : 'copy'; over(list);
    if (drag) place(list, e.target, e.clientY);
  });
  root.addEventListener('dragleave', e => { if (!root.contains(e.relatedTarget)) over(null); });
  root.addEventListener('drop', e => {
    if (drag) { e.preventDefault(); return say(name(drag) + ' moved to ' + pos(drag) + '.'); }
    const list = target(e.target); if (!list || !('ddAccept' in list.dataset)) return;
    e.preventDefault();
    const url = (e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain')).split(/\s+/).find(s => /^https?:\/\//.test(s));
    over(null);
    if (!url) return say('Only links can be dropped here.');
    const detail = { list, url, name: '' }; root.dispatchEvent(new CustomEvent('dd:external', { detail, bubbles: true })); commit();
    say('Added ' + (detail.name || 'a link') + ' to ' + label(list) + '.');
  });
  root.addEventListener('dragend', e => { if (!drag) return; if (e.dataTransfer.dropEffect === 'none') { putBack(drag); say('Move cancelled.'); } stop(); });

  // 2. Touch and pen: pointer events from the handle, with a floating copy of the item under the finger.
  root.addEventListener('pointerdown', e => {
    const h = e.target.closest('[data-dd-handle]');
    if (e.pointerType === 'mouse' || !h || kb) return;
    pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null; start(h.closest('[data-dd-item]'));
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) {
      if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return;
      const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; root.setPointerCapture(pid);
      ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
      Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
      drag.after(ghost); drag.setAttribute('data-dragging', '');
    }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    const hit = document.elementFromPoint(e.clientX, e.clientY), list = target(hit, home[0].dataset.ddList);
    over(list); if (list) place(list, hit, e.clientY);
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) { ghost.remove(); ghost = null; if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.'); }
    stop();
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);

  // 3. Keyboard and screen readers: the handle is a toggle button (aria-pressed) that picks the item up.
  const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); over(null); kb = null; commit(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); over(kb.parentElement); commit(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h) return;
    const item = h.closest('[data-dd-item]');
    if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
    if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
    kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
    item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); over(item.parentElement); h.focus();
    say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
  });
  root.addEventListener('keydown', e => {
    if (!kb || e.target !== kbh) return;
    if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
    const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
    e.preventDefault();
    const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l);
    if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + label(l) + '.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    if (!all[i + d]) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, d > 0 ? all[i + d].nextSibling : all[i + d]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  commit();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-nv-muted uppercase">Native sortable reading list</span>
<section class="group/nv rounded-2xl border border-nv-line bg-white p-4 text-nv-ink shadow-[0_1px_2px_rgba(15,23,42,.05),0_12px_32px_-18px_rgba(15,23,42,.25)]" data-dd aria-labelledby="nv-read-t">
  <div class="mx-1 mt-0.5 mb-3 grid gap-0.5"><h2 class="font-bold tracking-[-.01em]" id="nv-read-t">Weekend reading</h2><p class="text-[.8rem] text-nv-muted" id="nv-hint1">Drag an article with the mouse or by its handle on touch screens, or focus a handle, press Space and use the arrow keys. Drag one out to another app to share its link.</p></div>
  <ol class="grid min-h-[58px] gap-1.5 rounded-xl p-1 outline-2 -outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/nv:outline-[#b8c2cf] data-over:bg-nv-tint data-over:outline-nv-accent! data-empty:after:grid data-empty:after:min-h-12 data-empty:after:place-items-center data-empty:after:text-[.8rem] data-empty:after:text-nv-muted data-empty:after:content-['Drop_links_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#7db4d8] *:data-dragging:bg-nv-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_16px_36px_-12px_rgba(15,23,42,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-nv-accent *:data-grabbed:shadow-[0_0_0_2px_#0369a1]" data-dd-list="reading" aria-label="Weekend reading">
    <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://inkwell.example/slower-software"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid min-w-0 flex-1"><b class="text-[.9rem] font-semibold" data-dd-name>The case for slower software</b><small class="text-[.76rem] text-nv-muted">inkwell.example · 8 min</small></span></li>
    <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://fieldnotes.example/calm-notifications"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid min-w-0 flex-1"><b class="text-[.9rem] font-semibold" data-dd-name>Designing calm notifications</b><small class="text-[.76rem] text-nv-muted">fieldnotes.example · 6 min</small></span></li>
    <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://gridline.example/anchor-positioning"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid min-w-0 flex-1"><b class="text-[.9rem] font-semibold" data-dd-name>A field guide to CSS anchor positioning</b><small class="text-[.76rem] text-nv-muted">gridline.example · 12 min</small></span></li>
    <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://northstar.example/writing-culture"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid min-w-0 flex-1"><b class="text-[.9rem] font-semibold" data-dd-name>Why your team needs a writing culture</b><small class="text-[.76rem] text-nv-muted">northstar.example · 9 min</small></span></li>
    <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://tabindex.example/keyboard-first"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint1"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid min-w-0 flex-1"><b class="text-[.9rem] font-semibold" data-dd-name>Notes on keyboard-first interfaces</b><small class="text-[.76rem] text-nv-muted">tabindex.example · 5 min</small></span></li>
  </ol>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>

<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-nv-muted uppercase">Link shelf that accepts links from other tabs</span>
<section class="group/nv rounded-2xl border border-nv-line bg-white p-4 text-nv-ink shadow-[0_1px_2px_rgba(15,23,42,.05),0_12px_32px_-18px_rgba(15,23,42,.25)]" data-dd aria-labelledby="nv-shelf-t">
  <div class="mx-1 mt-0.5 mb-3 grid gap-0.5"><h2 class="font-bold tracking-[-.01em]" id="nv-shelf-t">Project links</h2><p class="text-[.8rem] text-nv-muted" id="nv-hint2">Move links between Pinned and Saved, or drop a link from another tab or the address bar onto Saved. Left and Right switch lists from the keyboard.</p></div>
  <div class="grid grid-cols-2 gap-3 max-[560px]:grid-cols-1">
    <div data-dd-zone><h3 class="mx-1.5 mb-1.5 text-[.8rem] font-extrabold tracking-[.08em] text-nv-muted uppercase">Pinned · <span data-dd-count="nv-pinned">2</span></h3><ul class="grid min-h-[58px] gap-1.5 rounded-xl p-1 outline-2 -outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/nv:outline-[#b8c2cf] data-over:bg-nv-tint data-over:outline-nv-accent! data-empty:after:grid data-empty:after:min-h-12 data-empty:after:place-items-center data-empty:after:text-[.8rem] data-empty:after:text-nv-muted data-empty:after:content-['Drop_links_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#7db4d8] *:data-dragging:bg-nv-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_16px_36px_-12px_rgba(15,23,42,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-nv-accent *:data-grabbed:shadow-[0_0_0_2px_#0369a1]" id="nv-pinned" data-dd-list="links" aria-label="Pinned">
      <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://handbook.example/"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid size-7 flex-none place-items-center rounded-lg bg-nv-tint text-[.75rem] font-extrabold text-nv-accent" aria-hidden="true" data-letter>H</span><a class="min-w-0 flex-1 truncate rounded text-[.86rem] font-semibold text-nv-ink no-underline hover:text-nv-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" href="https://handbook.example/" draggable="false" data-dd-name>Team handbook</a></li>
      <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://boards.example/sprint-15"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid size-7 flex-none place-items-center rounded-lg bg-nv-tint text-[.75rem] font-extrabold text-nv-accent" aria-hidden="true" data-letter>S</span><a class="min-w-0 flex-1 truncate rounded text-[.86rem] font-semibold text-nv-ink no-underline hover:text-nv-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" href="https://boards.example/sprint-15" draggable="false" data-dd-name>Sprint board</a></li>
    </ul></div>
    <div data-dd-zone><h3 class="mx-1.5 mb-1.5 text-[.8rem] font-extrabold tracking-[.08em] text-nv-muted uppercase">Saved · <span data-dd-count="nv-saved">3</span></h3><ul class="grid min-h-[58px] gap-1.5 rounded-xl p-1 outline-2 -outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/nv:outline-[#b8c2cf] data-over:bg-nv-tint data-over:outline-nv-accent! data-empty:after:grid data-empty:after:min-h-12 data-empty:after:place-items-center data-empty:after:text-[.8rem] data-empty:after:text-nv-muted data-empty:after:content-['Drop_links_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#7db4d8] *:data-dragging:bg-nv-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_16px_36px_-12px_rgba(15,23,42,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-nv-accent *:data-grabbed:shadow-[0_0_0_2px_#0369a1]" id="nv-saved" data-dd-list="links" data-dd-accept aria-label="Saved">
      <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://tokens.example/brand"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid size-7 flex-none place-items-center rounded-lg bg-nv-tint text-[.75rem] font-extrabold text-nv-accent" aria-hidden="true" data-letter>B</span><a class="min-w-0 flex-1 truncate rounded text-[.86rem] font-semibold text-nv-ink no-underline hover:text-nv-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" href="https://tokens.example/brand" draggable="false" data-dd-name>Brand colour tokens</a></li>
      <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://docs.example/release"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid size-7 flex-none place-items-center rounded-lg bg-nv-tint text-[.75rem] font-extrabold text-nv-accent" aria-hidden="true" data-letter>R</span><a class="min-w-0 flex-1 truncate rounded text-[.86rem] font-semibold text-nv-ink no-underline hover:text-nv-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" href="https://docs.example/release" draggable="false" data-dd-name>Release checklist</a></li>
      <li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item data-url="https://research.example/interviews"><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid size-7 flex-none place-items-center rounded-lg bg-nv-tint text-[.75rem] font-extrabold text-nv-accent" aria-hidden="true" data-letter>C</span><a class="min-w-0 flex-1 truncate rounded text-[.86rem] font-semibold text-nv-ink no-underline hover:text-nv-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" href="https://research.example/interviews" draggable="false" data-dd-name>Customer interviews</a></li>
    </ul></div>
  </div>
  <template><li class="flex cursor-grab items-center gap-2.5 rounded-xl border border-nv-line bg-white py-2 pr-3 pl-1 transition-[opacity,translate] duration-200 starting:-translate-y-1.5 starting:opacity-0 motion-reduce:transition-none" draggable="true" data-dd-item><button class="grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f1f4f8] hover:text-nv-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" type="button" data-dd-handle aria-describedby="nv-hint2"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="grid size-7 flex-none place-items-center rounded-lg bg-nv-tint text-[.75rem] font-extrabold text-nv-accent" aria-hidden="true" data-letter></span><a class="min-w-0 flex-1 truncate rounded text-[.86rem] font-semibold text-nv-ink no-underline hover:text-nv-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nv-accent" href="#" draggable="false" data-dd-name></a></li></template>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
@theme {
  --color-nv-accent: #0369a1;
  --color-nv-tint: #e8f4fb;
  --color-nv-line: #e2e6ec;
  --color-nv-muted: #5d6679;
  --color-nv-ink: #0f172a;
}
JavaScript
// Link shelf: turn a link dropped from outside into a new item, built from the <template>.
document.addEventListener('dd:external', e => {
  const { list, url } = e.detail, u = new URL(url), host = u.hostname.replace(/^www\./, '');
  const li = e.target.querySelector('template').content.firstElementChild.cloneNode(true);
  e.detail.name = host + (u.pathname.length > 1 ? u.pathname.replace(/\/$/, '') : '');
  li.dataset.url = url; li.querySelector('a').href = url;
  li.querySelector('[data-dd-name]').textContent = e.detail.name;
  li.querySelector('[data-letter]').textContent = host[0].toUpperCase();
  list.prepend(li);
});
// No library: the native HTML Drag and Drop API (draggable="true", dragstart / dragover / drop, dataTransfer) for the mouse,
// pointer events for touch and pen (mobile browsers start native drags only after a long press, and not all of them),
// and a keyboard mode on every handle: Space or Enter picks up, arrow keys move, Space or Enter drops, Escape cancels.
document.querySelectorAll('[data-dd]').forEach(root => {
  const live = root.querySelector('[data-dd-live]');
  const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
  const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
  const name = el => el.querySelector('[data-dd-name]').textContent.trim();
  const label = l => l.getAttribute('aria-label');
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? label(l) + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const over = l => root.querySelectorAll('[data-dd-list]').forEach(x => x.toggleAttribute('data-over', x === l));
  const changed = () => {
    root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`); l.toggleAttribute('data-empty', !n); if (c) c.textContent = n; });
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
  };
  const commit = () => { changed(); root.dispatchEvent(new Event('dd:change', { bubbles: true })); }; // after every finished move
  const target = (hit, group) => { const sel = group ? `[data-dd-list="${group}"]` : '[data-dd-list]';
    const l = hit && hit.closest && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); return l && root.contains(l) ? l : null; };
  // Put the dragged item before or after the item under the pointer, or at the end of an empty list.
  const place = (list, hit, y) => {
    const o = items(list).find(c => c !== drag && c.contains(hit));
    if (o) { const r = o.getBoundingClientRect(), ref = y > r.top + r.height / 2 ? o.nextSibling : o; if (ref !== drag && ref !== drag.nextSibling) list.insertBefore(drag, ref); }
    else if (list !== drag.parentElement && !drag.contains(hit)) list.append(drag);
    changed();
  };
  let drag, home, ghost, kb, kbh, pid, sx, sy, oy, ox, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);
  const start = el => { drag = el; home = [el.parentElement, el.nextSibling]; root.setAttribute('data-dd-active', ''); };
  const stop = () => { drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); over(null); commit(); drag = null; };

  // 1. Mouse: native drag and drop. dataTransfer also carries the item out to other apps as text and a link.
  root.addEventListener('dragstart', e => {
    const item = e.target.closest && e.target.closest('[data-dd-item]');
    if (!item || kb || ghost) return e.preventDefault();
    start(item);
    e.dataTransfer.effectAllowed = 'copyMove';
    e.dataTransfer.setData('text/plain', item.dataset.url ? name(item) + ' ' + item.dataset.url : name(item));
    if (item.dataset.url) e.dataTransfer.setData('text/uri-list', item.dataset.url);
    requestAnimationFrame(() => drag && drag.setAttribute('data-dragging', '')); // after the browser has taken its drag image
  });
  root.addEventListener('dragover', e => {
    const list = target(e.target, drag && home[0].dataset.ddList);
    if (drag && !list) return e.preventDefault(); // between lists: keep the item where it last landed
    if (!list || (!drag && !('ddAccept' in list.dataset))) return; // outside drops only where data-dd-accept allows them
    e.preventDefault(); e.dataTransfer.dropEffect = drag ? 'move' : 'copy'; over(list);
    if (drag) place(list, e.target, e.clientY);
  });
  root.addEventListener('dragleave', e => { if (!root.contains(e.relatedTarget)) over(null); });
  root.addEventListener('drop', e => {
    if (drag) { e.preventDefault(); return say(name(drag) + ' moved to ' + pos(drag) + '.'); }
    const list = target(e.target); if (!list || !('ddAccept' in list.dataset)) return;
    e.preventDefault();
    const url = (e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain')).split(/\s+/).find(s => /^https?:\/\//.test(s));
    over(null);
    if (!url) return say('Only links can be dropped here.');
    const detail = { list, url, name: '' }; root.dispatchEvent(new CustomEvent('dd:external', { detail, bubbles: true })); commit();
    say('Added ' + (detail.name || 'a link') + ' to ' + label(list) + '.');
  });
  root.addEventListener('dragend', e => { if (!drag) return; if (e.dataTransfer.dropEffect === 'none') { putBack(drag); say('Move cancelled.'); } stop(); });

  // 2. Touch and pen: pointer events from the handle, with a floating copy of the item under the finger.
  root.addEventListener('pointerdown', e => {
    const h = e.target.closest('[data-dd-handle]');
    if (e.pointerType === 'mouse' || !h || kb) return;
    pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null; start(h.closest('[data-dd-item]'));
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) {
      if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return;
      const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; root.setPointerCapture(pid);
      ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
      Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
      drag.after(ghost); drag.setAttribute('data-dragging', '');
    }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    const hit = document.elementFromPoint(e.clientX, e.clientY), list = target(hit, home[0].dataset.ddList);
    over(list); if (list) place(list, hit, e.clientY);
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) { ghost.remove(); ghost = null; if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.'); }
    stop();
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);

  // 3. Keyboard and screen readers: the handle is a toggle button (aria-pressed) that picks the item up.
  const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); over(null); kb = null; commit(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); over(kb.parentElement); commit(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h) return;
    const item = h.closest('[data-dd-item]');
    if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
    if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
    kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
    item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); over(item.parentElement); h.focus();
    say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
  });
  root.addEventListener('keydown', e => {
    if (!kb || e.target !== kbh) return;
    if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
    const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
    e.preventDefault();
    const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l);
    if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + label(l) + '.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    if (!all[i + d]) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, d > 0 ? all[i + d].nextSibling : all[i + d]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  commit();
});

About this set

The no-library route, built on the browser’s own HTML Drag and Drop API, with notes on how it works. Native sortable reading list is a weekend reading list of five articles; items have draggable=”true”, and because dragstart writes the title and link into dataTransfer, an article can also be dragged out into an email, a document or another tab. Link shelf that accepts links from other tabs has Pinned and Saved lists; links move between them, and dropping a link from another tab or the address bar onto Saved adds it as a new item, read from text/uri-list and faded in with @starting-style. The native API covers the mouse, but mobile browsers start native drags only after a long press and not consistently, so the handle switches to pointer events for touch and pen, with touch-action: none so the page does not scroll. The keyboard mode is the same as in the other sets, a drop outside the component puts the item back, and the notes below the demo explain each part.

What’s included

  • HTML Drag and Drop API with dataTransfer
  • Items can be dragged out to other apps as links
  • Accepts links dropped in from other tabs
  • Pointer events fallback for touch and pen
  • Keyboard mode and live announcements as in every set

Accessibility

Every handle is a toggle button named Move plus the item name, with aria-pressed and the visible instructions joined through aria-describedby. Space or Enter picks the item up, the arrow keys move it, Space or Enter drops it and Escape puts it back, and each step is announced in an assertive aria-live region, for example "Designing calm notifications moved to position 2 of 5.". Links dropped in from another tab are announced as they are added. Handles show a visible focus outline, and the tilt of the dragged copy and any transitions switch off under prefers-reduced-motion.

Customise it

Change --nv-accent and --nv-tint on .nv; add data-dd-accept to a list to let it take links from outside, and edit the <template> for new items. Bootstrap uses .list-group rows; Tailwind uses the nv colours in @theme.