Open demo

CSS code

HTML
<div class="tcard">
  <p id="reorder-help" class="sr-only">Press the up or down arrow key to move this row.</p>
  <div class="tscroll" role="region" tabindex="0" aria-label="Task queue, scrollable">
  <table class="tbl" data-reorder="reorder-status">
    <caption class="sr-only">Task queue in priority order. Each row can be dragged by its handle or moved with the arrow keys.</caption>
    <thead><tr><th scope="col"><span class="sr-only">Reorder</span></th><th scope="col" style="width:34px">#</th><th scope="col">Task</th><th scope="col">Assignee</th><th scope="col" class="num">Due</th></tr></thead>
    <tbody>
      <tr draggable="true"><td class="h"><button class="handle" type="button" data-handle aria-label="Reorder: Rework the onboarding copy" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="rank" data-rank>1</td><td data-name>Rework the onboarding copy</td><td>Rosa M.</td><td class="num">2 Sep</td></tr>
      <tr draggable="true"><td class="h"><button class="handle" type="button" data-handle aria-label="Reorder: Audit colour contrast" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="rank" data-rank>2</td><td data-name>Audit colour contrast</td><td>Tomas P.</td><td class="num">5 Sep</td></tr>
      <tr draggable="true"><td class="h"><button class="handle" type="button" data-handle aria-label="Reorder: Ship the CSV export endpoint" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="rank" data-rank>3</td><td data-name>Ship the CSV export endpoint</td><td>Aigars K.</td><td class="num">9 Sep</td></tr>
      <tr draggable="true"><td class="h"><button class="handle" type="button" data-handle aria-label="Reorder: Migrate the docs site" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="rank" data-rank>4</td><td data-name>Migrate the docs site</td><td>Priya N.</td><td class="num">14 Sep</td></tr>
    </tbody>
  </table>
  </div>
</div>
<p class="reorder-status" id="reorder-status" role="status"></p>
CSS
/* Reorderable rows: draggable <tr>s with a grip button in the first cell. The grip also takes the
   up and down arrow keys; every move is written to a role="status" line under the table. */
