Open demo

CSS code

HTML
<div><span class="lbl">Photo order with a cover image</span>
<section class="pg" data-dd="grid" aria-labelledby="pg-title">
  <div class="pg-head"><h2 id="pg-title">Arctic night shoot</h2><p id="pg-hint">Drag a photo, or focus its handle, press Space and use the arrow keys. The first photo is the cover.</p></div>
  <ul class="pg-grid" data-dd-list="photos">
    <li class="pg-tile" data-dd-item><img src="https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A person standing under a bright arc of the Milky Way" draggable="false"><button class="pg-handle" type="button" data-dd-handle aria-describedby="pg-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover">Cover</span><span class="pg-cap"><span class="pg-no" data-photo-no>1</span><span data-dd-name>Milky Way arc</span></span></li>
    <li class="pg-tile" data-dd-item><img src="https://images.unsplash.com/photo-1483347756197-71ef80e95f73?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Green northern lights above a silhouetted pine forest" draggable="false"><button class="pg-handle" type="button" data-dd-handle aria-describedby="pg-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover">Cover</span><span class="pg-cap"><span class="pg-no" data-photo-no>2</span><span data-dd-name>Aurora over pines</span></span></li>
    <li class="pg-tile" data-dd-item><img src="https://images.unsplash.com/photo-1534447677768-be436bb09401?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A small sailing boat on still water under a starry sky" draggable="false"><button class="pg-handle" type="button" data-dd-handle aria-describedby="pg-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover">Cover</span><span class="pg-cap"><span class="pg-no" data-photo-no>3</span><span data-dd-name>Boat at midnight</span></span></li>
    <li class="pg-tile" data-dd-item><img src="https://images.unsplash.com/photo-1470813740244-df37b8c1edcb?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Stars seen through a narrow gap between sandstone canyon walls" draggable="false"><button class="pg-handle" type="button" data-dd-handle aria-describedby="pg-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover">Cover</span><span class="pg-cap"><span class="pg-no" data-photo-no>4</span><span data-dd-name>Canyon skylight</span></span></li>
    <li class="pg-tile" data-dd-item><img src="https://images.unsplash.com/photo-1531366936337-7c912a4589a7?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Purple and green aurora above a snowy valley" draggable="false"><button class="pg-handle" type="button" data-dd-handle aria-describedby="pg-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover">Cover</span><span class="pg-cap"><span class="pg-no" data-photo-no>5</span><span data-dd-name>Valley aurora</span></span></li>
    <li class="pg-tile" data-dd-item><img src="https://images.unsplash.com/photo-1436891620584-47fd0e565afb?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A starry sky over a cone-shaped peak, reflected in a calm lake" draggable="false"><button class="pg-handle" type="button" data-dd-handle aria-describedby="pg-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover">Cover</span><span class="pg-cap"><span class="pg-no" data-photo-no>6</span><span data-dd-name>Peak reflection</span></span></li>
  </ul>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>

<div><span class="lbl">Dashboard widgets</span>
<section class="pg" data-dd="grid" aria-labelledby="wd-title">
  <div class="pg-head"><h2 id="wd-title">Your overview</h2><p id="wd-hint">Drag a widget by its header, or focus the handle and use Space and the arrow keys.</p></div>
  <ul class="wd-grid" data-dd-list="widgets">
    <li class="wd wd-wide" data-dd-item><div class="wd-top"><span data-dd-name>Revenue</span><button class="pg-handle" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val">$48,210</span><span class="wd-delta">+12.4% vs September</span><svg class="wd-chart wd-bars" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="" x="1.00%" y="62%" width="6.33%" height="38%" rx="2"/><rect class="" x="9.33%" y="48%" width="6.33%" height="52%" rx="2"/><rect class="" x="17.67%" y="56%" width="6.33%" height="44%" rx="2"/><rect class="" x="26.00%" y="40%" width="6.33%" height="60%" rx="2"/><rect class="" x="34.33%" y="52%" width="6.33%" height="48%" rx="2"/><rect class="" x="42.67%" y="34%" width="6.33%" height="66%" rx="2"/><rect class="" x="51.00%" y="42%" width="6.33%" height="58%" rx="2"/><rect class="" x="59.33%" y="28%" width="6.33%" height="72%" rx="2"/><rect class="" x="67.67%" y="36%" width="6.33%" height="64%" rx="2"/><rect class="" x="76.00%" y="20%" width="6.33%" height="80%" rx="2"/><rect class="" x="84.33%" y="30%" width="6.33%" height="70%" rx="2"/><rect class="" x="92.67%" y="8%" width="6.33%" height="92%" rx="2"/></svg></li>
    <li class="wd" data-dd-item><div class="wd-top"><span data-dd-name>Active users</span><button class="pg-handle" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val">8,432</span><span class="wd-delta">+3.1% this week</span><svg class="wd-chart" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,26 12,22 24,24 36,16 48,18 60,10 72,12 84,4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></li>
    <li class="wd" data-dd-item><div class="wd-top"><span data-dd-name>Churn</span><button class="pg-handle" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val">1.8%</span><span class="wd-delta">0.4 points lower</span><svg class="wd-chart" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,6 12,8 24,7 36,13 48,12 60,18 72,17 84,22" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></li>
    <li class="wd" data-dd-item><div class="wd-top"><span data-dd-name>Signups</span><button class="pg-handle" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val">1,204</span><span class="wd-delta">+8% this week</span><svg class="wd-chart" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,26 12,22 24,24 36,16 48,18 60,10 72,12 84,4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></li>
    <li class="wd" data-dd-item><div class="wd-top"><span data-dd-name>NPS</span><button class="pg-handle" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val">62</span><span class="wd-delta">Up 4 since Q2</span><svg class="wd-chart" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,16 12,12 24,18 36,14 48,16 60,10 72,14 84,9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></li>
    <li class="wd wd-wide" data-dd-item><div class="wd-top"><span data-dd-name>Support queue</span><button class="pg-handle" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val">14 open</span><span class="wd-delta warn">3 urgent · median first reply 42 min</span><span class="wd-queue" aria-hidden="true"><span class="" style="width:21%"></span><span class="" style="width:36%"></span><span class="" style="width:43%"></span></span></li>
  </ul>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
