CSS code
Edit on CodePen ↗
StackBlitz ↗
HTML
Copy
<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
Copy
/* 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
Copy
// 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
Edit on CodePen ↗
StackBlitz ↗
Requires: Bootstrap 5.3.8 CSS
HTML
Copy
<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
Copy
/* 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
Copy
// 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
Edit on CodePen ↗
StackBlitz ↗
Requires: Tailwind CSS 4
HTML
Copy
<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
Copy
@theme {
--color-pg-accent: #0f766e;
--color-pg-tint: #e6f4f1;
--color-pg-line: #e4e7ec;
--color-pg-muted: #5d6679;
--color-pg-ink: #101828;
}
JavaScript
Copy
// 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();
});