.tcard{position:relative;background:#fff;border:1px solid #e7e9f0;border-radius:14px;overflow:hidden}
.tscroll{overflow-x:auto}
.tscroll:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.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}
.tbl{width:100%;border-collapse:collapse;font-size:.89rem;white-space:nowrap}
.tbl thead th{text-align:left;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;
  color:#5f6778;font-weight:700;padding:13px 16px;background:#fafbfc;border-bottom:1px solid #eceef3}
.tbl td{padding:13px 16px;border-bottom:1px solid #f2f4f7;color:#3c4048}
.tbl tbody tr:last-child td{border-bottom:0}
.tbl tbody tr:hover{background:#fafbfd}
.tbl .num{font-variant-numeric:tabular-nums;text-align:right}
.tbl thead th.num{text-align:right}
.tbl td.h{width:40px}
.handle{width:28px;height:28px;border:0;background:none;border-radius:7px;cursor:grab;
  color:#858d9c;display:grid;place-items:center}
.handle:hover{background:#f0f2f6;color:#667085}
.handle:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
.handle svg{display:block;width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.6;stroke-linecap:round}
.tbl tr[data-dragging]{opacity:.4}
.tbl tr[data-over] td{box-shadow:inset 0 2px 0 #4f46e5}
.tbl .rank{font-variant-numeric:tabular-nums;color:#667085;width:34px}
.reorder-status{font-size:.83rem;color:#667085;margin-top:11px;min-height:1.3em}
JavaScript
// Reorderable rows. Drag-and-drop alone locks out anyone not using a mouse, so the arrow keys on
// a handle move its row too, and both paths renumber the rows and report through the same live
// region. Row state is kept in data-dragging and data-over, which the styles read.
document.querySelectorAll('[data-reorder]').forEach(table => {
  const tbody = table.tBodies[0];
  const status = document.getElementById(table.dataset.reorder);
  let dragged = null;
  const renumber = () => [...tbody.rows].forEach((r, i) => { r.querySelector('[data-rank]').textContent = i + 1; });
  const announce = row => {
    const name = row.querySelector('[data-name]').textContent.trim();
    status.textContent = `${name} moved to position ${[...tbody.rows].indexOf(row) + 1} of ${tbody.rows.length}.`;
  };
  tbody.addEventListener('dragstart', e => {
    dragged = e.target.closest('tr');
    dragged.toggleAttribute('data-dragging', true);
  });
  tbody.addEventListener('dragover', e => {
    e.preventDefault();
    const row = e.target.closest('tr');
    if (!dragged || !row || row === dragged) return;
    [...tbody.rows].forEach(r => r.removeAttribute('data-over'));
    row.toggleAttribute('data-over', true);
    const after = e.clientY > row.getBoundingClientRect().top + row.offsetHeight / 2;
    tbody.insertBefore(dragged, after ? row.nextSibling : row);
  });
  tbody.addEventListener('dragend', () => {
    [...tbody.rows].forEach(r => { r.removeAttribute('data-over'); r.removeAttribute('data-dragging'); });
    renumber();
    announce(dragged);
    dragged = null;
  });
  tbody.addEventListener('keydown', e => {
    if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
    const handle = e.target.closest('[data-handle]');
    if (!handle) return;
    e.preventDefault();
    const row = handle.closest('tr');
    if (e.key === 'ArrowUp' && row.previousElementSibling) tbody.insertBefore(row, row.previousElementSibling);
    else if (e.key === 'ArrowDown' && row.nextElementSibling) tbody.insertBefore(row.nextElementSibling, row);
    else return;
    renumber();
    announce(row);
    handle.focus();
  });
  renumber();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="card tcard overflow-hidden">
  <p id="reorder-help" class="visually-hidden">Press the up or down arrow key to move this row.</p>
  <div class="table-responsive" role="region" tabindex="0" aria-label="Task queue, scrollable">
  <table class="table table-hover align-middle mb-0 tbl" data-reorder="reorder-status">
    <caption class="visually-hidden">Task queue in priority order. Each row can be dragged by its handle or moved with the arrow keys.</caption>
    <thead><tr><th scope="col"><span class="visually-hidden">Reorder</span></th><th scope="col" style="width:34px">#</th><th scope="col">Task</th><th scope="col">Assignee</th><th scope="col" class="text-end">Due</th></tr></thead>
    <tbody>
      <tr draggable="true"><td class="h"><button class="btn handle" type="button" data-handle aria-label="Reorder: Rework the onboarding copy" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="rank" data-rank>1</td><td data-name>Rework the onboarding copy</td><td>Rosa M.</td><td class="text-end">2 Sep</td></tr>
      <tr draggable="true"><td class="h"><button class="btn handle" type="button" data-handle aria-label="Reorder: Audit colour contrast" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="rank" data-rank>2</td><td data-name>Audit colour contrast</td><td>Tomas P.</td><td class="text-end">5 Sep</td></tr>
      <tr draggable="true"><td class="h"><button class="btn handle" type="button" data-handle aria-label="Reorder: Ship the CSV export endpoint" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="rank" data-rank>3</td><td data-name>Ship the CSV export endpoint</td><td>Aigars K.</td><td class="text-end">9 Sep</td></tr>
      <tr draggable="true"><td class="h"><button class="btn handle" type="button" data-handle aria-label="Reorder: Migrate the docs site" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="rank" data-rank>4</td><td data-name>Migrate the docs site</td><td>Priya N.</td><td class="text-end">14 Sep</td></tr>
    </tbody>
  </table>
  </div>
</div>
<p class="reorder-status" id="reorder-status" role="status"></p>
CSS
/* Bootstrap .card + .table-responsive + .table-hover with draggable rows. The grip is a .btn with
   its colours set through --bs-btn-*; data-dragging and data-over style the row being moved. */
.tcard{--bs-card-border-color:#e7e9f0;--bs-card-border-radius:14px;--bs-card-bg:#fff}
.tcard .table-responsive:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.tbl{--bs-table-color:#3c4048;--bs-table-bg:#fff;--bs-table-border-color:#f2f4f7;
  --bs-table-hover-color:#3c4048;--bs-table-hover-bg:#fafbfd;font-size:.89rem;white-space:nowrap}
.tbl > :not(caption) > * > *{padding:13px 16px}
.tbl > tbody > tr:last-child > *{border-bottom-width:0}
.tbl > thead > tr > th{font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;color:#5f6778;
  --bs-table-bg:#fafbfc;border-bottom-color:#eceef3}
.tbl td.h{width:40px}
.tbl .rank{font-variant-numeric:tabular-nums;--bs-table-color:#667085;width:34px}
.tbl > tbody > tr > td:last-child{font-variant-numeric:tabular-nums}
.handle{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-border-radius:7px;
  --bs-btn-color:#858d9c;--bs-btn-hover-color:#667085;--bs-btn-hover-bg:#f0f2f6;--bs-btn-active-color:#667085;
  --bs-btn-active-bg:#e4e7ec;--bs-btn-focus-box-shadow:0 0 0 2px #fff,0 0 0 4px #4f46e5;
  width:28px;height:28px;display:grid;place-items:center;cursor:grab}
.handle svg{display:block;width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.6;stroke-linecap:round}
.tbl > tbody > tr[data-dragging]{opacity:.4}
.tbl > tbody > tr[data-over] > td{box-shadow:inset 0 2px 0 #4f46e5}
.reorder-status{font-size:.83rem;color:#667085;margin:11px 0 0;min-height:1.3em}
JavaScript
// Reorderable rows. Drag-and-drop alone locks out anyone not using a mouse, so the arrow keys on
// a handle move its row too, and both paths renumber the rows and report through the same live
// region. Row state is kept in data-dragging and data-over, which the styles read.
document.querySelectorAll('[data-reorder]').forEach(table => {
  const tbody = table.tBodies[0];
  const status = document.getElementById(table.dataset.reorder);
  let dragged = null;
  const renumber = () => [...tbody.rows].forEach((r, i) => { r.querySelector('[data-rank]').textContent = i + 1; });
  const announce = row => {
    const name = row.querySelector('[data-name]').textContent.trim();
    status.textContent = `${name} moved to position ${[...tbody.rows].indexOf(row) + 1} of ${tbody.rows.length}.`;
  };
  tbody.addEventListener('dragstart', e => {
    dragged = e.target.closest('tr');
    dragged.toggleAttribute('data-dragging', true);
  });
  tbody.addEventListener('dragover', e => {
    e.preventDefault();
    const row = e.target.closest('tr');
    if (!dragged || !row || row === dragged) return;
    [...tbody.rows].forEach(r => r.removeAttribute('data-over'));
    row.toggleAttribute('data-over', true);
    const after = e.clientY > row.getBoundingClientRect().top + row.offsetHeight / 2;
    tbody.insertBefore(dragged, after ? row.nextSibling : row);
  });
  tbody.addEventListener('dragend', () => {
    [...tbody.rows].forEach(r => { r.removeAttribute('data-over'); r.removeAttribute('data-dragging'); });
    renumber();
    announce(dragged);
    dragged = null;
  });
  tbody.addEventListener('keydown', e => {
    if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
    const handle = e.target.closest('[data-handle]');
    if (!handle) return;
    e.preventDefault();
    const row = handle.closest('tr');
    if (e.key === 'ArrowUp' && row.previousElementSibling) tbody.insertBefore(row, row.previousElementSibling);
    else if (e.key === 'ArrowDown' && row.nextElementSibling) tbody.insertBefore(row.nextElementSibling, row);
    else return;
    renumber();
    announce(row);
    handle.focus();
  });
  renumber();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="relative overflow-hidden rounded-[14px] border border-[#e7e9f0] bg-white">
  <p id="reorder-help" class="sr-only">Press the up or down arrow key to move this row.</p>
  <div class="overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" role="region" tabindex="0" aria-label="Task queue, scrollable">
  <table class="w-full border-collapse text-[.89rem] whitespace-nowrap [&_td]:border-b [&_td]:border-[#f2f4f7] [&_td]:px-4 [&_td]:py-[13px] [&_td]:text-[#3c4048] [&_tbody_tr:last-child_td]:border-b-0" data-reorder="reorder-status">
    <caption class="sr-only">Task queue in priority order. Each row can be dragged by its handle or moved with the arrow keys.</caption>
    <thead><tr><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase"><span class="sr-only">Reorder</span></th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase w-[34px] text-left">#</th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-left">Task</th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-left">Assignee</th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Due</th></tr></thead>
    <tbody>
      <tr class="hover:bg-[#fafbfd] data-dragging:opacity-40 data-over:[&>td]:shadow-[inset_0_2px_0_#4f46e5]" draggable="true"><td class="w-10"><button class="grid size-7 cursor-grab place-items-center rounded-[7px] text-[#858d9c] hover:bg-[#f0f2f6] hover:text-[#667085] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] [&>svg]:size-[15px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-[2.6] [&>svg]:[stroke-linecap:round]" type="button" data-handle aria-label="Reorder: Rework the onboarding copy" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="w-[34px] tabular-nums text-[#667085]!" data-rank>1</td><td data-name>Rework the onboarding copy</td><td>Rosa M.</td><td class="text-right tabular-nums">2 Sep</td></tr>
      <tr class="hover:bg-[#fafbfd] data-dragging:opacity-40 data-over:[&>td]:shadow-[inset_0_2px_0_#4f46e5]" draggable="true"><td class="w-10"><button class="grid size-7 cursor-grab place-items-center rounded-[7px] text-[#858d9c] hover:bg-[#f0f2f6] hover:text-[#667085] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] [&>svg]:size-[15px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-[2.6] [&>svg]:[stroke-linecap:round]" type="button" data-handle aria-label="Reorder: Audit colour contrast" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="w-[34px] tabular-nums text-[#667085]!" data-rank>2</td><td data-name>Audit colour contrast</td><td>Tomas P.</td><td class="text-right tabular-nums">5 Sep</td></tr>
      <tr class="hover:bg-[#fafbfd] data-dragging:opacity-40 data-over:[&>td]:shadow-[inset_0_2px_0_#4f46e5]" draggable="true"><td class="w-10"><button class="grid size-7 cursor-grab place-items-center rounded-[7px] text-[#858d9c] hover:bg-[#f0f2f6] hover:text-[#667085] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] [&>svg]:size-[15px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-[2.6] [&>svg]:[stroke-linecap:round]" type="button" data-handle aria-label="Reorder: Ship the CSV export endpoint" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="w-[34px] tabular-nums text-[#667085]!" data-rank>3</td><td data-name>Ship the CSV export endpoint</td><td>Aigars K.</td><td class="text-right tabular-nums">9 Sep</td></tr>
      <tr class="hover:bg-[#fafbfd] data-dragging:opacity-40 data-over:[&>td]:shadow-[inset_0_2px_0_#4f46e5]" draggable="true"><td class="w-10"><button class="grid size-7 cursor-grab place-items-center rounded-[7px] text-[#858d9c] hover:bg-[#f0f2f6] hover:text-[#667085] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] [&>svg]:size-[15px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-[2.6] [&>svg]:[stroke-linecap:round]" type="button" data-handle aria-label="Reorder: Migrate the docs site" aria-describedby="reorder-help"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"/></svg></button></td><td class="w-[34px] tabular-nums text-[#667085]!" data-rank>4</td><td data-name>Migrate the docs site</td><td>Priya N.</td><td class="text-right tabular-nums">14 Sep</td></tr>
    </tbody>
  </table>
  </div>
</div>
<p class="mt-[11px] min-h-[1.3em] text-[.83rem] text-[#667085]" id="reorder-status" role="status"></p>
JavaScript
// Reorderable rows. Drag-and-drop alone locks out anyone not using a mouse, so the arrow keys on
// a handle move its row too, and both paths renumber the rows and report through the same live
// region. Row state is kept in data-dragging and data-over, which the styles read.
document.querySelectorAll('[data-reorder]').forEach(table => {
  const tbody = table.tBodies[0];
  const status = document.getElementById(table.dataset.reorder);
  let dragged = null;
  const renumber = () => [...tbody.rows].forEach((r, i) => { r.querySelector('[data-rank]').textContent = i + 1; });
  const announce = row => {
    const name = row.querySelector('[data-name]').textContent.trim();
    status.textContent = `${name} moved to position ${[...tbody.rows].indexOf(row) + 1} of ${tbody.rows.length}.`;
  };
  tbody.addEventListener('dragstart', e => {
    dragged = e.target.closest('tr');
    dragged.toggleAttribute('data-dragging', true);
  });
  tbody.addEventListener('dragover', e => {
    e.preventDefault();
    const row = e.target.closest('tr');
    if (!dragged || !row || row === dragged) return;
    [...tbody.rows].forEach(r => r.removeAttribute('data-over'));
    row.toggleAttribute('data-over', true);
    const after = e.clientY > row.getBoundingClientRect().top + row.offsetHeight / 2;
    tbody.insertBefore(dragged, after ? row.nextSibling : row);
  });
  tbody.addEventListener('dragend', () => {
    [...tbody.rows].forEach(r => { r.removeAttribute('data-over'); r.removeAttribute('data-dragging'); });
    renumber();
    announce(dragged);
    dragged = null;
  });
  tbody.addEventListener('keydown', e => {
    if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
    const handle = e.target.closest('[data-handle]');
    if (!handle) return;
    e.preventDefault();
    const row = handle.closest('tr');
    if (e.key === 'ArrowUp' && row.previousElementSibling) tbody.insertBefore(row, row.previousElementSibling);
    else if (e.key === 'ArrowDown' && row.nextElementSibling) tbody.insertBefore(row.nextElementSibling, row);
    else return;
    renumber();
    announce(row);
    handle.focus();
  });
  renumber();
});

About this set

A prioritised task queue whose rows can be put in a new order by dragging or with the keyboard. Each row starts with a grip handle. Drag a row and the others move out of its way while it is dimmed and an indigo line marks where it will land. Or Tab to a handle and press the up or down arrow key to move that row one place, with focus staying on the handle so you can keep going. Both paths renumber the position column and write a sentence such as Rework the onboarding copy moved to position 2 of 4 to a status line under the table, which screen readers announce. Rows are draggable table rows using the browser’s built-in drag and drop, and the row being dragged and the drop target are marked with data attributes that all three versions style. The handles carry a hidden instruction through aria-describedby, and the handle’s name includes the task. Use it for priority lists, playlists, menu editors and form builders. Send the new order to your server in the dragend and keydown handlers.

What’s included

  • Drag and drop with a drop-position line
  • Arrow keys move a row from its handle
  • Positions renumbered after every move
  • Moves announced through a status region
  • Handles named after their task

Accessibility

The caption explains the two ways to reorder, the handle column has a hidden header, and each handle is a native button whose name includes the task and whose description gives the arrow key instruction. Every move is written to a role="status" region, focus stays on the handle after a keyboard move, and handles show a 2px focus outline. Nothing animates.

Customise it

The drop line is the inset 0 2px 0 #4f46e5 box-shadow on tr[data-over] cells and the dragged-row opacity is .4 on tr[data-dragging]. Handle colours are #858d9c and #f0f2f6 in .handle (plain CSS), the --bs-btn-* variables (Bootstrap) or the handle classes (Tailwind).