Open demo

CSS code

HTML
<div><span class="lbl">Task list with handles</span>
<section class="sl" data-dd aria-labelledby="sl-tasks-title">
  <div class="sl-head"><h2 id="sl-tasks-title">Launch checklist</h2><p id="sl-tasks-hint">Drag to reorder, or focus a handle and press Space</p></div>
  <ul class="sl-list" data-dd-list="tasks">
    <li class="sl-row" data-dd-item><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="sl-check" type="checkbox" aria-labelledby="sl-t1" checked><span class="sl-text"><span class="sl-name" id="sl-t1" data-dd-name>Write the launch brief</span><span class="sl-meta">Due Monday · Priya</span></span><span class="sl-tag t-copy">Copy</span></li>
    <li class="sl-row" data-dd-item><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="sl-check" type="checkbox" aria-labelledby="sl-t2"><span class="sl-text"><span class="sl-name" id="sl-t2" data-dd-name>Review the pricing page</span><span class="sl-meta">Due Tuesday · Marco</span></span><span class="sl-tag t-design">Design</span></li>
    <li class="sl-row" data-dd-item><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="sl-check" type="checkbox" aria-labelledby="sl-t3"><span class="sl-text"><span class="sl-name" id="sl-t3" data-dd-name>Record the product demo</span><span class="sl-meta">Due Wednesday · Lena</span></span><span class="sl-tag t-video">Video</span></li>
    <li class="sl-row" data-dd-item><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="sl-check" type="checkbox" aria-labelledby="sl-t4"><span class="sl-text"><span class="sl-name" id="sl-t4" data-dd-name>Schedule the announcement email</span><span class="sl-meta">Due Thursday · Priya</span></span><span class="sl-tag t-email">Email</span></li>
    <li class="sl-row" data-dd-item><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="sl-check" type="checkbox" aria-labelledby="sl-t5"><span class="sl-text"><span class="sl-name" id="sl-t5" data-dd-name>Brief the support team</span><span class="sl-meta">Due Friday · Tomás</span></span><span class="sl-tag">Support</span></li>
  </ul>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>

<div><span class="lbl">Agenda with running times</span>
<section class="sl" data-dd data-agenda data-start="600" aria-labelledby="sl-agenda-title">
  <div class="sl-head"><h2 id="sl-agenda-title">Design review, Thursday</h2><p id="sl-agenda-hint">Start times update as you reorder</p></div>
  <ol class="sl-list" data-dd-list="agenda">
    <li class="sl-row" data-dd-item data-min="5"><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time" data-time>10:00</span><span class="sl-text"><span class="sl-name" data-dd-name>Welcome and goals</span><span class="sl-meta">5 min</span></span><span class="sl-who" aria-label="Hannah Ito" role="img">HI</span></li>
    <li class="sl-row" data-dd-item data-min="20"><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time" data-time>10:05</span><span class="sl-text"><span class="sl-name" data-dd-name>Checkout redesign walkthrough</span><span class="sl-meta">20 min</span></span><span class="sl-who" aria-label="Omar Haddad" role="img">OH</span></li>
    <li class="sl-row" data-dd-item data-min="15"><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time" data-time>10:25</span><span class="sl-text"><span class="sl-name" data-dd-name>Mobile navigation prototype</span><span class="sl-meta">15 min</span></span><span class="sl-who" aria-label="Jo Kim" role="img">JK</span></li>
    <li class="sl-row" data-dd-item data-min="15"><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time" data-time>10:40</span><span class="sl-text"><span class="sl-name" data-dd-name>Accessibility audit findings</span><span class="sl-meta">15 min</span></span><span class="sl-who" aria-label="Sara Lund" role="img">SL</span></li>
    <li class="sl-row" data-dd-item data-min="10"><button class="sl-handle" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time" data-time>10:55</span><span class="sl-text"><span class="sl-name" data-dd-name>Open questions</span><span class="sl-meta">10 min</span></span><span class="sl-who" aria-label="Hannah Ito" role="img">HI</span></li>
  </ol>
  <div class="sl-foot"><span>5 items · 65 min</span><span>Ends at <strong data-end>11:05</strong></span></div>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
