Open demo

CSS code

HTML
<div class="tcard" data-editable>
  <div class="tscroll" role="region" tabindex="0" aria-label="Order lines, scrollable">
  <table class="tbl">
    <caption class="sr-only">Order lines. Product and quantity cells are editable: Enter keeps a change, Escape undoes it.</caption>
    <thead><tr><th scope="col">Product</th><th scope="col">SKU</th><th scope="col" class="num">Qty</th><th scope="col" class="num">Unit price</th></tr></thead>
    <tbody>
      <tr><td contenteditable="true">Aeron Chair</td><td>CHR-1180</td><td class="num" contenteditable="true">12</td><td class="num">$1,395.00</td></tr>
      <tr><td contenteditable="true">Standing Desk</td><td>DSK-0420</td><td class="num" contenteditable="true">8</td><td class="num">$749.00</td></tr>
      <tr><td contenteditable="true">Monitor Arm</td><td>ARM-3310</td><td class="num" contenteditable="true">24</td><td class="num">$189.00</td></tr>
    </tbody>
  </table>
  </div>
  <div class="foot">
    <span role="status" data-msg>No changes</span>
    <div class="btns">
      <button type="button" data-discard>Discard</button>
      <button type="button" class="pri" data-save aria-disabled="true">Save changes</button>
    </div>
  </div>
</div>
CSS
/* Inline editing: cells are contenteditable, the script compares each cell with its last saved
   value and marks changed rows with data-dirty, which draws the amber row and edge bar. */
