Spreadsheet

Bidirectional grid with header resizing
A bidirectional grid demonstrating spreadsheet-like functionality with 1,000 rows and 1,000 columns. Features include sticky column headers (A, B, C...) and sticky row headers (1, 2, 3...). New: Drag the edges of headers to resize rows and columns.

How to build a feature like this

A spreadsheet scrolls on two independent axes, so it needs two virtualizations at once: rows are virtualized vertically like any list, and every mounted row must contain only the narrow slice of columns that fits the viewport. With direction="both" and a column-count, one VirtualScroll instance handles both axes - it mounts only the visible rows and, inside each row, only the visible columns (the slot's columnRange). The two header rails are pinned by different mechanisms: the column-header row sticks to the top through the engine's sticky-indices, while the row-header column sticks to the left through plain CSS position: sticky cells rendered inside every row. Because row and column sizes come from functions you own, resizing is a data change: write the new size into an override map and call the exposed refresh() so the engine rebuilds its offsets.

1. Reserve a header row and a header column in the grid model

In grid mode (direction="both") the items array is a flat list of rows and each row is rendered by the #item slot. Reserve index 0 on each axis for the headers: render ROWS + 1 items and set :column-count="COLS + 1" so the top row holds the column labels and column 0 of every row holds the row number - the data cells live in the 1..ROWS × 1..COLS region in between. The scroll host needs a definite width and height (both axes scroll, so give it a real box - an explicit size or a flex parent with min-height: 0). buffer-before/buffer-after (default 5) keep extra rows mounted around the viewport; gap/column-gap add spacing between rows/columns in the scroll math.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';

// Grid model: a flat array of ROW indexes; every row is rendered by the
// #item slot. Row 0 and column 0 are reserved for the headers, so the
// virtual grid is one row/column larger than the displayed data.
const ROWS = 1_000;
const COLS = 1_000;
const rows = Array.from({ length: ROWS + 1 }, (_, index) => index);

const rowHeight = 35;
const colWidth = 100;
const rowSize = (_row: unknown, _index: number) => rowHeight;
const colSize = (_index: number) => colWidth;

// Spreadsheet column names: 0 -> A ... 25 -> Z, 26 -> AA ...
function columnLabel(index: number): string {
  let label = '';
  for (let i = index; i >= 0; i = Math.floor(i / 26) - 1) {
    label = String.fromCharCode(65 + (i % 26)) + label;
  }
  return label;
}

function cellText(row: number, col: number) {
  if (row === 0) return columnLabel(col - 1); // column header
  if (col === 0) return String(row);          // row header
  return `R${row}C${col}`;
}
</script>

2. Render each row from its visible column slice

The #item slot exposes index, columnRange (the visible column interval, inclusive start / exclusive end), getColumnWidth(col), getCellAriaProps(col) and offset. Only rows inside the vertical window are mounted, and each mounted row contains only the columns in columnRange - loop that range instead of iterating every column. Cells must carry their exact size inline (column width, row height) because the row is a flex strip whose alignment depends on those widths; the first column (the pinned row-header cell, rendered separately below) is skipped in the loop. When the visible range starts past column 0 the pinned cell still occupies its flow slot, so the first mounted data cell is pulled back by that cell's width with a negative margin to keep every column at its virtual offset. ARIA roles are per-cell: rowheader for the row-number cells, columnheader for the header row and gridcell elsewhere, wired with v-bind="getCellAriaProps(col)" (the engine already gives the container role="grid" with aria-rowcount/ aria-colcount and each row wrapper its role="row"). Keep the slot light: rows and cells mount and unmount as you scroll.

<template>
  <VirtualScroll
    virtual-scrollbar
    ref="grid"
    class="sheet"
    direction="both"
    :items="rows"
    :item-size="rowSize"
    :column-count="COLS + 1"
    :column-width="colSize"
    :sticky-indices="[0]"
    aria-label="Spreadsheet grid"
  >
    <template #item="{ item: row, index, columnRange, isStickyActive, getCellAriaProps }">
      <div class="row" :class="{ 'row--header': index === 0, 'row--pinned': isStickyActive }">
        <!-- Column 0 (row header / corner): re-rendered in every row and
             pinned to the inline-start edge via CSS position: sticky. -->
        <div
          class="cell cell--row-head"
          :role="index === 0 ? 'gridcell' : 'rowheader'"
          v-bind="getCellAriaProps(0)"
          :style="{ width: colSize(0) + 'px', height: rowSize(null, index) + 'px' }"
        >
          {{ index === 0 ? '' : index }}
        </div>

        <!-- Only the visible columns (columnRange) are mounted; cells are
             sized to their column width so the flex strip stays aligned. -->
        <template v-for="n in columnRange.end - columnRange.start" :key="columnRange.start + n">
          <div
            v-if="columnRange.start + n > 1"
            class="cell"
            :class="{ 'cell--col-head': index === 0 }"
            :role="index === 0 ? 'columnheader' : 'gridcell'"
            v-bind="getCellAriaProps(columnRange.start + n - 1)"
            :style="{
              width: colSize(columnRange.start + n - 1) + 'px',
              height: rowSize(null, index) + 'px',
              marginInlineStart:
                n === 1 && columnRange.start > 0 ? -colSize(0) + 'px' : undefined,
            }"
          >
            {{ cellText(index, columnRange.start + n - 1) }}
          </div>
        </template>
      </div>
    </template>
  </VirtualScroll>
