Right-click context menu
Custom row context menu (copy, duplicate, move up/down, delete) wired via a contextmenu listener + the wrapper's data-svgrid-row attribute.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Selection & Clipboard). See the SvGrid documentation for the full API.
About this example
A right-click context menu on rows of the Svelte 5 data grid with Edit, Duplicate, Move up, Move down and Delete. A single contextmenu listener on the wrapper reads the data-svgrid-row attribute to find the row, a floating menu opens at the pointer, and the actions call api.addRow, api.removeRow or rebuild the data array. The grid does not impose its own row menu, so you can stack whatever menu your app already has.
Right-click on any row to open a context menu with row-level actions:
- Edit row title (focuses the first cell in edit mode)
- Duplicate row (api.addRow)
- Move up / down (rebuilds the data array)
- Delete row (api.removeRow)
The grid does not own a context menu - intentionally - so consumers can stack their own. The pattern here uses a single floating menu positioned at the pointer event, anchored to the row id.
Imports, features and API used
Imports: svelte, @svgrid/grid, ../shared/seed
Table features registered: rowSortingFeature, rowSelectionFeature
Columns: firstName (First), lastName (Last), department (Department), country (Country), salary (Salary)
SvGridApi methods called: api.addRow(), api.removeRow()
Frequently asked questions
How do I find which row was right-clicked?
Listen for contextmenu on the grid wrapper and walk up from event.target to the element carrying data-svgrid-row. Its value is the row id, which the menu keeps as its anchor.
How are rows added and removed?
Duplicate calls api.addRow with a copy of the row, Delete calls api.removeRow with its id. Move up and down rebuild the data array in the new order, since order lives in your data.
Does the grid ship a context menu of its own?
There is a contextMenu prop for the built-in clipboard commands, but the grid deliberately does not own row-level actions, so your app can supply a menu that matches the rest of its UI.
Related documentation
Related articles
- A Right-Click Context Menu for Your Svelte Data Grid - Build a right-click context menu that wires row actions, handles multi-row selection, and stays out of the way of SvGrid's own event handling.
- Copy a Cell Range to the Clipboard in SvGrid - How SvGrid copies selected cell ranges as tab-separated values that paste cleanly into Excel and Google Sheets, plus headers, programmatic copy, and format control.
- Row Selection and Clipboard Copy-Paste in SvGrid - How checkbox row selection, cell range copy, and Excel-compatible clipboard output work together in SvGrid - with a full working example.
Source code (67-context-menu.svelte)
<script lang="ts">
/**
* 67. Context menu - row-level operations
* --------------------------------------
* Right-click on any row to open a context menu with row-level actions:
*
* - Edit row title (focuses the first cell in edit mode)
* - Duplicate row (api.addRow)
* - Move up / down (rebuilds the data array)
* - Delete row (api.removeRow)
*
* The grid does not own a context menu - intentionally - so consumers
* can stack their own. The pattern here uses a single floating menu
* positioned at the pointer event, anchored to the row id.
*/
import { onMount } from 'svelte'
import {
SvGrid,
tableFeatures,
rowSortingFeature,
rowSelectionFeature,
type GridColumns,
type SvGridApi,
} from '@svgrid/grid'
import { makePeople, type Person } from '../shared/seed'
const features = tableFeatures({ rowSortingFeature, rowSelectionFeature })
let rows = $state<Person[]>(makePeople(30))
let api = $state<SvGridApi<typeof features, Person> | null>(null)
type Menu = { x: number; y: number; rowIndex: number; name: string } | null
let menu = $state<Menu>(null)
/** Approximate menu dimensions for viewport-edge clamping. */
const MENU_W = 220
const MENU_H = 240
/**
* Wire a `contextmenu` listener on the grid root. We translate the
* target element back into a row index via the `[data-svgrid-row]`
* attribute the wrapper writes onto each <tr>. The menu uses
* `position: fixed`, so clientX/clientY are exactly what we need - no
* relative-parent math, no off-screen surprises.
*/
onMount(() => {
const root = document.querySelector('[role="grid"]')
if (!root) return
const handler = (e: Event) => {
const ev = e as MouseEvent
const tr = (ev.target as HTMLElement | null)?.closest('[data-svgrid-row]')
if (!tr) return
ev.preventDefault()
const ix = Number((tr as HTMLElement).dataset.svgridRow)
if (!Number.isFinite(ix)) return
const r = rows[ix]
const name = r ? `${r.firstName} ${r.lastName}` : `Row ${ix + 1}`
// Clamp to viewport so the menu never opens past the right / bottom edge.
const vw = window.innerWidth
const vh = window.innerHeight
const x = Math.min(ev.clientX, vw - MENU_W - 8)
const y = Math.min(ev.clientY, vh - MENU_H - 8)
menu = { x, y, rowIndex: ix, name }
}
root.addEventListener('contextmenu', handler)
const onDocClick = (e: MouseEvent) => {
// Don't close if the click landed inside the menu itself.
const t = e.target as HTMLElement | null
if (t?.closest('.ctx-menu')) return
menu = null
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') menu = null }
const onScroll = () => (menu = null)
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKey)
window.addEventListener('scroll', onScroll, true)
return () => {
root.removeEventListener('contextmenu', handler)
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll, true)
}
})
function dup(rowIndex: number) {
const r = rows[rowIndex]
if (!r) return
api?.addRow({ ...r, id: `${r.id}_copy` }, rowIndex + 1)
menu = null
}
function del(rowIndex: number) { api?.removeRow(rowIndex); menu = null }
function moveUp(rowIndex: number) {
if (rowIndex === 0) return
const next = rows.slice()
;[next[rowIndex - 1], next[rowIndex]] = [next[rowIndex]!, next[rowIndex - 1]!]
rows = next
menu = null
}
function moveDown(rowIndex: number) {
if (rowIndex >= rows.length - 1) return
const next = rows.slice()
;[next[rowIndex + 1], next[rowIndex]] = [next[rowIndex]!, next[rowIndex + 1]!]
rows = next
menu = null
}
function copyTitle(rowIndex: number) {
const r = rows[rowIndex]
if (!r) return
void navigator.clipboard?.writeText(`${r.firstName} ${r.lastName}`)
menu = null
}
const columns: GridColumns<Person> = [
{ field: 'firstName', header: 'First', width: 120 },
{ field: 'lastName', header: 'Last', width: 140 },
{ field: 'department', header: 'Department', width: 140 },
{ field: 'country', header: 'Country', width: 90 },
{ field: 'salary', header: 'Salary', width: 130,
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
]
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3 relative">
<p class="text-sm shrink-0 ctx-note">
<strong>Right-click any row</strong> for the context menu. ESC closes it.
</p>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
filterMode="menu"
selectionMode="row"
showRowNumbers={true}
enableInlineEditing={true}
enableCellSelection={false}
rowHeight={36}
containerHeight="100%"
fitColumns={true}
onApiReady={(next) => (api = next)}
/>
</div>
{#if menu}
<div
role="menu"
class="ctx-menu"
style="left: {menu.x}px; top: {menu.y}px;"
aria-label={`Actions for ${menu.name}`}
>
<div class="ctx-menu-head">
<span class="ctx-menu-row-label">Row {menu.rowIndex + 1}</span>
<span class="ctx-menu-name">{menu.name}</span>
</div>
<button type="button" role="menuitem" class="ctx-menu-item" onclick={() => copyTitle(menu!.rowIndex)}>
<span class="ctx-menu-icon">⧉</span>
<span class="ctx-menu-text">Copy name</span>
<kbd class="ctx-menu-kbd">⌘C</kbd>
</button>
<button type="button" role="menuitem" class="ctx-menu-item" onclick={() => dup(menu!.rowIndex)}>
<span class="ctx-menu-icon">+</span>
<span class="ctx-menu-text">Duplicate row</span>
</button>
<div class="ctx-menu-sep"></div>
<button type="button" role="menuitem" class="ctx-menu-item"
disabled={menu.rowIndex === 0} onclick={() => moveUp(menu!.rowIndex)}>
<span class="ctx-menu-icon">↑</span>
<span class="ctx-menu-text">Move up</span>
</button>
<button type="button" role="menuitem" class="ctx-menu-item"
disabled={menu.rowIndex >= rows.length - 1} onclick={() => moveDown(menu!.rowIndex)}>
<span class="ctx-menu-icon">↓</span>
<span class="ctx-menu-text">Move down</span>
</button>
<div class="ctx-menu-sep"></div>
<button type="button" role="menuitem" class="ctx-menu-item ctx-menu-danger" onclick={() => del(menu!.rowIndex)}>
<span class="ctx-menu-icon">🗑</span>
<span class="ctx-menu-text">Delete row</span>
<kbd class="ctx-menu-kbd">⌫</kbd>
</button>
</div>
{/if}
</section>
<style>
.ctx-note { color: var(--sg-muted, #64748b); }
/* Use position: fixed so clientX/clientY map 1:1 - no relative-parent
math, no off-screen drift. --sg-* tokens follow the active theme. */
.ctx-menu {
position: fixed;
z-index: 50;
min-width: 220px;
border-radius: 8px;
padding: 4px;
background: var(--sg-bg, #ffffff);
color: var(--sg-fg, #0f172a);
border: 1px solid var(--sg-border, #cbd5e1);
box-shadow:
0 10px 30px rgba(15, 23, 42, 0.18),
0 2px 6px rgba(15, 23, 42, 0.10);
animation: ctx-menu-pop 90ms ease-out;
user-select: none;
}
@keyframes ctx-menu-pop {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.ctx-menu-head {
display: flex; flex-direction: column;
padding: 8px 10px 6px;
border-bottom: 1px solid var(--sg-border, #e2e8f0);
margin-bottom: 4px;
}
.ctx-menu-row-label {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--sg-muted, #64748b);
}
.ctx-menu-name {
font-size: 13px; font-weight: 600;
color: var(--sg-fg, #0f172a);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ctx-menu-item {
display: flex; align-items: center; gap: 10px;
width: 100%;
padding: 7px 10px;
border: 0; background: transparent;
color: var(--sg-fg, #0f172a);
font-size: 13px; text-align: left; cursor: pointer;
border-radius: 5px;
transition: background 80ms ease;
}
.ctx-menu-item:hover:not(:disabled),
.ctx-menu-item:focus-visible:not(:disabled) {
background: var(--sg-row-hover-bg, rgba(148, 163, 184, 0.14));
outline: none;
}
.ctx-menu-item:disabled {
opacity: 0.45;
cursor: default;
}
.ctx-menu-icon {
width: 18px; text-align: center;
color: var(--sg-muted, #64748b);
font-size: 13px; line-height: 1;
}
.ctx-menu-text { flex: 1; }
.ctx-menu-kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10.5px;
padding: 1px 5px;
border: 1px solid var(--sg-border, #cbd5e1);
border-radius: 3px;
color: var(--sg-muted, #64748b);
background: var(--sg-header-bg, transparent);
}
.ctx-menu-sep {
height: 1px;
background: var(--sg-border, #e2e8f0);
margin: 4px 6px;
}
.ctx-menu-danger { color: #dc2626; }
.ctx-menu-danger .ctx-menu-icon { color: #dc2626; }
.ctx-menu-danger:hover:not(:disabled),
.ctx-menu-danger:focus-visible:not(:disabled) {
background: rgba(220, 38, 38, 0.10);
}
:global(html[data-theme='dark']) .ctx-menu-danger { color: #f87171; }
:global(html[data-theme='dark']) .ctx-menu-danger .ctx-menu-icon { color: #f87171; }
:global(html[data-theme='dark']) .ctx-menu-danger:hover:not(:disabled),
:global(html[data-theme='dark']) .ctx-menu-danger:focus-visible:not(:disabled) {
background: rgba(248, 113, 113, 0.16);
}
</style>More Selection & Clipboard examples
- Bulk-action bar - selectionBar floats a bar over the grid while rows are selected - the count, your actions, an overflow menu, and Clear - the pattern an issue tracker uses for bulk edit. position pins it to the bottom (default) or top edge. Actions receive the selected rows in display order, and hidden / disabled re-check against the live selection so buttons appear and grey out as the set changes.
- Selection + copy/paste - Row + cell-range selection with TSV clipboard round-trip.
- Selection API + events - Drive cell selection with api.selectCells / api.getSelected; subscribe to changes via onCellSelectionChange. Live SUM/AVG/MIN/MAX panel + event log + copy-as-TSV.
- Status bar (range aggregates) - Excel-style bar under the grid with live aggregates of the selected cell range: Count, Sum, Avg, Min, Max. Enable with statusBar + enableCellSelection, then drag a rectangle across numeric cells. Choose the aggregate set via statusBar={ aggregates }.
- Range selection (Excel-style) - Drag any rectangle of cells, or Ctrl/Cmd+drag to add MORE ranges - all stay highlighted and copy together. Toolbar issues common ranges; live SUM/AVG/MIN/MAX/COUNT status bar; copy as TSV.