.tcard{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}
.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;white-space:nowrap}
.tbl td{padding:13px 16px;border-bottom:1px solid #f2f4f7;color:#3c4048;white-space:nowrap}
.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[contenteditable]{cursor:text;border-radius:6px}
.tbl td[contenteditable]:hover{box-shadow:inset 0 0 0 1px #d5dae4}
.tbl td[contenteditable]:focus{outline:none;box-shadow:inset 0 0 0 2px #4f46e5;background:#fff}
.tbl tbody td:first-child{position:relative}
.tbl tr[data-dirty]{background:#fffbeb}
.tbl tr[data-dirty]:hover{background:#fff7e0}
.tbl tr[data-dirty] td:first-child::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;
  background:#f59e0b}
.foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;
  border-top:1px solid #eceef3;background:#fafbfc;flex-wrap:wrap}
.foot span{font-size:.83rem;color:#5f6778}
.foot .btns{display:flex;gap:8px}
.foot button{font:inherit;font-size:.84rem;font-weight:700;padding:8px 16px;border-radius:8px;
  cursor:pointer;border:1px solid #e4e7ec;background:#fff;color:#344054}
.foot button:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
.foot button.pri{background:#4f46e5;border-color:#4f46e5;color:#fff}
.foot button[aria-disabled=true]{background:#e4e7ec;border-color:#e4e7ec;color:#98a2b3;cursor:not-allowed}
JavaScript
// Inline editing. Each cell remembers its last saved value (for Discard and the dirty mark) and
// the value it had when focused (for Escape). Save is aria-disabled rather than disabled, so it
// keeps keyboard focus after a save instead of dropping it on the page.
document.querySelectorAll('[data-editable]').forEach(card => {
  const cells = [...card.querySelectorAll('td[contenteditable]')];
  const save = card.querySelector('[data-save]');
  const msg = card.querySelector('[data-msg]');
  const saved = new Map(cells.map(c => [c, c.textContent]));
  const before = new Map();

  const mark = row => row.toggleAttribute('data-dirty',
    [...row.querySelectorAll('td[contenteditable]')].some(c => c.textContent !== saved.get(c)));
  const refresh = () => {
    const n = card.querySelectorAll('tr[data-dirty]').length;
    save.setAttribute('aria-disabled', n === 0);
    msg.textContent = n === 0 ? 'No changes' : `${n} row${n === 1 ? '' : 's'} changed`;
  };

  cells.forEach(cell => {
    cell.addEventListener('focus', () => before.set(cell, cell.textContent));
    cell.addEventListener('input', () => { mark(cell.closest('tr')); refresh(); });
    cell.addEventListener('keydown', e => {
      if (e.key === 'Enter') { e.preventDefault(); before.set(cell, cell.textContent); }
      if (e.key === 'Escape') {
        cell.textContent = before.get(cell) ?? cell.textContent;
        mark(cell.closest('tr'));
        refresh();
      }
    });
  });

  card.querySelector('[data-discard]').addEventListener('click', () => {
    cells.forEach(c => { c.textContent = saved.get(c); mark(c.closest('tr')); });
    refresh();
  });
  save.addEventListener('click', () => {
    if (save.getAttribute('aria-disabled') === 'true') return;
    cells.forEach(c => { saved.set(c, c.textContent); mark(c.closest('tr')); });
    refresh();
    msg.textContent = 'Changes saved';
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="card tcard overflow-hidden" data-editable>
  <div class="table-responsive" role="region" tabindex="0" aria-label="Order lines, scrollable">
  <table class="table table-hover align-middle mb-0 tbl">
    <caption class="visually-hidden">Order lines. Product and quantity cells are editable: Enter keeps a change, Escape undoes it.</caption>
    <thead><tr><th scope="col">Product</th><th scope="col">SKU</th><th scope="col" class="text-end">Qty</th><th scope="col" class="text-end">Unit price</th></tr></thead>
    <tbody>
      <tr><td contenteditable="true">Aeron Chair</td><td>CHR-1180</td><td class="text-end num" contenteditable="true">12</td><td class="text-end num">$1,395.00</td></tr>
      <tr><td contenteditable="true">Standing Desk</td><td>DSK-0420</td><td class="text-end num" contenteditable="true">8</td><td class="text-end num">$749.00</td></tr>
      <tr><td contenteditable="true">Monitor Arm</td><td>ARM-3310</td><td class="text-end num" contenteditable="true">24</td><td class="text-end num">$189.00</td></tr>
    </tbody>
  </table>
  </div>
  <div class="card-footer d-flex flex-wrap align-items-center justify-content-between">
    <span role="status" data-msg>No changes</span>
    <div class="d-flex gap-2">
      <button type="button" class="btn btn-light" data-discard>Discard</button>
      <button type="button" class="btn btn-primary" data-save aria-disabled="true">Save changes</button>
    </div>
  </div>
</div>
CSS
/* Bootstrap .card + .table-hover with contenteditable cells. Changed rows get data-dirty, which
   sets the row's --bs-table-bg-state and draws the amber edge bar; .card-footer holds the .btn pair. */
.tcard{--bs-card-border-color:#e7e9f0;--bs-card-border-radius:14px;--bs-card-bg:#fff;
  --bs-card-cap-bg:#fafbfc;--bs-card-cap-padding-x:16px;--bs-card-cap-padding-y:12px}
.tcard .card-footer{border-top-color:#eceef3;gap:12px}
.tcard .card-footer [data-msg]{font-size:.83rem;color:#5f6778}
.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}
.tbl > :not(caption) > * > *{padding:13px 16px;white-space:nowrap}
.tbl > tbody > tr:last-child > *{border-bottom-width:0}
.tbl > thead > tr > th{font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;color:#5f6778;
  background:#fafbfc;border-bottom-color:#eceef3}
.tbl .num{font-variant-numeric:tabular-nums}
.tbl td[contenteditable]{cursor:text;border-radius:6px}
.tbl td[contenteditable]:hover{box-shadow:inset 0 0 0 1px #d5dae4}
.tbl td[contenteditable]:focus{outline:none;box-shadow:inset 0 0 0 2px #4f46e5;background:#fff}
.tbl > tbody > tr > td:first-child{position:relative}
.tbl > tbody > tr[data-dirty] > *{--bs-table-bg-state:#fffbeb}
.tbl.table-hover > tbody > tr[data-dirty]:hover > *{--bs-table-bg-state:#fff7e0}
.tbl > tbody > tr[data-dirty] > td:first-child::before{content:"";position:absolute;left:0;top:0;bottom:0;
  width:3px;background:#f59e0b}
.tcard .card-footer .btn{--bs-btn-padding-x:16px;--bs-btn-padding-y:8px;--bs-btn-font-size:.84rem;
  --bs-btn-font-weight:700;--bs-btn-line-height:normal;--bs-btn-border-radius:8px;
  --bs-btn-focus-box-shadow:0 0 0 2px #fff,0 0 0 4px #4f46e5}
.tcard .btn-primary{--bs-btn-bg:#4f46e5;--bs-btn-border-color:#4f46e5;--bs-btn-hover-bg:#4338ca;
  --bs-btn-hover-border-color:#4338ca;--bs-btn-active-bg:#3730a3;--bs-btn-active-border-color:#3730a3}
.tcard .btn-light{--bs-btn-color:#344054;--bs-btn-bg:#fff;--bs-btn-border-color:#e4e7ec;--bs-btn-hover-color:#344054;
  --bs-btn-hover-bg:#f7f8fa;--bs-btn-hover-border-color:#e4e7ec;--bs-btn-active-bg:#eef0f4;--bs-btn-active-border-color:#e4e7ec}
.tcard .btn[aria-disabled=true]{--bs-btn-bg:#e4e7ec;--bs-btn-border-color:#e4e7ec;--bs-btn-color:#98a2b3;
  --bs-btn-hover-bg:#e4e7ec;--bs-btn-hover-border-color:#e4e7ec;--bs-btn-hover-color:#98a2b3;cursor:not-allowed}
JavaScript
// Inline editing. Each cell remembers its last saved value (for Discard and the dirty mark) and
// the value it had when focused (for Escape). Save is aria-disabled rather than disabled, so it
// keeps keyboard focus after a save instead of dropping it on the page.
document.querySelectorAll('[data-editable]').forEach(card => {
  const cells = [...card.querySelectorAll('td[contenteditable]')];
  const save = card.querySelector('[data-save]');
  const msg = card.querySelector('[data-msg]');
  const saved = new Map(cells.map(c => [c, c.textContent]));
  const before = new Map();

  const mark = row => row.toggleAttribute('data-dirty',
    [...row.querySelectorAll('td[contenteditable]')].some(c => c.textContent !== saved.get(c)));
  const refresh = () => {
    const n = card.querySelectorAll('tr[data-dirty]').length;
    save.setAttribute('aria-disabled', n === 0);
    msg.textContent = n === 0 ? 'No changes' : `${n} row${n === 1 ? '' : 's'} changed`;
  };

  cells.forEach(cell => {
    cell.addEventListener('focus', () => before.set(cell, cell.textContent));
    cell.addEventListener('input', () => { mark(cell.closest('tr')); refresh(); });
    cell.addEventListener('keydown', e => {
      if (e.key === 'Enter') { e.preventDefault(); before.set(cell, cell.textContent); }
      if (e.key === 'Escape') {
        cell.textContent = before.get(cell) ?? cell.textContent;
        mark(cell.closest('tr'));
        refresh();
      }
    });
  });

  card.querySelector('[data-discard]').addEventListener('click', () => {
    cells.forEach(c => { c.textContent = saved.get(c); mark(c.closest('tr')); });
    refresh();
  });
  save.addEventListener('click', () => {
    if (save.getAttribute('aria-disabled') === 'true') return;
    cells.forEach(c => { saved.set(c, c.textContent); mark(c.closest('tr')); });
    refresh();
    msg.textContent = 'Changes saved';
  });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="overflow-hidden rounded-[14px] border border-[#e7e9f0] bg-white" data-editable>
  <div class="overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" role="region" tabindex="0" aria-label="Order lines, 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] [&_th]:border-b [&_th]:border-[#eceef3] [&_th]:bg-[#fafbfc] [&_th]:px-4 [&_th]:py-[13px] [&_th]:text-[.74rem] [&_th]:font-bold [&_th]:tracking-[.06em] [&_th]:text-[#5f6778] [&_th]:uppercase [&_tbody_tr:last-child_td]:border-b-0">
    <caption class="sr-only">Order lines. Product and quantity cells are editable: Enter keeps a change, Escape undoes it.</caption>
    <thead><tr><th scope="col" class="text-left">Product</th><th scope="col" class="text-left">SKU</th><th scope="col" class="text-right">Qty</th><th scope="col" class="text-right">Unit price</th></tr></thead>
    <tbody>
      <tr class="group hover:bg-[#fafbfd] data-dirty:bg-[#fffbeb] data-dirty:hover:bg-[#fff7e0]"><td class="relative cursor-text rounded-md before:absolute before:inset-y-0 before:left-0 before:w-[3px] group-data-dirty:before:bg-[#f59e0b] hover:shadow-[inset_0_0_0_1px_#d5dae4] focus:bg-white focus:shadow-[inset_0_0_0_2px_#4f46e5] focus:outline-none" contenteditable="true">Aeron Chair</td><td>CHR-1180</td><td class="cursor-text rounded-md text-right tabular-nums hover:shadow-[inset_0_0_0_1px_#d5dae4] focus:bg-white focus:shadow-[inset_0_0_0_2px_#4f46e5] focus:outline-none" contenteditable="true">12</td><td class="text-right tabular-nums">$1,395.00</td></tr>
      <tr class="group hover:bg-[#fafbfd] data-dirty:bg-[#fffbeb] data-dirty:hover:bg-[#fff7e0]"><td class="relative cursor-text rounded-md before:absolute before:inset-y-0 before:left-0 before:w-[3px] group-data-dirty:before:bg-[#f59e0b] hover:shadow-[inset_0_0_0_1px_#d5dae4] focus:bg-white focus:shadow-[inset_0_0_0_2px_#4f46e5] focus:outline-none" contenteditable="true">Standing Desk</td><td>DSK-0420</td><td class="cursor-text rounded-md text-right tabular-nums hover:shadow-[inset_0_0_0_1px_#d5dae4] focus:bg-white focus:shadow-[inset_0_0_0_2px_#4f46e5] focus:outline-none" contenteditable="true">8</td><td class="text-right tabular-nums">$749.00</td></tr>
      <tr class="group hover:bg-[#fafbfd] data-dirty:bg-[#fffbeb] data-dirty:hover:bg-[#fff7e0]"><td class="relative cursor-text rounded-md before:absolute before:inset-y-0 before:left-0 before:w-[3px] group-data-dirty:before:bg-[#f59e0b] hover:shadow-[inset_0_0_0_1px_#d5dae4] focus:bg-white focus:shadow-[inset_0_0_0_2px_#4f46e5] focus:outline-none" contenteditable="true">Monitor Arm</td><td>ARM-3310</td><td class="cursor-text rounded-md text-right tabular-nums hover:shadow-[inset_0_0_0_1px_#d5dae4] focus:bg-white focus:shadow-[inset_0_0_0_2px_#4f46e5] focus:outline-none" contenteditable="true">24</td><td class="text-right tabular-nums">$189.00</td></tr>
    </tbody>
  </table>
  </div>
  <div class="flex flex-wrap items-center justify-between gap-3 border-t border-[#eceef3] bg-[#fafbfc] px-4 py-3">
    <span class="text-[.83rem] text-[#5f6778]" role="status" data-msg>No changes</span>
    <div class="flex gap-2">
      <button type="button" class="cursor-pointer rounded-lg border border-[#e4e7ec] bg-white px-4 py-2 text-[.84rem] font-bold text-[#344054] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" data-discard>Discard</button>
      <button type="button" class="cursor-pointer rounded-lg border border-[#4f46e5] bg-[#4f46e5] px-4 py-2 text-[.84rem] font-bold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] aria-disabled:cursor-not-allowed aria-disabled:border-[#e4e7ec] aria-disabled:bg-[#e4e7ec] aria-disabled:text-[#98a2b3]" data-save aria-disabled="true">Save changes</button>
    </div>
  </div>
</div>
JavaScript
// Inline editing. Each cell remembers its last saved value (for Discard and the dirty mark) and
// the value it had when focused (for Escape). Save is aria-disabled rather than disabled, so it
// keeps keyboard focus after a save instead of dropping it on the page.
document.querySelectorAll('[data-editable]').forEach(card => {
  const cells = [...card.querySelectorAll('td[contenteditable]')];
  const save = card.querySelector('[data-save]');
  const msg = card.querySelector('[data-msg]');
  const saved = new Map(cells.map(c => [c, c.textContent]));
  const before = new Map();

  const mark = row => row.toggleAttribute('data-dirty',
    [...row.querySelectorAll('td[contenteditable]')].some(c => c.textContent !== saved.get(c)));
  const refresh = () => {
    const n = card.querySelectorAll('tr[data-dirty]').length;
    save.setAttribute('aria-disabled', n === 0);
    msg.textContent = n === 0 ? 'No changes' : `${n} row${n === 1 ? '' : 's'} changed`;
  };

  cells.forEach(cell => {
    cell.addEventListener('focus', () => before.set(cell, cell.textContent));
    cell.addEventListener('input', () => { mark(cell.closest('tr')); refresh(); });
    cell.addEventListener('keydown', e => {
      if (e.key === 'Enter') { e.preventDefault(); before.set(cell, cell.textContent); }
      if (e.key === 'Escape') {
        cell.textContent = before.get(cell) ?? cell.textContent;
        mark(cell.closest('tr'));
        refresh();
      }
    });
  });

  card.querySelector('[data-discard]').addEventListener('click', () => {
    cells.forEach(c => { c.textContent = saved.get(c); mark(c.closest('tr')); });
    refresh();
  });
  save.addEventListener('click', () => {
    if (save.getAttribute('aria-disabled') === 'true') return;
    cells.forEach(c => { saved.set(c, c.textContent); mark(c.closest('tr')); });
    refresh();
    msg.textContent = 'Changes saved';
  });
});

About this set

An order table where the product name and quantity cells can be edited in place, with a footer that counts changed rows and offers Discard and Save changes. Click or Tab into an editable cell and type. Enter keeps the change and Escape puts back the value the cell had when you entered it. Every changed row turns pale amber and gets a 3 pixel amber bar on its left edge until it is saved or discarded, and the footer reads 1 row changed, 2 rows changed and so on through a status region. Discard restores every cell to its last saved value and Save makes the current values the new baseline. The cells are contenteditable table cells, so no inputs are swapped in and the layout does not shift while editing. The script compares each editable cell with its saved value and toggles a data-dirty attribute on the row, and all three versions style the row from that attribute. Use it for quick corrections in admin tables, price lists and stock counts. Validate and send the values to your server in the Save handler.

What’s included

  • Edit in place with contenteditable cells, no layout shift
  • Enter keeps a change, Escape undoes it
  • Changed rows marked with an amber tint and edge bar
  • Discard restores the last saved values
  • Row count announced through a status region

Accessibility

The caption explains which cells are editable and what Enter and Escape do. Editable cells are in the Tab order and draw a 2px indigo inset ring when focused. The change count is a role="status" region, and Save uses aria-disabled rather than disabled so it stays focusable and keeps focus after saving. Nothing animates.

Customise it

The dirty row colours are #fffbeb and the #f59e0b edge bar on tr[data-dirty]; the focus ring is the inset box-shadow in #4f46e5. In Bootstrap the row tint is --bs-table-bg-state, and in Tailwind it is the data-dirty: utilities on each row.