</template>

3. Pin the header row and the header column

The two axes pin differently. Vertically, list the header row in sticky-indices (here [0]): the engine sticks it to the top edge while you scroll down and reports isStickyActive on the slot while it is pinned. In both mode that index list applies to rows on the vertical axis only, so the horizontal rail is a CSS job: render column 0 as a dedicated first cell in every row and pin it with position: sticky; inset-inline-start: 0. It sticks because the rest of the row strip actually moves underneath it inside the scrollport - each mounted row is translated with the scroll offset and remounts its visible column window. Two details matter: the cell needs an opaque background (data cells slide under it while it overlaps them), and stacking must be ordered - row-versus-row layering is handled by the engine (a pinned row is marked virtual-scroll--sticky and raised with z-index 10 in the library stylesheet), while the cell z-indexes here only lift the pinned cell above its own row's data cells. The top-left corner cell combines both rails: it belongs to the sticky header row and carries the same sticky-cell class, raised above the column headers of its row.

.sheet {
  height: 480px; /* definite size - virtualization needs a real viewport */
  border: 1px solid #8884;
}

.row {
  display: flex;
  white-space: nowrap;
  background: #fff;
}
.row--header {
  background: #f3f4f6;
}

.cell {
  position: relative;
  box-sizing: border-box;
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-right: 1px solid #8883;
  border-bottom: 1px solid #8883;
}

/* The column-0 cell pins itself to the scrollport's inline-start edge with
   plain CSS sticky while the rest of the row scrolls beneath it. For that
   to work the row strip must actually move (VirtualScroll translates each
   mounted row with the scroll offset) and the cell needs an opaque
   background, or the sliding data cells show through underneath. */
.cell--row-head {
  position: sticky;
  inset-inline-start: 0;
  z-index: 2; /* orders this cell above its own row's data cells */
  background: #f3f4f6;
  font-weight: 700;
}
.row--header .cell--row-head {
  z-index: 3;
} /* corner above column headers */

/* Row-versus-row stacking belongs to the engine: while a sticky row is
   pinned it is marked .virtual-scroll--sticky and raised (z-index 10 in the
   library stylesheet) above the body rows scrolling under it. The z-indexes
   in this file only order cells within a single row. */

4. Resize rows and columns by editing their sizes

Because item-size and column-width are functions you provide, resizing needs no library mode: keep per-index override maps, read them first in the size functions, and let a drag write into them. Cells update reactively (their inline width/height re-evaluates), and calling the exposed refresh() makes the engine rebuild offsets, ranges and the total scroll extent from the new sizes - coalesce that call with requestAnimationFrame while dragging and issue one final refresh() on pointer-up. Attach the drag to thin hit areas: along the bottom edge of every row-header cell for row heights, along the inline-end edge of every column-header cell for column widths, both absolutely positioned with row-resize/col-resize cursors. A window-level pointermove/pointerup pair tracks the drag outside the cell, preventDefault() on pointerdown stops text selection, and the new size is clamped to a sensible minimum.

<script setup lang="ts">
// Resizing is a data change, not a layout mode: dragging writes new sizes
// into per-index override maps. Replace the plain rowSize()/colSize() from
// step 1 with these override-aware versions:
import { reactive, ref } from 'vue';

const grid = ref<InstanceType<typeof VirtualScroll> | null>(null); // template ref="grid"

const manualRowSizes = reactive<Record<number, number>>({});
const manualColSizes = reactive<Record<number, number>>({});
const rowSize = (_row: unknown, index: number) => manualRowSizes[index] ?? rowHeight;
const colSize = (index: number) => manualColSizes[index] ?? colWidth;

let dragging: { axis: 'row' | 'col'; index: number; start: number; size: number } | null = null;
let frame: number | null = null;

function startResize(event: PointerEvent, axis: 'row' | 'col', index: number) {
  event.preventDefault(); // keep the drag from selecting text / native drag
  dragging = {
    axis,
    index,
    start: axis === 'row' ? event.clientY : event.clientX,
    size: axis === 'row' ? rowSize(null, index) : colSize(index),
  };
  window.addEventListener('pointermove', onPointerMove);
  window.addEventListener('pointerup', stopResize);
}

function onPointerMove(event: PointerEvent) {
  if (!dragging) return;
  const delta = (dragging.axis === 'row' ? event.clientY : event.clientX) - dragging.start;
  const size = Math.max(20, dragging.size + delta); // clamp to a minimum
  if (dragging.axis === 'row') manualRowSizes[dragging.index] = size;
  else manualColSizes[dragging.index] = size;

  // Cell boxes update reactively; refresh() once per frame so the engine
  // rebuilds offsets, ranges and the scroll extent from the new sizes.
  if (!frame) {
    frame = requestAnimationFrame(() => {
      frame = null;
      grid.value?.refresh();
    });
  }
}

function stopResize() {
  dragging = null;
  window.removeEventListener('pointermove', onPointerMove);
  window.removeEventListener('pointerup', stopResize);
}
</script>
A
1
R1C1
2
R2C1
3
R3C1
4
R4C1
  • Scroll Status
  • Direction
    both
  • Current Item #
    - ×
  • Rendered Range #
    0:0
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y
  • Controls