/* Grid reordering: the list is a CSS grid with data-dd="grid", so the script compares the pointer with each tile's
   horizontal middle and Up/Down keys jump a whole row. The first photo carries the Cover badge through :first-child. */
.pg{--pg-accent:#0f766e;--pg-tint:#e6f4f1;--pg-line:#e4e7ec;--pg-muted:#5d6679;--pg-ink:#101828;
  background:#fff;border:1px solid var(--pg-line);border-radius:18px;padding:18px;color:var(--pg-ink);box-shadow:0 1px 2px rgba(16,24,40,.05),0 10px 28px -16px rgba(16,24,40,.18)}
.pg-head{display:flex;align-items:baseline;justify-content:space-between;gap:6px 16px;flex-wrap:wrap;margin-bottom:14px}
.pg-head h2{font-size:1rem;letter-spacing:-.01em}
.pg-head p{font-size:.82rem;color:var(--pg-muted)}
.pg-grid{list-style:none;display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.pg-tile{position:relative;border:1px solid var(--pg-line);border-radius:14px;overflow:hidden;background:#fff;cursor:grab;user-select:none}
.pg-tile img{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;background:#d0d5dd}
.pg-cap{display:flex;align-items:center;gap:8px;padding:8px 10px;font-size:.82rem;font-weight:600}
.pg-no{display:grid;place-items:center;min-width:22px;height:22px;border-radius:6px;background:#f2f4f7;font-size:.72rem;font-weight:800;color:#344054;font-variant-numeric:tabular-nums}
.pg-cover{position:absolute;top:10px;right:10px;display:none;padding:3px 9px;border-radius:999px;background:var(--pg-accent);color:#fff;font-size:.7rem;font-weight:800;letter-spacing:.02em}
.pg-tile:first-child .pg-cover{display:block}
.pg-handle{position:absolute;top:8px;left:8px;display:grid;place-items:center;width:34px;height:34px;border:0;border-radius:50%;
  background:rgba(255,255,255,.94);color:#344054;box-shadow:0 2px 6px rgba(16,24,40,.25);cursor:grab;touch-action:none}
.pg-handle:hover{color:var(--pg-ink);background:#fff}
.pg-handle:focus-visible{outline:3px solid var(--pg-accent);outline-offset:2px}
.pg-handle svg{width:18px;height:18px}
.wd-grid{list-style:none;display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.wd{display:grid;align-content:start;gap:4px;padding:14px 16px 16px;border:1px solid var(--pg-line);border-radius:14px;background:#fff;cursor:grab;user-select:none}
.wd-wide{grid-column:span 2}
.wd-top{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.8rem;font-weight:600;color:var(--pg-muted)}
.wd .pg-handle{position:static;width:28px;height:28px;margin:-4px -6px -4px 0;border-radius:8px;background:none;box-shadow:none;color:#667085}
.wd .pg-handle:hover{background:#f2f4f7}
.wd-val{font-size:1.45rem;font-weight:800;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.wd-delta{font-size:.78rem;font-weight:600;color:#067647}
.wd-delta.warn{color:#b54708}
.wd-chart{display:block;width:100%;height:32px;margin-top:6px;color:var(--pg-accent)}
.wd-bars{height:56px}
.wd-bars rect{fill:var(--pg-accent);opacity:.85}
.wd-queue{display:flex;height:10px;margin-top:10px;border-radius:999px;overflow:hidden;background:#f2f4f7}
.wd-queue span{background:#b42318}.wd-queue span+span{background:#f79009}.wd-queue span+span+span{background:#98a2b3}
/* states set by the script */
.pg-tile[data-dragging],.wd[data-dragging]{border:2px dashed var(--pg-accent);background:var(--pg-tint)}
.pg-tile[data-dragging]>*,.wd[data-dragging]>*{visibility:hidden}
.pg-tile[data-dd-ghost],.wd[data-dd-ghost]{box-shadow:0 22px 44px -14px rgba(16,24,40,.45);rotate:-1.5deg;scale:1.03}
.pg-tile[data-grabbed],.wd[data-grabbed]{border-color:var(--pg-accent);box-shadow:0 0 0 3px var(--pg-accent),0 14px 28px -14px rgba(15,118,110,.6)}
.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:640px){.pg-grid{grid-template-columns:repeat(2,1fr)}.wd-grid{grid-template-columns:repeat(2,1fr)}}
@media (prefers-reduced-motion:reduce){.pg-tile[data-dd-ghost],.wd[data-dd-ghost]{rotate:none;scale:none}}
JavaScript
// Photo grid: number the photos in order so the count under each one stays right.
document.addEventListener('dd:change', e => {
  e.target.querySelectorAll('[data-photo-no]').forEach((n, i) => { n.textContent = i + 1; });
});
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
  const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', 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 full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
  const marks = () => 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); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
  const changed = () => {
    marks();
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
    root.dispatchEvent(new Event('dd:change', { bubbles: true }));
  };
  let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);

  root.addEventListener('pointerdown', e => {
    const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
    if (!item || e.button > 0 || kb) return;
    if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
    e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
    const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
    const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
    clearOver(list);
    if (!list || !root.contains(list) || drag.contains(list)) return;
    list.setAttribute('data-over', '');
    if (list !== drag.parentElement && full(list)) return;
    const over = items(list).find(c => c !== drag && c.contains(hit));
    if (over) { const r = over.getBoundingClientRect();
      const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
      if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
    } else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) {
      ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
      if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
      changed(); dragged = true; setTimeout(() => { dragged = false; });
    }
    drag = null;
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
  function start() {
    const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
    ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
    ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
    Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
    drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
  }

  // Keyboard and screen reader mode: 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'); clearOver(); kb = null; changed(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) 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', ''); item.parentElement.setAttribute('data-over', ''); 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), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
    if (side && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
      if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
    if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  changed();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Photo order with a cover image</span>
<section class="pg card" data-dd="grid" aria-labelledby="pg-title">
  <p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
  <div class="pg-head d-flex flex-wrap align-items-baseline justify-content-between column-gap-3 row-gap-1 mb-3"><h2 class="fw-bold mb-0" id="pg-title">Arctic night shoot</h2><p class="text-body-secondary mb-0" id="pg-hint">Drag a photo, or focus its handle, press Space and use the arrow keys. The first photo is the cover.</p></div>
  <ul class="pg-list row row-cols-2 row-cols-sm-3 list-unstyled mb-0" data-dd-list="photos">
    <li class="col" data-dd-item><div class="pg-tile card position-relative"><img class="card-img-top d-block w-100" src="https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A person standing under a bright arc of the Milky Way" draggable="false"><button class="pg-handle btn rounded-circle position-absolute d-grid" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover badge rounded-pill position-absolute">Cover</span><span class="pg-cap d-flex align-items-center gap-2 fw-semibold"><span class="pg-no rounded-2 d-grid align-content-center text-center fw-bolder" data-photo-no>1</span><span data-dd-name>Milky Way arc</span></span></div></li>
    <li class="col" data-dd-item><div class="pg-tile card position-relative"><img class="card-img-top d-block w-100" src="https://images.unsplash.com/photo-1483347756197-71ef80e95f73?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Green northern lights above a silhouetted pine forest" draggable="false"><button class="pg-handle btn rounded-circle position-absolute d-grid" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover badge rounded-pill position-absolute">Cover</span><span class="pg-cap d-flex align-items-center gap-2 fw-semibold"><span class="pg-no rounded-2 d-grid align-content-center text-center fw-bolder" data-photo-no>2</span><span data-dd-name>Aurora over pines</span></span></div></li>
    <li class="col" data-dd-item><div class="pg-tile card position-relative"><img class="card-img-top d-block w-100" src="https://images.unsplash.com/photo-1534447677768-be436bb09401?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A small sailing boat on still water under a starry sky" draggable="false"><button class="pg-handle btn rounded-circle position-absolute d-grid" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover badge rounded-pill position-absolute">Cover</span><span class="pg-cap d-flex align-items-center gap-2 fw-semibold"><span class="pg-no rounded-2 d-grid align-content-center text-center fw-bolder" data-photo-no>3</span><span data-dd-name>Boat at midnight</span></span></div></li>
    <li class="col" data-dd-item><div class="pg-tile card position-relative"><img class="card-img-top d-block w-100" src="https://images.unsplash.com/photo-1470813740244-df37b8c1edcb?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Stars seen through a narrow gap between sandstone canyon walls" draggable="false"><button class="pg-handle btn rounded-circle position-absolute d-grid" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover badge rounded-pill position-absolute">Cover</span><span class="pg-cap d-flex align-items-center gap-2 fw-semibold"><span class="pg-no rounded-2 d-grid align-content-center text-center fw-bolder" data-photo-no>4</span><span data-dd-name>Canyon skylight</span></span></div></li>
    <li class="col" data-dd-item><div class="pg-tile card position-relative"><img class="card-img-top d-block w-100" src="https://images.unsplash.com/photo-1531366936337-7c912a4589a7?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Purple and green aurora above a snowy valley" draggable="false"><button class="pg-handle btn rounded-circle position-absolute d-grid" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover badge rounded-pill position-absolute">Cover</span><span class="pg-cap d-flex align-items-center gap-2 fw-semibold"><span class="pg-no rounded-2 d-grid align-content-center text-center fw-bolder" data-photo-no>5</span><span data-dd-name>Valley aurora</span></span></div></li>
    <li class="col" data-dd-item><div class="pg-tile card position-relative"><img class="card-img-top d-block w-100" src="https://images.unsplash.com/photo-1436891620584-47fd0e565afb?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A starry sky over a cone-shaped peak, reflected in a calm lake" draggable="false"><button class="pg-handle btn rounded-circle position-absolute d-grid" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="pg-cover badge rounded-pill position-absolute">Cover</span><span class="pg-cap d-flex align-items-center gap-2 fw-semibold"><span class="pg-no rounded-2 d-grid align-content-center text-center fw-bolder" data-photo-no>6</span><span data-dd-name>Peak reflection</span></span></div></li>
  </ul>
</section></div>

<div><span class="lbl">Dashboard widgets</span>
<section class="pg card" data-dd="grid" aria-labelledby="wd-title">
  <p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
  <div class="pg-head d-flex flex-wrap align-items-baseline justify-content-between column-gap-3 row-gap-1 mb-3"><h2 class="fw-bold mb-0" id="wd-title">Your overview</h2><p class="text-body-secondary mb-0" id="wd-hint">Drag a widget by its header, or focus the handle and use Space and the arrow keys.</p></div>
  <ul class="pg-list row row-cols-2 row-cols-md-4 list-unstyled mb-0" data-dd-list="widgets">
    <li class="col-12 col-md-6" data-dd-item><div class="wd card d-grid align-content-start"><div class="wd-top d-flex align-items-center justify-content-between gap-2 fw-semibold text-body-secondary"><span data-dd-name>Revenue</span><button class="pg-handle btn d-grid" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val fw-bolder">$48,210</span><span class="wd-delta fw-semibold">+12.4% vs September</span><svg class="wd-chart wd-bars" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="" x="1.00%" y="62%" width="6.33%" height="38%" rx="2"/><rect class="" x="9.33%" y="48%" width="6.33%" height="52%" rx="2"/><rect class="" x="17.67%" y="56%" width="6.33%" height="44%" rx="2"/><rect class="" x="26.00%" y="40%" width="6.33%" height="60%" rx="2"/><rect class="" x="34.33%" y="52%" width="6.33%" height="48%" rx="2"/><rect class="" x="42.67%" y="34%" width="6.33%" height="66%" rx="2"/><rect class="" x="51.00%" y="42%" width="6.33%" height="58%" rx="2"/><rect class="" x="59.33%" y="28%" width="6.33%" height="72%" rx="2"/><rect class="" x="67.67%" y="36%" width="6.33%" height="64%" rx="2"/><rect class="" x="76.00%" y="20%" width="6.33%" height="80%" rx="2"/><rect class="" x="84.33%" y="30%" width="6.33%" height="70%" rx="2"/><rect class="" x="92.67%" y="8%" width="6.33%" height="92%" rx="2"/></svg></div></li>
    <li class="col" data-dd-item><div class="wd card d-grid align-content-start"><div class="wd-top d-flex align-items-center justify-content-between gap-2 fw-semibold text-body-secondary"><span data-dd-name>Active users</span><button class="pg-handle btn d-grid" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val fw-bolder">8,432</span><span class="wd-delta fw-semibold">+3.1% this week</span><svg class="wd-chart" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,26 12,22 24,24 36,16 48,18 60,10 72,12 84,4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div></li>
    <li class="col" data-dd-item><div class="wd card d-grid align-content-start"><div class="wd-top d-flex align-items-center justify-content-between gap-2 fw-semibold text-body-secondary"><span data-dd-name>Churn</span><button class="pg-handle btn d-grid" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val fw-bolder">1.8%</span><span class="wd-delta fw-semibold">0.4 points lower</span><svg class="wd-chart" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,6 12,8 24,7 36,13 48,12 60,18 72,17 84,22" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div></li>
    <li class="col" data-dd-item><div class="wd card d-grid align-content-start"><div class="wd-top d-flex align-items-center justify-content-between gap-2 fw-semibold text-body-secondary"><span data-dd-name>Signups</span><button class="pg-handle btn d-grid" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val fw-bolder">1,204</span><span class="wd-delta fw-semibold">+8% this week</span><svg class="wd-chart" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,26 12,22 24,24 36,16 48,18 60,10 72,12 84,4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div></li>
    <li class="col" data-dd-item><div class="wd card d-grid align-content-start"><div class="wd-top d-flex align-items-center justify-content-between gap-2 fw-semibold text-body-secondary"><span data-dd-name>NPS</span><button class="pg-handle btn d-grid" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val fw-bolder">62</span><span class="wd-delta fw-semibold">Up 4 since Q2</span><svg class="wd-chart" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,16 12,12 24,18 36,14 48,16 60,10 72,14 84,9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div></li>
    <li class="col-12 col-md-6" data-dd-item><div class="wd card d-grid align-content-start"><div class="wd-top d-flex align-items-center justify-content-between gap-2 fw-semibold text-body-secondary"><span data-dd-name>Support queue</span><button class="pg-handle btn d-grid" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="wd-val fw-bolder">14 open</span><span class="wd-delta fw-semibold warn">3 urgent · median first reply 42 min</span><div class="progress-stacked" aria-hidden="true"><div class="progress" style="width:21%"><div class="progress-bar" style="background:#b42318"></div></div><div class="progress" style="width:36%"><div class="progress-bar" style="background:#f79009"></div></div><div class="progress" style="width:43%"><div class="progress-bar" style="background:#98a2b3"></div></div></div></div></li>
  </ul>
</section></div>
CSS
/* Grid reordering for Bootstrap 5: tiles and widgets are .col items in a .row (row-cols-*, .col-md-6 for wide widgets),
   each holding a .card; data-dd="grid" makes the script read the pointer horizontally and move by rows with Up/Down. */
.pg{--bs-card-border-color:#e4e7ec;--bs-card-border-radius:18px;--pg-accent:#0f766e;--pg-tint:#e6f4f1;--bs-secondary-color:#5d6679;color:#101828;
  padding:18px;box-shadow:0 1px 2px rgba(16,24,40,.05),0 10px 28px -16px rgba(16,24,40,.18)}
.pg-head h2{font-size:1rem;letter-spacing:-.01em}
.pg-head p{font-size:.82rem}
.pg .row{--bs-gutter-x:12px;--bs-gutter-y:12px}
.pg-tile,.wd{--bs-card-border-color:#e4e7ec;--bs-card-border-radius:14px;height:100%;overflow:hidden;cursor:grab;user-select:none}
.pg-tile img{aspect-ratio:4/3;object-fit:cover;height:auto;background:#d0d5dd}
.pg-cap{font-size:.82rem;padding:8px 10px}
.pg-no{min-width:22px;height:22px;font-size:.72rem;color:#344054;background:#f2f4f7;font-variant-numeric:tabular-nums}
.pg-cover{top:10px;right:10px;display:none;--bs-badge-font-size:.7rem;--bs-badge-font-weight:800;background:var(--pg-accent)}
.pg-list>:first-child .pg-cover{display:inline-block}
.pg-handle{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-color:#344054;--bs-btn-bg:rgba(255,255,255,.94);--bs-btn-hover-bg:#fff;--bs-btn-hover-color:#101828;--bs-btn-active-bg:#fff;
  --bs-btn-border-width:0;top:8px;left:8px;width:34px;height:34px;box-shadow:0 2px 6px rgba(16,24,40,.25);cursor:grab;touch-action:none}
.pg-handle:focus-visible{outline:3px solid var(--pg-accent);outline-offset:2px;box-shadow:none}
.wd{padding:14px 16px 16px;gap:4px}
.wd-top{font-size:.8rem}
.wd .pg-handle{--bs-btn-bg:transparent;--bs-btn-color:#667085;--bs-btn-hover-bg:#f2f4f7;--bs-btn-border-radius:8px;width:28px;height:28px;margin:-4px -6px -4px 0;box-shadow:none}
.wd-val{font-size:1.45rem;letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.5}
.wd-delta{font-size:.78rem;color:#067647}
.wd-delta.warn{color:#b54708}
.wd-chart{display:block;width:100%;height:32px;margin-top:6px;color:var(--pg-accent)}
.wd-bars{height:56px}
.wd-bars rect{fill:var(--pg-accent);opacity:.85}
.wd .progress{--bs-progress-height:10px;--bs-progress-bg:#f2f4f7;margin-top:10px}
.pg-list>[data-dragging]>.card{border:2px dashed var(--pg-accent);background:var(--pg-tint)}
.pg-list>[data-dragging]>.card>*{visibility:hidden}
.pg-list>[data-dd-ghost]>.card{box-shadow:0 22px 44px -14px rgba(16,24,40,.45);rotate:-1.5deg;scale:1.03}
.pg-list>[data-grabbed]>.card{border-color:var(--pg-accent);box-shadow:0 0 0 3px var(--pg-accent),0 14px 28px -14px rgba(15,118,110,.6)}
@media (prefers-reduced-motion:reduce){.pg-list>[data-dd-ghost]>.card{rotate:none;scale:none}}
JavaScript
// Photo grid: number the photos in order so the count under each one stays right.
document.addEventListener('dd:change', e => {
  e.target.querySelectorAll('[data-photo-no]').forEach((n, i) => { n.textContent = i + 1; });
});
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
  const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', 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 full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
  const marks = () => 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); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
  const changed = () => {
    marks();
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
    root.dispatchEvent(new Event('dd:change', { bubbles: true }));
  };
  let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);

  root.addEventListener('pointerdown', e => {
    const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
    if (!item || e.button > 0 || kb) return;
    if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
    e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
    const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
    const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
    clearOver(list);
    if (!list || !root.contains(list) || drag.contains(list)) return;
    list.setAttribute('data-over', '');
    if (list !== drag.parentElement && full(list)) return;
    const over = items(list).find(c => c !== drag && c.contains(hit));
    if (over) { const r = over.getBoundingClientRect();
      const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
      if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
    } else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) {
      ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
      if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
      changed(); dragged = true; setTimeout(() => { dragged = false; });
    }
    drag = null;
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
  function start() {
    const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
    ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
    ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
    Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
    drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
  }

  // Keyboard and screen reader mode: 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'); clearOver(); kb = null; changed(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) 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', ''); item.parentElement.setAttribute('data-over', ''); 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), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
    if (side && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
      if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
    if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  changed();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-pg-muted uppercase">Photo order with a cover image</span>
<section class="rounded-[18px] border border-pg-line bg-white p-[18px] text-pg-ink shadow-[0_1px_2px_rgba(16,24,40,.05),0_10px_28px_-16px_rgba(16,24,40,.18)]" data-dd="grid" aria-labelledby="pg-title">
  <div class="mb-3.5 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1.5"><h2 class="font-bold tracking-[-.01em]" id="pg-title">Arctic night shoot</h2><p class="text-[.82rem] text-pg-muted" id="pg-hint">Drag a photo, or focus its handle, press Space and use the arrow keys. The first photo is the cover.</p></div>
  <ul class="grid grid-cols-3 gap-3 max-[640px]:grid-cols-2 *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-pg-accent *:data-dragging:bg-pg-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-[1.5deg] *:data-dd-ghost:scale-[1.03] *:data-dd-ghost:shadow-[0_22px_44px_-14px_rgba(16,24,40,.45)] motion-reduce:*:data-dd-ghost:rotate-0 motion-reduce:*:data-dd-ghost:scale-100 *:data-grabbed:border-pg-accent *:data-grabbed:shadow-[0_0_0_3px_#0f766e,0_14px_28px_-14px_rgba(15,118,110,.6)]" data-dd-list="photos">
    <li class="group relative cursor-grab overflow-hidden rounded-[14px] border border-pg-line bg-white select-none" data-dd-item><img class="block aspect-[4/3] h-auto w-full bg-[#d0d5dd] object-cover" src="https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A person standing under a bright arc of the Milky Way" draggable="false"><button class="absolute top-2 left-2 grid size-[34px] cursor-grab touch-none place-items-center rounded-full bg-white/95 text-[#344054] shadow-[0_2px_6px_rgba(16,24,40,.25)] hover:bg-white hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="absolute top-2.5 right-2.5 hidden rounded-full bg-pg-accent px-[9px] py-[3px] text-[.7rem] font-extrabold tracking-[.02em] text-white group-first:block">Cover</span><span class="flex items-center gap-2 px-2.5 py-2 text-[.82rem] font-semibold"><span class="grid h-[22px] min-w-[22px] place-items-center rounded-md bg-[#f2f4f7] text-[.72rem] font-extrabold text-[#344054] tabular-nums" data-photo-no>1</span><span data-dd-name>Milky Way arc</span></span></li>
    <li class="group relative cursor-grab overflow-hidden rounded-[14px] border border-pg-line bg-white select-none" data-dd-item><img class="block aspect-[4/3] h-auto w-full bg-[#d0d5dd] object-cover" src="https://images.unsplash.com/photo-1483347756197-71ef80e95f73?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Green northern lights above a silhouetted pine forest" draggable="false"><button class="absolute top-2 left-2 grid size-[34px] cursor-grab touch-none place-items-center rounded-full bg-white/95 text-[#344054] shadow-[0_2px_6px_rgba(16,24,40,.25)] hover:bg-white hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="absolute top-2.5 right-2.5 hidden rounded-full bg-pg-accent px-[9px] py-[3px] text-[.7rem] font-extrabold tracking-[.02em] text-white group-first:block">Cover</span><span class="flex items-center gap-2 px-2.5 py-2 text-[.82rem] font-semibold"><span class="grid h-[22px] min-w-[22px] place-items-center rounded-md bg-[#f2f4f7] text-[.72rem] font-extrabold text-[#344054] tabular-nums" data-photo-no>2</span><span data-dd-name>Aurora over pines</span></span></li>
    <li class="group relative cursor-grab overflow-hidden rounded-[14px] border border-pg-line bg-white select-none" data-dd-item><img class="block aspect-[4/3] h-auto w-full bg-[#d0d5dd] object-cover" src="https://images.unsplash.com/photo-1534447677768-be436bb09401?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A small sailing boat on still water under a starry sky" draggable="false"><button class="absolute top-2 left-2 grid size-[34px] cursor-grab touch-none place-items-center rounded-full bg-white/95 text-[#344054] shadow-[0_2px_6px_rgba(16,24,40,.25)] hover:bg-white hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="absolute top-2.5 right-2.5 hidden rounded-full bg-pg-accent px-[9px] py-[3px] text-[.7rem] font-extrabold tracking-[.02em] text-white group-first:block">Cover</span><span class="flex items-center gap-2 px-2.5 py-2 text-[.82rem] font-semibold"><span class="grid h-[22px] min-w-[22px] place-items-center rounded-md bg-[#f2f4f7] text-[.72rem] font-extrabold text-[#344054] tabular-nums" data-photo-no>3</span><span data-dd-name>Boat at midnight</span></span></li>
    <li class="group relative cursor-grab overflow-hidden rounded-[14px] border border-pg-line bg-white select-none" data-dd-item><img class="block aspect-[4/3] h-auto w-full bg-[#d0d5dd] object-cover" src="https://images.unsplash.com/photo-1470813740244-df37b8c1edcb?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Stars seen through a narrow gap between sandstone canyon walls" draggable="false"><button class="absolute top-2 left-2 grid size-[34px] cursor-grab touch-none place-items-center rounded-full bg-white/95 text-[#344054] shadow-[0_2px_6px_rgba(16,24,40,.25)] hover:bg-white hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="absolute top-2.5 right-2.5 hidden rounded-full bg-pg-accent px-[9px] py-[3px] text-[.7rem] font-extrabold tracking-[.02em] text-white group-first:block">Cover</span><span class="flex items-center gap-2 px-2.5 py-2 text-[.82rem] font-semibold"><span class="grid h-[22px] min-w-[22px] place-items-center rounded-md bg-[#f2f4f7] text-[.72rem] font-extrabold text-[#344054] tabular-nums" data-photo-no>4</span><span data-dd-name>Canyon skylight</span></span></li>
    <li class="group relative cursor-grab overflow-hidden rounded-[14px] border border-pg-line bg-white select-none" data-dd-item><img class="block aspect-[4/3] h-auto w-full bg-[#d0d5dd] object-cover" src="https://images.unsplash.com/photo-1531366936337-7c912a4589a7?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="Purple and green aurora above a snowy valley" draggable="false"><button class="absolute top-2 left-2 grid size-[34px] cursor-grab touch-none place-items-center rounded-full bg-white/95 text-[#344054] shadow-[0_2px_6px_rgba(16,24,40,.25)] hover:bg-white hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="absolute top-2.5 right-2.5 hidden rounded-full bg-pg-accent px-[9px] py-[3px] text-[.7rem] font-extrabold tracking-[.02em] text-white group-first:block">Cover</span><span class="flex items-center gap-2 px-2.5 py-2 text-[.82rem] font-semibold"><span class="grid h-[22px] min-w-[22px] place-items-center rounded-md bg-[#f2f4f7] text-[.72rem] font-extrabold text-[#344054] tabular-nums" data-photo-no>5</span><span data-dd-name>Valley aurora</span></span></li>
    <li class="group relative cursor-grab overflow-hidden rounded-[14px] border border-pg-line bg-white select-none" data-dd-item><img class="block aspect-[4/3] h-auto w-full bg-[#d0d5dd] object-cover" src="https://images.unsplash.com/photo-1436891620584-47fd0e565afb?auto=format&fit=crop&w=480&h=360&q=70" width="480" height="360" alt="A starry sky over a cone-shaped peak, reflected in a calm lake" draggable="false"><button class="absolute top-2 left-2 grid size-[34px] cursor-grab touch-none place-items-center rounded-full bg-white/95 text-[#344054] shadow-[0_2px_6px_rgba(16,24,40,.25)] hover:bg-white hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="pg-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="absolute top-2.5 right-2.5 hidden rounded-full bg-pg-accent px-[9px] py-[3px] text-[.7rem] font-extrabold tracking-[.02em] text-white group-first:block">Cover</span><span class="flex items-center gap-2 px-2.5 py-2 text-[.82rem] font-semibold"><span class="grid h-[22px] min-w-[22px] place-items-center rounded-md bg-[#f2f4f7] text-[.72rem] font-extrabold text-[#344054] tabular-nums" data-photo-no>6</span><span data-dd-name>Peak reflection</span></span></li>
  </ul>
  <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-pg-muted uppercase">Dashboard widgets</span>
<section class="rounded-[18px] border border-pg-line bg-white p-[18px] text-pg-ink shadow-[0_1px_2px_rgba(16,24,40,.05),0_10px_28px_-16px_rgba(16,24,40,.18)]" data-dd="grid" aria-labelledby="wd-title">
  <div class="mb-3.5 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1.5"><h2 class="font-bold tracking-[-.01em]" id="wd-title">Your overview</h2><p class="text-[.82rem] text-pg-muted" id="wd-hint">Drag a widget by its header, or focus the handle and use Space and the arrow keys.</p></div>
  <ul class="grid grid-cols-4 gap-3 max-[640px]:grid-cols-2 *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-pg-accent *:data-dragging:bg-pg-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-[1.5deg] *:data-dd-ghost:scale-[1.03] *:data-dd-ghost:shadow-[0_22px_44px_-14px_rgba(16,24,40,.45)] motion-reduce:*:data-dd-ghost:rotate-0 motion-reduce:*:data-dd-ghost:scale-100 *:data-grabbed:border-pg-accent *:data-grabbed:shadow-[0_0_0_3px_#0f766e,0_14px_28px_-14px_rgba(15,118,110,.6)]" data-dd-list="widgets">
    <li class="grid cursor-grab content-start gap-1 rounded-[14px] border border-pg-line bg-white px-4 pt-3.5 pb-4 select-none col-span-2" data-dd-item><div class="flex items-center justify-between gap-2 text-[.8rem] font-semibold text-pg-muted"><span data-dd-name>Revenue</span><button class="-my-1 -mr-1.5 grid size-7 cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="text-[1.45rem] font-extrabold tracking-[-.02em] tabular-nums">$48,210</span><span class="text-[.78rem] font-semibold text-[#067647]">+12.4% vs September</span><svg class="mt-1.5 block h-14 w-full" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="fill-pg-accent opacity-85" x="1.00%" y="62%" width="6.33%" height="38%" rx="2"/><rect class="fill-pg-accent opacity-85" x="9.33%" y="48%" width="6.33%" height="52%" rx="2"/><rect class="fill-pg-accent opacity-85" x="17.67%" y="56%" width="6.33%" height="44%" rx="2"/><rect class="fill-pg-accent opacity-85" x="26.00%" y="40%" width="6.33%" height="60%" rx="2"/><rect class="fill-pg-accent opacity-85" x="34.33%" y="52%" width="6.33%" height="48%" rx="2"/><rect class="fill-pg-accent opacity-85" x="42.67%" y="34%" width="6.33%" height="66%" rx="2"/><rect class="fill-pg-accent opacity-85" x="51.00%" y="42%" width="6.33%" height="58%" rx="2"/><rect class="fill-pg-accent opacity-85" x="59.33%" y="28%" width="6.33%" height="72%" rx="2"/><rect class="fill-pg-accent opacity-85" x="67.67%" y="36%" width="6.33%" height="64%" rx="2"/><rect class="fill-pg-accent opacity-85" x="76.00%" y="20%" width="6.33%" height="80%" rx="2"/><rect class="fill-pg-accent opacity-85" x="84.33%" y="30%" width="6.33%" height="70%" rx="2"/><rect class="fill-pg-accent opacity-85" x="92.67%" y="8%" width="6.33%" height="92%" rx="2"/></svg></li>
    <li class="grid cursor-grab content-start gap-1 rounded-[14px] border border-pg-line bg-white px-4 pt-3.5 pb-4 select-none" data-dd-item><div class="flex items-center justify-between gap-2 text-[.8rem] font-semibold text-pg-muted"><span data-dd-name>Active users</span><button class="-my-1 -mr-1.5 grid size-7 cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="text-[1.45rem] font-extrabold tracking-[-.02em] tabular-nums">8,432</span><span class="text-[.78rem] font-semibold text-[#067647]">+3.1% this week</span><svg class="mt-1.5 block h-8 w-full text-pg-accent" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,26 12,22 24,24 36,16 48,18 60,10 72,12 84,4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></li>
    <li class="grid cursor-grab content-start gap-1 rounded-[14px] border border-pg-line bg-white px-4 pt-3.5 pb-4 select-none" data-dd-item><div class="flex items-center justify-between gap-2 text-[.8rem] font-semibold text-pg-muted"><span data-dd-name>Churn</span><button class="-my-1 -mr-1.5 grid size-7 cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="text-[1.45rem] font-extrabold tracking-[-.02em] tabular-nums">1.8%</span><span class="text-[.78rem] font-semibold text-[#067647]">0.4 points lower</span><svg class="mt-1.5 block h-8 w-full text-pg-accent" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,6 12,8 24,7 36,13 48,12 60,18 72,17 84,22" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></li>
    <li class="grid cursor-grab content-start gap-1 rounded-[14px] border border-pg-line bg-white px-4 pt-3.5 pb-4 select-none" data-dd-item><div class="flex items-center justify-between gap-2 text-[.8rem] font-semibold text-pg-muted"><span data-dd-name>Signups</span><button class="-my-1 -mr-1.5 grid size-7 cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="text-[1.45rem] font-extrabold tracking-[-.02em] tabular-nums">1,204</span><span class="text-[.78rem] font-semibold text-[#067647]">+8% this week</span><svg class="mt-1.5 block h-8 w-full text-pg-accent" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,26 12,22 24,24 36,16 48,18 60,10 72,12 84,4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></li>
    <li class="grid cursor-grab content-start gap-1 rounded-[14px] border border-pg-line bg-white px-4 pt-3.5 pb-4 select-none" data-dd-item><div class="flex items-center justify-between gap-2 text-[.8rem] font-semibold text-pg-muted"><span data-dd-name>NPS</span><button class="-my-1 -mr-1.5 grid size-7 cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="text-[1.45rem] font-extrabold tracking-[-.02em] tabular-nums">62</span><span class="text-[.78rem] font-semibold text-[#067647]">Up 4 since Q2</span><svg class="mt-1.5 block h-8 w-full text-pg-accent" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="0,16 12,12 24,18 36,14 48,16 60,10 72,14 84,9" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></li>
    <li class="grid cursor-grab content-start gap-1 rounded-[14px] border border-pg-line bg-white px-4 pt-3.5 pb-4 select-none col-span-2" data-dd-item><div class="flex items-center justify-between gap-2 text-[.8rem] font-semibold text-pg-muted"><span data-dd-name>Support queue</span><button class="-my-1 -mr-1.5 grid size-7 cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-pg-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-pg-accent" type="button" data-dd-handle aria-describedby="wd-hint"><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></div><span class="text-[1.45rem] font-extrabold tracking-[-.02em] tabular-nums">14 open</span><span class="text-[.78rem] font-semibold text-[#b54708]">3 urgent · median first reply 42 min</span><span class="mt-2.5 flex h-2.5 overflow-hidden rounded-full bg-[#f2f4f7]" aria-hidden="true"><span class="w-[21%] bg-[#b42318]"></span><span class="w-[36%] bg-[#f79009]"></span><span class="w-[43%] bg-[#98a2b3]"></span></span></li>
  </ul>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
@theme {
  --color-pg-accent: #0f766e;
  --color-pg-tint: #e6f4f1;
  --color-pg-line: #e4e7ec;
  --color-pg-muted: #5d6679;
  --color-pg-ink: #101828;
}
JavaScript
// Photo grid: number the photos in order so the count under each one stays right.
document.addEventListener('dd:change', e => {
  e.target.querySelectorAll('[data-photo-no]').forEach((n, i) => { n.textContent = i + 1; });
});
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
  const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', 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 full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
  const marks = () => 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); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
  const changed = () => {
    marks();
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
    root.dispatchEvent(new Event('dd:change', { bubbles: true }));
  };
  let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);

  root.addEventListener('pointerdown', e => {
    const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
    if (!item || e.button > 0 || kb) return;
    if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
    e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
    const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
    const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
    clearOver(list);
    if (!list || !root.contains(list) || drag.contains(list)) return;
    list.setAttribute('data-over', '');
    if (list !== drag.parentElement && full(list)) return;
    const over = items(list).find(c => c !== drag && c.contains(hit));
    if (over) { const r = over.getBoundingClientRect();
      const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
      if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
    } else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) {
      ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
      if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
      changed(); dragged = true; setTimeout(() => { dragged = false; });
    }
    drag = null;
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
  function start() {
    const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
    ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
    ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
    Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
    drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
  }

  // Keyboard and screen reader mode: 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'); clearOver(); kb = null; changed(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) 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', ''); item.parentElement.setAttribute('data-over', ''); 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), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
    if (side && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
      if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
    if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  changed();
});

About this set

Drag and drop for grids instead of lists. Photo order with a cover image is a three-column photo grid, two columns on phones, where the first photo carries a Cover badge and every tile shows its number; drag a photo to a new place and the badge and numbers follow. Dashboard widgets is a four-column overview with wide and narrow cards for revenue, active users, churn, signups, NPS and a support queue, each with a small chart; drag a widget by its header to rearrange the dashboard. Use the photo grid for listing, product and album uploads, and the widget grid for user-customisable dashboards. The list is a CSS grid marked data-dd=”grid”, which tells the script to compare the pointer with the horizontal middle of the tile under it, so items flow along rows as you drag. With the keyboard, Left and Right move one place and Up and Down jump a whole row, worked out from the tiles that share the first row. The Cover badge comes from :first-child, so no extra code keeps it on the right photo.

What’s included

  • Grid-aware dragging that follows rows
  • Up and Down keys jump a whole row
  • Cover badge on whichever photo is first
  • Wide and narrow dashboard widgets with inline SVG charts
  • Two columns on phones, three or four on wider screens

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 "Aurora over pines moved to position 4 of 6.". 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 --pg-accent and --pg-tint on .pg, and the grid-template-columns of .pg-grid or .wd-grid for the number of columns; .wd-wide makes a widget span two. Bootstrap uses row-cols-* and .col-md-6; Tailwind uses grid-cols-* and col-span-2.