Keyboard shortcuts + a11y

Ctrl+K command palette, Ctrl+/ cheat sheet, vim-style gg / G chord nav. Layers on top of the grid's WAI-ARIA grid pattern + roving tabindex.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Keyboard & Accessibility). See the SvGrid documentation for the full API.

About this example

Application-level shortcuts layered on the Svelte 5 data grid's built-in WAI-ARIA keyboard model: Ctrl or Cmd+K opens a command palette, Ctrl+/ toggles a cheat sheet, the vim chord g g jumps to the first row and G to the last, and Escape closes any overlay. Overlays use position fixed with no portals, and the document keydown listener bails when the target is a text input so typing g in the palette does not trigger the chord.

  • Ctrl/Cmd+K open / close the command palette
  • Ctrl/Cmd+/ toggle the keyboard cheat sheet
  • g g jump to the first row (vim chord)
  • G jump to the last row
  • Esc close any open overlay

Overlays use plain position: fixed with z-index 9999 (no portals) so they always render even when an ancestor creates a stacking context. The document keydown listener bails when the target is a text input, so typing "g" in the palette doesn't trigger the chord.

Imports, features and API used

Imports: svelte, @svgrid/grid, ../shared/seed

Table features registered: rowSortingFeature, columnFilteringFeature

SvGridApi methods called: api.clearAllFilters()

Frequently asked questions

How does the chord detection work?

A document keydown listener records the last key and its time; a second g within the chord window jumps to the first row through the API, and any other key resets it. Inputs are excluded by checking the event target.

What can the command palette do?

Its commands call the grid API: clear filters with api.clearAllFilters, sort by a column, jump to a row, toggle the cheat sheet. Adding a command is one entry in the list.

Why no portals for the overlays?

position: fixed with a high z-index renders above the page even when an ancestor creates a stacking context, so the palette shows inside any layout without moving DOM nodes.

Related documentation

Related articles

  • Accessibility from the Ground Up - How SvGrid bakes WAI-ARIA roles and keyboard navigation into the core - not as a post-launch checkbox, but as a design constraint that shaped every feature.
  • Keyboard Navigation and Accessibility in SvGrid - WAI-ARIA grid semantics, roving tabindex, and live-region announcements are built into SvGrid from the start. Here is what that means in practice and where custom cells require your attention.
  • Choosing the Most Accessible Svelte Data Grid - A practical guide to testing data grid accessibility - ARIA roles, keyboard navigation, focus management under virtualization, and screen-reader behavior - so you can verify claims yourself.

Source code (65-keyboard-shortcuts.svelte)

