Keyboard Copy and Paste in Data Grids
Permalink to "Keyboard Copy and Paste in Data Grids"Copy and paste are the keyboard user’s bulk-editing tools. In a spreadsheet-style grid, Ctrl+C on a selected range should put the values on the clipboard in a form that pastes cleanly into another grid or a spreadsheet, and Ctrl+V should fill a range from the clipboard. The accessibility failures are all about feedback: a copy that happens silently, a paste that changes forty cells without a word, or a paste where six values were quietly rejected.
This page covers the clipboard mechanics and the messages around them. It belongs to cell selection & clipboard in data grids and depends on the range model from announcing rectangular cell range selections.
Spec reference
Permalink to "Spec reference"The HTML clipboard events — copy, cut, paste — fire on the focused element and bubble. In a copy handler, event.clipboardData.setData('text/plain', …) with event.preventDefault() replaces what the browser would have copied. In a paste handler, event.clipboardData.getData('text/plain') reads the clipboard synchronously, without a permission prompt, because the user initiated it.
Tab-separated values (TSV), with rows separated by newlines, is the interchange format every spreadsheet understands on paste. Cells that contain tabs or newlines need quoting.
WCAG criteria: SC 2.1.1 Keyboard (copy and paste must work from the keyboard, not only a context menu), SC 4.1.3 Status Messages (confirmations and results), SC 3.3.1 Error Identification (rejected paste values must be identified), and SC 3.3.4 Error Prevention for data that matters — a paste should be reversible.
When to support clipboard — and when not to
Permalink to "When to support clipboard — and when not to"Support it in any grid where users would otherwise re-type data: budgeting, inventory, scheduling, bulk configuration. For users who find precise pointer work difficult, pasting a column from a spreadsheet is often the only practical way to enter data at volume.
A read-only reporting grid needs copy but not paste. Copying a range should still work — users move data into their own tools — and it is the more commonly broken half, because custom grids intercept Ctrl+C for navigation reasons and never implement it.
The misapplication to name is relying on the browser’s native text selection for copy. In a grid that uses roving focus, Ctrl+C copies nothing (no text selection exists) or copies the whole page. The grid has to own the copy event.
Annotated code example
Permalink to "Annotated code example"// COPY: the selected rectangle as TSV, raw values not display text
grid.addEventListener('copy', (e) => {
const { r1, r2, c1, c2 } = rangeBounds();
const rows = [];
for (let r = r1; r <= r2; r++) {
const cells = [];
for (let c = c1; c <= c2; c++) cells.push(tsvQuote(rawValue(r, c)));
rows.push(cells.join('\t'));
}
e.clipboardData.setData('text/plain', rows.join('\n'));
e.preventDefault();
const n = (r2 - r1 + 1) * (c2 - c1 + 1);
status(`Copied ${n} cell${n > 1 ? 's' : ''}.`); // SC 4.1.3
});
// PASTE: validate each cell, apply valid ones as a single undoable change
grid.addEventListener('paste', (e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
const matrix = parseTsv(text); // [[v, v], [v, v]]
const origin = activeCell(); // paste starts here
const changes = [], rejects = [];
matrix.forEach((row, i) => row.forEach((value, j) => {
const r = origin.row + i, c = origin.col + j;
if (!inBounds(r, c) || isReadonly(r, c)) { rejects.push({ r, c, why: 'read-only' }); return; }
const err = validate(c, value);
if (err) rejects.push({ r, c, why: err });
else changes.push({ r, c, from: rawValue(r, c), to: value });
}));
applyChanges(changes); // SC 3.3.4: one entry on the undo stack
rejects.forEach(({ r, c, why }) => markInvalid(r, c, why)); // SC 3.3.1
if (!rejects.length) {
status(`Pasted ${changes.length} cells. Press Control Z to undo.`);
} else {
// Focus the first reject so the user can fix it; say where the rest are
focusCell(rejects[0].r, rejects[0].c);
status(`Pasted ${changes.length} cells. ${rejects.length} rejected, starting at ${cellName(rejects[0])}: ${rejects[0].why}.`, 'assertive');
}
});
Copy the raw value. If a cell displays “€1.2k”, the clipboard should carry 1200, so it lands as a number in a spreadsheet. This is also where the visually hidden text from formatting numbers and units in table cells would otherwise leak into the copied text.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected announcement | AT-specific deviations |
|---|---|---|
Ctrl+C / Cmd+C on a range |
“Copied 12 cells.” | NVDA may also say “copied to clipboard” from its own hook |
Ctrl+C on one cell |
“Copied 1 cell.” | Some teams stay silent for one cell; confirm anyway |
Ctrl+V, all valid |
“Pasted 20 cells. Press Control Z to undo.” | Polite |
Ctrl+V, some rejected |
Focus to first reject; “…2 rejected, starting at South Q2: must be a number.” | Assertive, so it is not lost behind the cell read |
Ctrl+Z |
“Paste undone, 20 cells restored.” | — |
Integration context
Permalink to "Integration context"Every paste should be a single undo step, so one Ctrl+Z reverses all of it; see undo and cancel affordances for inline cell edits. Rejected cells use the same invalid marking — aria-invalid plus a description — as a failed inline edit in inline form validation inside editable table cells.
If the grid is inside a page with its own keyboard shortcuts, make sure none of them claim Ctrl+C or Ctrl+V; the collision rules are in keyboard shortcuts and command palettes.
Gotchas
Permalink to "Gotchas"Copy events in browse mode. A screen reader in browse mode handles Ctrl+C itself if there is a virtual selection, and your handler may never fire. Grids with role="grid" put readers into focus mode, where your handler works.
Pasting a larger block than fits. Decide whether overflow is clipped or rejected, and say which in the result message. Silent clipping is data loss.
Multi-line cell values. A notes column containing newlines breaks naïve TSV parsing. Quote on copy, and use a parser that honours quotes on paste.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Should cut be supported as well as copy?
Only in editable grids, and with the same care as delete: cut clears data, so it should be undoable in one step and confirmed with a message that says how many cells were cut. In read-only grids, handle cut as copy or ignore it.
How do I make Ctrl+C copy cells from a custom data grid?
Handle the copy event on the grid, build tab-separated text from the selected range’s raw values, set it with clipboardData.setData(‘text/plain’), call preventDefault, and confirm with a status message such as “Copied 12 cells”.
How should a paste with invalid values be reported?
Apply the valid cells, mark each rejected cell with aria-invalid and a description, move focus to the first rejected cell, and announce the counts and the first problem assertively. Make the whole paste undoable in one step.
Should copied values be the formatted text or the raw data?
The raw data. Formatted display text — currency symbols, abbreviations, hidden screen reader text — pastes badly into spreadsheets. Keep formatting for display and copy the underlying values.
Related
Permalink to "Related"- Announcing cell ranges — the selection being copied
- Undo and cancel affordances — making a paste reversible
- Inline validation in cells — how rejected cells are marked