/* Sortable lists: rows are <li data-dd-item>; the script moves the real row and floats a copy under the pointer. */
.sl{--sl-accent:#4f46e5;--sl-tint:#eef0ff;--sl-line:#e4e7ec;--sl-muted:#5d6679;--sl-ink:#101828;
  background:#fff;border:1px solid var(--sl-line);border-radius:16px;box-shadow:0 1px 2px rgba(16,24,40,.05),0 10px 28px -16px rgba(16,24,40,.18);color:var(--sl-ink)}
.sl-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:18px 20px 12px}
.sl-head h2{font-size:1rem;font-weight:700;letter-spacing:-.01em}
.sl-head p,.sl-hint{font-size:.82rem;color:var(--sl-muted)}
.sl-list{list-style:none;padding:0 10px}
.sl-row{display:flex;align-items:center;gap:12px;padding:11px 10px;border-top:1px solid var(--sl-line);background:#fff;cursor:grab;user-select:none}
.sl-row:first-child{border-top-color:transparent}
.sl-handle{flex:none;display:grid;place-items:center;width:30px;height:34px;margin-left:-4px;border:0;border-radius:8px;background:none;color:#667085;cursor:grab;touch-action:none}
.sl-handle:hover{background:#f2f4f7;color:var(--sl-ink)}
.sl-handle:focus-visible{outline:2px solid var(--sl-accent);outline-offset:1px}
.sl-handle svg{width:18px;height:18px}
.sl-check{flex:none;width:18px;height:18px;accent-color:var(--sl-accent);cursor:pointer}
.sl-check:focus-visible{outline:2px solid var(--sl-accent);outline-offset:2px}
.sl-text{flex:1;min-width:0;display:grid}
.sl-name{font-size:.93rem;font-weight:600}
.sl-meta{font-size:.8rem;color:var(--sl-muted)}
.sl-check:checked ~ .sl-text .sl-name{text-decoration:line-through;color:var(--sl-muted)}
.sl-tag{flex:none;font-size:.72rem;font-weight:700;padding:3px 9px;border-radius:999px;background:#f2f4f7;color:#344054}
.sl-tag.t-copy{background:#fdf2fa;color:#9e165f}.sl-tag.t-design{background:#eef4ff;color:#3538cd}
.sl-tag.t-video{background:#fff6ed;color:#b93815}.sl-tag.t-email{background:#ecfdf3;color:#067647}
.sl-time{flex:none;width:3.4rem;font-variant-numeric:tabular-nums;font-weight:700;font-size:.9rem;color:var(--sl-accent)}
.sl-who{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;font-size:.7rem;font-weight:800;background:#eef0ff;color:#3730a3}
.sl-foot{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 20px 16px;border-top:1px solid var(--sl-line);font-size:.82rem;color:var(--sl-muted)}
.sl-foot strong{color:var(--sl-ink)}
/* states set by the script */
.sl-row[data-dragging]{background:var(--sl-tint);border-radius:12px;outline:2px dashed #a5b4fc;outline-offset:-2px}
.sl-row[data-dragging]>*{visibility:hidden}
.sl-row[data-dd-ghost]{border:1px solid var(--sl-line);border-radius:12px;box-shadow:0 18px 40px -12px rgba(16,24,40,.35);rotate:.8deg;scale:1.02;transition:scale .15s}
.sl-row[data-grabbed]{position:relative;border-radius:12px;border-top-color:transparent;background:var(--sl-tint);box-shadow:0 0 0 2px var(--sl-accent),0 10px 24px -12px rgba(79,70,229,.5)}
.sl-row[data-grabbed] + .sl-row{border-top-color:transparent}
.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:480px){.sl-tag{display:none}}
@media (prefers-reduced-motion:reduce){.sl-row[data-dd-ghost]{transition:none;rotate:none;scale:none}}
JavaScript
// Agenda: rewrite each start time and the end time whenever the order changes.
document.addEventListener('dd:change', e => {
  const root = e.target; if (!root.matches('[data-agenda]')) return;
  let t = +root.dataset.start; const hhmm = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
  root.querySelectorAll('[data-dd-item]').forEach(li => { li.querySelector('[data-time]').textContent = hhmm(t); t += +li.dataset.min; });
  root.querySelector('[data-end]').textContent = hhmm(t);
});
// 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">Task list with handles</span>
<section class="sl card" data-dd aria-labelledby="sl-tasks-title">
  <p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
  <div class="card-header d-flex align-items-baseline justify-content-between gap-3"><h2 class="h6 fw-bold mb-0" id="sl-tasks-title">Launch checklist</h2><p class="small text-body-secondary mb-0" id="sl-tasks-hint">Drag to reorder, or focus a handle and press Space</p></div>
  <ul class="sl-list list-group list-group-flush" data-dd-list="tasks">
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="form-check-input flex-shrink-0" type="checkbox" aria-labelledby="sl-t1" checked><div class="flex-grow-1 min-w-0 d-grid"><span class="sl-name fw-semibold" id="sl-t1" data-dd-name>Write the launch brief</span><span class="small text-body-secondary">Due Monday · Priya</span></div><span class="badge rounded-pill t-copy">Copy</span></li>
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="form-check-input flex-shrink-0" type="checkbox" aria-labelledby="sl-t2"><div class="flex-grow-1 min-w-0 d-grid"><span class="sl-name fw-semibold" id="sl-t2" data-dd-name>Review the pricing page</span><span class="small text-body-secondary">Due Tuesday · Marco</span></div><span class="badge rounded-pill t-design">Design</span></li>
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="form-check-input flex-shrink-0" type="checkbox" aria-labelledby="sl-t3"><div class="flex-grow-1 min-w-0 d-grid"><span class="sl-name fw-semibold" id="sl-t3" data-dd-name>Record the product demo</span><span class="small text-body-secondary">Due Wednesday · Lena</span></div><span class="badge rounded-pill t-video">Video</span></li>
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="form-check-input flex-shrink-0" type="checkbox" aria-labelledby="sl-t4"><div class="flex-grow-1 min-w-0 d-grid"><span class="sl-name fw-semibold" id="sl-t4" data-dd-name>Schedule the announcement email</span><span class="small text-body-secondary">Due Thursday · Priya</span></div><span class="badge rounded-pill t-email">Email</span></li>
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="form-check-input flex-shrink-0" type="checkbox" aria-labelledby="sl-t5"><div class="flex-grow-1 min-w-0 d-grid"><span class="sl-name fw-semibold" id="sl-t5" data-dd-name>Brief the support team</span><span class="small text-body-secondary">Due Friday · Tomás</span></div><span class="badge rounded-pill t-support">Support</span></li>
  </ul>
</section></div>

<div><span class="lbl">Agenda with running times</span>
<section class="sl card" data-dd data-agenda data-start="600" aria-labelledby="sl-agenda-title">
  <p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
  <div class="card-header d-flex align-items-baseline justify-content-between gap-3"><h2 class="h6 fw-bold mb-0" id="sl-agenda-title">Design review, Thursday</h2><p class="small text-body-secondary mb-0" id="sl-agenda-hint">Start times update as you reorder</p></div>
  <ol class="sl-list list-group list-group-flush" data-dd-list="agenda">
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item data-min="5"><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time fw-bold flex-shrink-0" data-time>10:00</span><div class="flex-grow-1 min-w-0 d-grid"><span class="fw-semibold" data-dd-name>Welcome and goals</span><span class="small text-body-secondary">5 min</span></div><span class="sl-who rounded-circle d-grid flex-shrink-0 align-content-center text-center" role="img" aria-label="Hannah Ito">HI</span></li>
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item data-min="20"><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time fw-bold flex-shrink-0" data-time>10:05</span><div class="flex-grow-1 min-w-0 d-grid"><span class="fw-semibold" data-dd-name>Checkout redesign walkthrough</span><span class="small text-body-secondary">20 min</span></div><span class="sl-who rounded-circle d-grid flex-shrink-0 align-content-center text-center" role="img" aria-label="Omar Haddad">OH</span></li>
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item data-min="15"><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time fw-bold flex-shrink-0" data-time>10:25</span><div class="flex-grow-1 min-w-0 d-grid"><span class="fw-semibold" data-dd-name>Mobile navigation prototype</span><span class="small text-body-secondary">15 min</span></div><span class="sl-who rounded-circle d-grid flex-shrink-0 align-content-center text-center" role="img" aria-label="Jo Kim">JK</span></li>
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item data-min="15"><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time fw-bold flex-shrink-0" data-time>10:40</span><div class="flex-grow-1 min-w-0 d-grid"><span class="fw-semibold" data-dd-name>Accessibility audit findings</span><span class="small text-body-secondary">15 min</span></div><span class="sl-who rounded-circle d-grid flex-shrink-0 align-content-center text-center" role="img" aria-label="Sara Lund">SL</span></li>
    <li class="list-group-item d-flex align-items-center gap-3" data-dd-item data-min="10"><button class="sl-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="m-auto" width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="sl-time fw-bold flex-shrink-0" data-time>10:55</span><div class="flex-grow-1 min-w-0 d-grid"><span class="fw-semibold" data-dd-name>Open questions</span><span class="small text-body-secondary">10 min</span></div><span class="sl-who rounded-circle d-grid flex-shrink-0 align-content-center text-center" role="img" aria-label="Hannah Ito">HI</span></li>
  </ol>
  <div class="sl-foot card-footer bg-transparent d-flex justify-content-between flex-wrap gap-3 small text-body-secondary"><span>5 items · 65 min</span><span>Ends at <strong class="text-body" data-end>11:05</strong></span></div>
</section></div>
CSS
/* Sortable lists for Bootstrap 5: .card + .list-group-flush rows, .form-check-input and .badge;
   these rules add the handle and the states the script sets (data-dragging, data-dd-ghost, data-grabbed). */
.sl{--bs-primary:#4f46e5;--bs-primary-rgb:79,70,229;--sl-tint:#eef0ff;--bs-border-color:#e4e7ec;--bs-secondary-color:#5d6679;
  --bs-card-border-radius:16px;--bs-card-cap-bg:transparent;color:#101828;box-shadow:0 1px 2px rgba(16,24,40,.05),0 10px 28px -16px rgba(16,24,40,.18)}
.sl .card-header{padding:18px 20px 12px;border:0}
.sl .list-group{--bs-list-group-border-color:#e4e7ec;--bs-list-group-item-padding-x:10px;--bs-list-group-item-padding-y:11px;padding:0 10px}
.sl .list-group-item{cursor:grab;user-select:none}
.sl .list-group-item .fw-semibold{font-size:.93rem}.sl .list-group-item .small{font-size:.8rem}.sl .card-header .small,.sl .sl-foot{font-size:.82rem}.sl-time{font-size:.9rem}
.sl .list-group-item:first-child{border-top:0}
.sl-handle{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-color:#667085;--bs-btn-hover-bg:#f2f4f7;--bs-btn-hover-color:#101828;--bs-btn-active-bg:#f2f4f7;
  --bs-btn-border-width:0;--bs-btn-border-radius:8px;width:30px;height:34px;margin-left:-4px;cursor:grab;touch-action:none}
.sl-handle:focus-visible{outline:2px solid #4f46e5;outline-offset:1px;box-shadow:none}
.sl .form-check-input{width:18px;height:18px;margin:0;border-color:#8c95a8;cursor:pointer}
.sl .form-check-input:checked{background-color:#4f46e5;border-color:#4f46e5}
.sl .form-check-input:focus-visible{outline:2px solid #4f46e5;outline-offset:2px;box-shadow:none}
.sl .form-check-input:checked ~ div .sl-name{text-decoration:line-through;color:#5d6679}
.sl .badge{font-size:.72rem;font-weight:700;padding:5px 9px;background:#f2f4f7;color:#344054}
.sl .t-copy{background:#fdf2fa;color:#9e165f}.sl .t-design{background:#eef4ff;color:#3538cd}.sl .t-video{background:#fff6ed;color:#b93815}.sl .t-email{background:#ecfdf3;color:#067647}
.sl .sl-foot{padding:12px 20px 16px}
.sl-time{width:3.4rem;font-variant-numeric:tabular-nums;color:#4f46e5}
.sl-who{width:30px;height:30px;font-size:.7rem;font-weight:800;background:#eef0ff;color:#3730a3}
.sl-list[data-dd-list] .list-group-item[data-dragging]{background:var(--sl-tint);border-radius:12px;outline:2px dashed #a5b4fc;outline-offset:-2px}
.sl .list-group-item[data-dragging]>*{visibility:hidden}
.sl .list-group-item[data-dd-ghost]{border:1px solid #e4e7ec;border-radius:12px;box-shadow:0 18px 40px -12px rgba(16,24,40,.35);rotate:.8deg;scale:1.02;transition:scale .15s}
.sl .list-group-item[data-grabbed]{z-index:2;border-radius:12px;border-color:transparent;background:var(--sl-tint);box-shadow:0 0 0 2px #4f46e5,0 10px 24px -12px rgba(79,70,229,.5)}
.sl .list-group-item[data-grabbed] + .list-group-item{border-top-color:transparent}
@media (max-width:480px){.sl .badge{display:none}}
@media (prefers-reduced-motion:reduce){.sl .list-group-item[data-dd-ghost]{transition:none;rotate:none;scale:none}}
JavaScript
// Agenda: rewrite each start time and the end time whenever the order changes.
document.addEventListener('dd:change', e => {
  const root = e.target; if (!root.matches('[data-agenda]')) return;
  let t = +root.dataset.start; const hhmm = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
  root.querySelectorAll('[data-dd-item]').forEach(li => { li.querySelector('[data-time]').textContent = hhmm(t); t += +li.dataset.min; });
  root.querySelector('[data-end]').textContent = hhmm(t);
});
// 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-sl-muted uppercase">Task list with handles</span>
<section class="rounded-2xl border border-sl-line bg-white shadow-[0_1px_2px_rgba(16,24,40,.05),0_10px_28px_-16px_rgba(16,24,40,.18)]" data-dd aria-labelledby="sl-tasks-title">
  <div class="flex items-baseline justify-between gap-3 px-5 pt-[18px] pb-3"><h2 class="font-bold tracking-[-.01em]" id="sl-tasks-title">Launch checklist</h2><p class="text-[.82rem] text-sl-muted" id="sl-tasks-hint">Drag to reorder, or focus a handle and press Space</p></div>
  <ul class="px-2.5 *:data-dragging:rounded-xl *:data-dragging:bg-sl-tint *:data-dragging:outline-2 *:data-dragging:-outline-offset-2 *:data-dragging:outline-dashed *:data-dragging:outline-[#a5b4fc] [&>[data-dragging]>*]:invisible *:data-dd-ghost:rounded-xl *:data-dd-ghost:border *:data-dd-ghost:border-sl-line *:data-dd-ghost:shadow-[0_18px_40px_-12px_rgba(16,24,40,.35)] *:data-dd-ghost:rotate-[.8deg] *:data-dd-ghost:scale-[1.02] motion-reduce:*:data-dd-ghost:rotate-0 motion-reduce:*:data-dd-ghost:scale-100 *:data-grabbed:relative *:data-grabbed:rounded-xl *:data-grabbed:border-t-transparent *:data-grabbed:bg-sl-tint *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_10px_24px_-12px_rgba(79,70,229,.5)] [&>[data-grabbed]+*]:border-t-transparent" data-dd-list="tasks">
    <li class="group flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="size-[18px] flex-none cursor-pointer accent-sl-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sl-accent" type="checkbox" aria-labelledby="sl-t1" checked><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold group-has-checked:text-sl-muted group-has-checked:line-through" id="sl-t1" data-dd-name>Write the launch brief</span><span class="text-[.8rem] text-sl-muted">Due Monday · Priya</span></span><span class="flex-none rounded-full bg-[#fdf2fa] px-[9px] py-[3px] text-[.72rem] font-bold text-[#9e165f] max-[480px]:hidden">Copy</span></li>
    <li class="group flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="size-[18px] flex-none cursor-pointer accent-sl-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sl-accent" type="checkbox" aria-labelledby="sl-t2"><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold group-has-checked:text-sl-muted group-has-checked:line-through" id="sl-t2" data-dd-name>Review the pricing page</span><span class="text-[.8rem] text-sl-muted">Due Tuesday · Marco</span></span><span class="flex-none rounded-full bg-[#eef4ff] px-[9px] py-[3px] text-[.72rem] font-bold text-[#3538cd] max-[480px]:hidden">Design</span></li>
    <li class="group flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="size-[18px] flex-none cursor-pointer accent-sl-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sl-accent" type="checkbox" aria-labelledby="sl-t3"><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold group-has-checked:text-sl-muted group-has-checked:line-through" id="sl-t3" data-dd-name>Record the product demo</span><span class="text-[.8rem] text-sl-muted">Due Wednesday · Lena</span></span><span class="flex-none rounded-full bg-[#fff6ed] px-[9px] py-[3px] text-[.72rem] font-bold text-[#b93815] max-[480px]:hidden">Video</span></li>
    <li class="group flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="size-[18px] flex-none cursor-pointer accent-sl-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sl-accent" type="checkbox" aria-labelledby="sl-t4"><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold group-has-checked:text-sl-muted group-has-checked:line-through" id="sl-t4" data-dd-name>Schedule the announcement email</span><span class="text-[.8rem] text-sl-muted">Due Thursday · Priya</span></span><span class="flex-none rounded-full bg-[#ecfdf3] px-[9px] py-[3px] text-[.72rem] font-bold text-[#067647] max-[480px]:hidden">Email</span></li>
    <li class="group flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-tasks-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>
      <input class="size-[18px] flex-none cursor-pointer accent-sl-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sl-accent" type="checkbox" aria-labelledby="sl-t5"><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold group-has-checked:text-sl-muted group-has-checked:line-through" id="sl-t5" data-dd-name>Brief the support team</span><span class="text-[.8rem] text-sl-muted">Due Friday · Tomás</span></span><span class="flex-none rounded-full bg-[#f2f4f7] px-[9px] py-[3px] text-[.72rem] font-bold text-[#344054] max-[480px]:hidden">Support</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-sl-muted uppercase">Agenda with running times</span>
<section class="rounded-2xl border border-sl-line bg-white shadow-[0_1px_2px_rgba(16,24,40,.05),0_10px_28px_-16px_rgba(16,24,40,.18)]" data-dd data-agenda data-start="600" aria-labelledby="sl-agenda-title">
  <div class="flex items-baseline justify-between gap-3 px-5 pt-[18px] pb-3"><h2 class="font-bold tracking-[-.01em]" id="sl-agenda-title">Design review, Thursday</h2><p class="text-[.82rem] text-sl-muted" id="sl-agenda-hint">Start times update as you reorder</p></div>
  <ol class="px-2.5 *:data-dragging:rounded-xl *:data-dragging:bg-sl-tint *:data-dragging:outline-2 *:data-dragging:-outline-offset-2 *:data-dragging:outline-dashed *:data-dragging:outline-[#a5b4fc] [&>[data-dragging]>*]:invisible *:data-dd-ghost:rounded-xl *:data-dd-ghost:border *:data-dd-ghost:border-sl-line *:data-dd-ghost:shadow-[0_18px_40px_-12px_rgba(16,24,40,.35)] *:data-dd-ghost:rotate-[.8deg] *:data-dd-ghost:scale-[1.02] motion-reduce:*:data-dd-ghost:rotate-0 motion-reduce:*:data-dd-ghost:scale-100 *:data-grabbed:relative *:data-grabbed:rounded-xl *:data-grabbed:border-t-transparent *:data-grabbed:bg-sl-tint *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_10px_24px_-12px_rgba(79,70,229,.5)] [&>[data-grabbed]+*]:border-t-transparent" data-dd-list="agenda">
    <li class="flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item data-min="5"><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="w-[3.4rem] flex-none text-[.9rem] font-bold text-sl-accent tabular-nums" data-time>10:00</span><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold" data-dd-name>Welcome and goals</span><span class="text-[.8rem] text-sl-muted">5 min</span></span><span class="grid size-[30px] flex-none place-items-center rounded-full bg-sl-tint text-[.7rem] font-extrabold text-[#3730a3]" role="img" aria-label="Hannah Ito">HI</span></li>
    <li class="flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item data-min="20"><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="w-[3.4rem] flex-none text-[.9rem] font-bold text-sl-accent tabular-nums" data-time>10:05</span><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold" data-dd-name>Checkout redesign walkthrough</span><span class="text-[.8rem] text-sl-muted">20 min</span></span><span class="grid size-[30px] flex-none place-items-center rounded-full bg-sl-tint text-[.7rem] font-extrabold text-[#3730a3]" role="img" aria-label="Omar Haddad">OH</span></li>
    <li class="flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item data-min="15"><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="w-[3.4rem] flex-none text-[.9rem] font-bold text-sl-accent tabular-nums" data-time>10:25</span><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold" data-dd-name>Mobile navigation prototype</span><span class="text-[.8rem] text-sl-muted">15 min</span></span><span class="grid size-[30px] flex-none place-items-center rounded-full bg-sl-tint text-[.7rem] font-extrabold text-[#3730a3]" role="img" aria-label="Jo Kim">JK</span></li>
    <li class="flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item data-min="15"><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="w-[3.4rem] flex-none text-[.9rem] font-bold text-sl-accent tabular-nums" data-time>10:40</span><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold" data-dd-name>Accessibility audit findings</span><span class="text-[.8rem] text-sl-muted">15 min</span></span><span class="grid size-[30px] flex-none place-items-center rounded-full bg-sl-tint text-[.7rem] font-extrabold text-[#3730a3]" role="img" aria-label="Sara Lund">SL</span></li>
    <li class="flex cursor-grab items-center gap-3 border-t border-sl-line bg-white px-2.5 py-[11px] select-none first:border-t-transparent" data-dd-item data-min="10"><button class="-ml-1 grid h-[34px] w-[30px] flex-none cursor-grab touch-none place-items-center rounded-lg text-[#667085] hover:bg-[#f2f4f7] hover:text-sl-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sl-accent" type="button" data-dd-handle aria-describedby="sl-agenda-hint"><svg class="size-[18px]" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button>
      <span class="w-[3.4rem] flex-none text-[.9rem] font-bold text-sl-accent tabular-nums" data-time>10:55</span><span class="grid min-w-0 flex-1"><span class="text-[.93rem] font-semibold" data-dd-name>Open questions</span><span class="text-[.8rem] text-sl-muted">10 min</span></span><span class="grid size-[30px] flex-none place-items-center rounded-full bg-sl-tint text-[.7rem] font-extrabold text-[#3730a3]" role="img" aria-label="Hannah Ito">HI</span></li>
  </ol>
  <div class="flex flex-wrap justify-between gap-3 border-t border-sl-line px-5 pt-3 pb-4 text-[.82rem] text-sl-muted"><span>5 items · 65 min</span><span>Ends at <strong class="text-sl-ink" data-end>11:05</strong></span></div>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
@theme {
  --color-sl-accent: #4f46e5;
  --color-sl-tint: #eef0ff;
  --color-sl-line: #e4e7ec;
  --color-sl-muted: #5d6679;
  --color-sl-ink: #101828;
}
JavaScript
// Agenda: rewrite each start time and the end time whenever the order changes.
document.addEventListener('dd:change', e => {
  const root = e.target; if (!root.matches('[data-agenda]')) return;
  let t = +root.dataset.start; const hhmm = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
  root.querySelectorAll('[data-dd-item]').forEach(li => { li.querySelector('[data-time]').textContent = hhmm(t); t += +li.dataset.min; });
  root.querySelector('[data-end]').textContent = hhmm(t);
});
// 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

Two sortable lists for the most common drag and drop job: putting things in order. Task list with handles is a launch checklist where each row has a six-dot grip, a checkbox, a due day, an owner and a coloured tag; drag a row anywhere by the mouse, or by its grip on a touch screen. Agenda with running times is a meeting agenda in which every item has a duration, so the start times and the end time are recalculated each time an item moves. Use the first for to-do lists, playlists and settings that users rank, and the second whenever order changes something else on screen. Both run on one script of under a hundred lines of vanilla JavaScript with no library. Pointer events drive the drag for mouse, touch and pen: the real row stays in the list as a dashed placeholder while a floating copy follows the pointer, and the row is moved in the DOM as you pass the middle of its neighbours. The same script gives every handle a keyboard mode, so nothing depends on the pointer, and each move is spoken by screen readers.

What’s included

  • Drag rows with mouse, touch or pen through pointer events
  • Keyboard mode: Space to pick up, arrow keys to move, Escape to cancel
  • Every move announced in an aria-live region
  • Dashed placeholder and a lifted floating copy while dragging
  • Agenda start and end times recalculated on every move

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 "Review the pricing page moved to position 2 of 5.". 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 --sl-accent, --sl-tint and --sl-line on .sl for colour, and the .sl-row padding for density. In Bootstrap the same variables sit on .sl next to --bs-primary; in Tailwind edit the sl colours in @theme.