<script lang="ts">
  /**
   * 65. Keyboard shortcuts + ARIA grid pattern
   * -----------------------------------------
   *   - `Ctrl/Cmd+K` open / close the command palette
   *   - `Ctrl/Cmd+/` toggle the keyboard cheat sheet
   *   - `g g`        jump to the first row (vim chord)
   *   - `G`          jump to the last row
   *   - `Esc`        close any open overlay
   *
   * Overlays use plain `position: fixed` with z-index 9999 (no portals)
   * so they always render even when an ancestor creates a stacking
   * context. The document keydown listener bails when the target is a
   * text input, so typing "g" in the palette doesn't trigger the chord.
   */
  import { onMount, tick } from 'svelte'
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type ColumnDef,
    type SvGridApi,
  } from '@svgrid/grid'
  import { makePeople, type Person } from '../shared/seed'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
  const rows = makePeople(60)
  let api = $state<SvGridApi<typeof features, Person> | null>(null)

  let showPalette = $state(false)
  let showCheatsheet = $state(false)
  let paletteQuery = $state('')
  let paletteInput = $state<HTMLInputElement | null>(null)
  let lastChord = $state<string>('')
  let chordReset: number | null = null

  $effect(() => {
    if (showPalette) tick().then(() => paletteInput?.focus())
  })

  function isTyping(target: EventTarget | null): boolean {
    const el = target as HTMLElement | null
    if (!el) return false
    if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') return true
    return Boolean(el.isContentEditable)
  }

  let prev = ''
  function onKey(e: KeyboardEvent) {
    const isMod = e.ctrlKey || e.metaKey

    if (isMod && e.key.toLowerCase() === 'k') {
      e.preventDefault()
      showPalette = !showPalette
      showCheatsheet = false
      return
    }
    if (isMod && e.key === '/') {
      e.preventDefault()
      showCheatsheet = !showCheatsheet
      return
    }
    if (e.key === 'Escape' && (showPalette || showCheatsheet)) {
      e.preventDefault()
      showPalette = false
      showCheatsheet = false
      return
    }

    if (isTyping(e.target)) { prev = ''; return }

    if (e.key === 'G' && e.shiftKey && !isMod) {
      e.preventDefault()
      jumpToRow(rows.length - 1)
      lastChord = 'G'
      return
    }
    if (e.key === 'g' && !e.shiftKey && !isMod) {
      e.preventDefault()
      if (prev === 'g') {
        jumpToRow(0)
        lastChord = 'gg'
        prev = ''
        return
      }
      prev = 'g'
      if (chordReset) clearTimeout(chordReset)
      chordReset = window.setTimeout(() => { prev = '' }, 700)
      return
    }
    prev = ''
  }

  /** Scroll the grid's viewport so the target row is centred, then focus
   * its first cell. We compute the scroll offset from rowHeight so the row
   * is brought into view even if the virtualizer hasn't rendered it yet,
   * then wait a tick for the new range to render before focusing. */
  async function jumpToRow(i: number) {
    const root = document.querySelector('[role="grid"]') as HTMLElement | null
    if (!root) return
    const ROW_HEIGHT = 32
    // The grid's scroll container is the closest ancestor with overflow set.
    let scroller: HTMLElement | null = root.parentElement
    while (scroller && getComputedStyle(scroller).overflowY === 'visible') {
      scroller = scroller.parentElement
    }
    if (scroller) {
      const target = Math.max(0, i * ROW_HEIGHT - scroller.clientHeight / 2 + ROW_HEIGHT / 2)
      scroller.scrollTop = target
    }
    await tick()
    // Try a few times - the virtualizer may need an extra paint to render
    // the target row after scrollTop changes.
    for (let attempt = 0; attempt < 4; attempt += 1) {
      const cell = root.querySelector(`tbody tr[data-svgrid-row="${i}"] td`) as HTMLElement | null
      if (cell) {
        cell.focus()
        cell.scrollIntoView({ block: 'center' })
        return
      }
      await new Promise((r) => requestAnimationFrame(() => r(undefined)))
    }
  }

  onMount(() => {
    document.addEventListener('keydown', onKey, { capture: true })
    // Auto-focus the grid's first cell after mount so arrow keys / PgUp /
    // PgDn navigate (and auto-scroll) the moment the demo loads. Without
    // this, the user has to click into a cell first - then it looks like
    // "arrow keys don't scroll".
    void tick().then(() => {
      requestAnimationFrame(() => {
        const root = document.querySelector('[role="grid"]') as HTMLElement | null
        const cell = root?.querySelector(
          'tbody tr[data-svgrid-row="0"] td[data-svgrid-col="0"], tbody tr[data-svgrid-row="0"] td',
        ) as HTMLElement | null
        cell?.focus()
      })
    })
    return () => document.removeEventListener('keydown', onKey, { capture: true })
  })

  const SHORTCUTS = [
    { keys: ['Ctrl', 'K'],           action: 'Open / close command palette' },
    { keys: ['Ctrl', '/'],           action: 'Toggle this cheat sheet' },
    { keys: ['↑', '↓', '←', '→'],    action: 'Move active cell' },
    { keys: ['Home / End'],          action: 'First / last column in row' },
    { keys: ['PgUp / PgDn'],         action: 'Page up / down' },
    { keys: ['g', 'g'],              action: 'Jump to first row (chord)' },
    { keys: ['G'],                   action: 'Jump to last row' },
    { keys: ['Enter'],               action: 'Start editing the active cell' },
    { keys: ['Esc'],                 action: 'Cancel edit / close overlay' },
    { keys: ['Ctrl', 'C'],           action: 'Copy cell range as TSV' },
    { keys: ['Ctrl', 'V'],           action: 'Paste TSV into range' },
  ]

  type PaletteItem = { id: string; label: string; run: () => void }
  const allItems: PaletteItem[] = [
    { id: 'jump-top',  label: 'Jump to first row',         run: () => jumpToRow(0) },
    { id: 'jump-end',  label: 'Jump to last row',          run: () => jumpToRow(rows.length - 1) },
    { id: 'clear',     label: 'Clear all filters',         run: () => api?.clearAllFilters() },
    { id: 'cheats',    label: 'Show keyboard cheat sheet', run: () => (showCheatsheet = true) },
  ]
  const matches = $derived(
    allItems.filter((it) => it.label.toLowerCase().includes(paletteQuery.toLowerCase())),
  )

  function runItem(it: PaletteItem) {
    // Close the palette first, then defer the action so any panel it
    // opens (e.g. the cheatsheet) mounts cleanly after the palette is
    // unmounted. Without the tick the two overlays race during the same
    // microtask and the second one sometimes never renders.
    showPalette = false
    paletteQuery = ''
    tick().then(() => it.run())
  }
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="flex flex-wrap items-center gap-2 text-sm shrink-0">
    <button type="button" onclick={() => (showPalette = true)}
      class="kbd-btn rounded-md border px-3 py-1.5"
      >⌘ K - Command palette</button>
    <button type="button" onclick={() => (showCheatsheet = !showCheatsheet)}
      class="kbd-btn rounded-md border px-3 py-1.5"
      >⌘ / - Keyboard shortcuts</button>
    {#if lastChord}
      <span class="kbd-muted text-xs">last chord: <code>{lastChord}</code></span>
    {/if}
    <span class="kbd-muted ml-auto text-xs">Try <kbd>g g</kbd>, <kbd>G</kbd>, <kbd>⌘ K</kbd></span>
  </div>

  <div class="flex-1 min-h-0">
    <SvGrid responsive={true}
      columnResize
      data={rows}
      columns={[
        { field: 'firstName',  header: 'First',      width: 110 },
        { field: 'lastName',   header: 'Last',       width: 130 },
        { field: 'department', header: 'Department', width: 140 },
        { field: 'country',    header: 'Country',    width: 90 },
        { field: 'age',        header: 'Age',        width: 80 },
        { field: 'salary', header: 'Salary', width: 130,
          format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
      ] satisfies ColumnDef<typeof features, Person>[]}
      features={features}
      filterMode="menu"
      selectionMode="cell"
      showRowNumbers={true}
      virtualization={false}
      enableInlineEditing={true}
      enableCellSelection={true}
      rowHeight={32}
      containerHeight="100%"
      fitColumns={true}
      onApiReady={(next) => (api = next)}
    />
  </div>

  <!-- Command palette overlay (no portal - position:fixed + z-index 9999) -->
  {#if showPalette}
    <button type="button" aria-label="Close palette" class="kbd-backdrop"
      onclick={() => (showPalette = false)}></button>
    <div role="dialog" aria-modal="true" aria-label="Command palette" class="kbd-palette">
      <input
        bind:this={paletteInput}
        type="text"
        placeholder="Type a command…"
        bind:value={paletteQuery}
        onkeydown={(e) => { if (e.key === 'Enter' && matches[0]) runItem(matches[0]) }}
      />
      <ul>
        {#each matches as it (it.id)}
          <li>
            <button type="button" onclick={() => runItem(it)}>{it.label}</button>
          </li>
        {/each}
        {#if matches.length === 0}
          <li class="empty">No matches</li>
        {/if}
      </ul>
    </div>
  {/if}

  {#if showCheatsheet}
    <button type="button" aria-label="Close cheat sheet" class="kbd-backdrop"
      onclick={() => (showCheatsheet = false)}></button>
    <div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" class="kbd-cheatsheet">
      <header>
        <span>Keyboard shortcuts</span>
        <button type="button" class="kbd-close" aria-label="Close"
          onclick={() => (showCheatsheet = false)}>×</button>
      </header>
      <ul>
        {#each SHORTCUTS as s (s.action)}
          <li>
            <span>{s.action}</span>
            <span class="keys">
              {#each s.keys as k, i (i)}<kbd>{k}</kbd>{/each}
            </span>
          </li>
        {/each}
      </ul>
    </div>
  {/if}
</section>

<style>
  kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }

  .kbd-btn {
    border-color: var(--sg-border, #cbd5e1);
    background: var(--sg-bg, #fff);
    color: var(--sg-fg, #0f172a);
  }
  .kbd-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
  .kbd-muted { color: var(--sg-muted, #64748b); }

  /* The scrim stays a fixed dark wash - it dims whatever is behind the
   * overlay, so it must not follow the surface tokens. */
  .kbd-backdrop {
    position: fixed; inset: 0; z-index: 9998;
    background: rgba(15, 23, 42, 0.45);
    border: 0; padding: 0; cursor: default;
  }

  .kbd-palette {
    position: fixed; z-index: 9999;
    top: 15vh; left: 50%; transform: translateX(-50%);
    width: min(520px, 92vw);
    border-radius: 12px;
    border: 1px solid var(--sg-border, rgba(15, 23, 42, 0.15));
    background: var(--sg-bg, #ffffff);
    color: var(--sg-fg, #0f172a);
    box-shadow: 0 20px 50px rgba(15, 23, 42, 0.35);
    overflow: hidden;
  }
  .kbd-palette input {
    display: block; width: 100%;
    background: transparent; color: inherit;
    border: 0; border-bottom: 1px solid var(--sg-border, rgba(15, 23, 42, 0.08));
    padding: 14px 16px; font-size: 15px;
    outline: none;
  }
  .kbd-palette input::placeholder { color: var(--sg-muted, rgba(15, 23, 42, 0.45)); }
  .kbd-palette ul { list-style: none; margin: 0; padding: 4px 0; max-height: 360px; overflow: auto; }
  .kbd-palette li button {
    display: block; width: 100%; text-align: left;
    background: transparent; border: 0; color: inherit;
    padding: 8px 16px; font-size: 14px; cursor: pointer;
  }
  .kbd-palette li button:hover { background: var(--sg-row-hover-bg, rgba(99,102,241,0.12)); }
  .kbd-palette li.empty { padding: 12px 16px; color: var(--sg-muted, rgba(15, 23, 42, 0.45)); font-size: 13px; }

  .kbd-cheatsheet {
    position: fixed; z-index: 9999;
    top: 80px; right: 24px;
    width: min(380px, 92vw);
    border-radius: 12px;
    border: 1px solid var(--sg-border, rgba(15, 23, 42, 0.15));
    background: var(--sg-bg, #ffffff);
    color: var(--sg-fg, #0f172a);
    box-shadow: 0 20px 50px rgba(15, 23, 42, 0.35);
    overflow: hidden;
  }
  .kbd-cheatsheet header {
    display: flex; align-items: center; justify-content: space-between;
    font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
    color: var(--sg-muted, rgba(15, 23, 42, 0.55));
    padding: 12px 16px;
    border-bottom: 1px solid var(--sg-border, rgba(15, 23, 42, 0.08));
  }
  .kbd-close {
    background: transparent; border: 0; color: inherit; cursor: pointer;
    font-size: 18px; line-height: 1; padding: 0 4px;
  }
  .kbd-cheatsheet ul { list-style: none; margin: 0; padding: 6px 0; }
  .kbd-cheatsheet li {
    display: flex; align-items: center; justify-content: space-between;
    padding: 6px 16px; font-size: 13px;
  }
  .kbd-cheatsheet .keys { display: inline-flex; gap: 4px; }
  .kbd-cheatsheet kbd {
    border: 1px solid var(--sg-border, rgba(15, 23, 42, 0.18));
    background: var(--sg-bg-subtle, var(--sg-header-bg, rgba(241, 245, 249, 0.7)));
    border-radius: 4px;
    padding: 2px 6px; font-size: 11px; min-width: 18px; text-align: center;
  }
</style>

View this example on GitHub

More Keyboard & Accessibility examples

  • Accessibility - WAI-ARIA grid, keyboard navigation, aria-live announcements, focus toggle.