Data Browser

Sticky sections, search and jump navigation
A grouped directory browser combining sticky section headers (the next header pushes the stuck one out of view), instant search that rebuilds the section index, and jump-to navigation with configurable alignment. Switch between 4 datasets and sizes up to 250,000 records - the telemetry strip shows the rendered window stays constant no matter the dataset size. Toggle dynamic sizing to reveal each record's full description and let ResizeObserver measure the variable row heights (the Knowledge Base dataset has the widest variance).

How to build a feature like this

A grouped, searchable directory is still one virtualized vertical list: the array passed to items holds rows, and turning some of them into section headers is a data-modeling decision rather than a library mode. Each header is an ordinary row object flagged as such, and its index is listed in sticky-indices; the engine then pins that row to the top edge while you scroll and lets the next pinned row push the current one out of view - the familiar iOS-style section effect. Because filtering and section jumps only ever reshape the row array and call scrollToIndex(), the hard part of this UI is data shaping, and the virtualizer's job stays constant: render the visible rows of whatever array it is given.

1. Size the scroll container

Virtualization needs a viewport of known size: when the host element is not height-constrained it grows with its content and never produces scroll events. Give the list an explicit height (the demo's resizable card sizes it with flex, flex-1 min-h-0); in your own layout any explicit or viewport-relative height works. In flex/grid parents add min-height: 0 so the box may shrink below its content.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. 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';
import { ref } from 'vue';

type Row =
  | { kind: 'header'; title: string; count: number }
  | { kind: 'record'; id: number; name: string };

// Grouping is a data concern: one flat array where a section is a header row
// followed by its records; the virtualizer only sees rows.
const items = ref<Row[]>([
  { kind: 'header', title: 'Section A', count: 2 },
  { kind: 'record', id: 1, name: 'Ada Lovelace' },
  { kind: 'record', id: 2, name: 'Alan Turing' },
]);

// Header row indices pin to the top edge while scrolling; consecutive ones
// push each other out iOS-style when the next header reaches the top.
const stickyIndices = items.value
  .flatMap((row, index) => (row.kind === 'header' ? [index] : []));

const rowSize = (item: Row, _index: number) => (item.kind === 'header' ? 48 : 64);
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    class="browser"
    :items="items"
    :item-size="rowSize"
    :sticky-indices="stickyIndices"
    :gap="4"
    aria-label="Sectioned list"
  >
    <template #item="{ item, isStickyActive }">
      <div v-if="item.kind === 'header'" class="section-header" :class="{ 'section-header--pinned': isStickyActive }">
        {{ item.title }} · {{ item.count }} records
      </div>
      <div v-else class="record">#{{ item.id }} - {{ item.name }}</div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.browser {
  height: 480px;
  border: 1px solid #8884;
} /* definite viewport */
.section-header,
.record {
  box-sizing: border-box; /* wrappers are sized to :item-size: fill them */
  height: 100%;
  display: flex;
  align-items: center;
  padding-inline: 0.75rem;
  border-bottom: 1px solid #8883;
}
.section-header {
  font-weight: 700;
  background: #eee;
}
.section-header--pinned {
  box-shadow: 0 2px 6px #0003;
} /* isStickyActive */
</style>

2. Model every section as rows in one flat array

Flatten the grouped data so each section is a header row followed by its record rows; the same #item slot renders both, branching on a type flag on the row object. Headers stay ordinary items - they occupy their own index, scroll with the list, and can be addressed by scrollToIndex(). Build the sticky-indices list from the header positions: rows at those indices pin to the viewport top, and when several sticky rows are consecutive an approaching header pushes the previous one out of view. While a row is pinned the slot reports isStickyActive - the hook for the elevated "stuck" look. Keep the data nested and derive the flat array with a computed when your source is grouped by a field or folder tree.

3. Choose a sizing strategy

The scroll math needs a height for every row; three strategies, in increasing cost: a single numeric item-size when all rows share one height (positions are then pure arithmetic); a function (item, index) => number when heights are known per row but differ (headers vs. records, compact vs. expanded rows); or null/0/undefined for fully dynamic sizing, where each mounted row is measured with a ResizeObserver and the layout follows the real content. With known sizes the returned value is a contract: the engine sizes each row wrapper to it, so the slot root must fill the wrapper (height: 100%, borders inside via box-sizing: border-box). With dynamic sizing the contract is inverted - let the content decide the height and do not force one on the slot root. Dynamic measurement costs per mounted row and corrects as you scroll; pass default-item-size so the first frame and the scrollbar are not empty. gap adds spacing between rows in the scroll math, and buffer-before/buffer-after (default 5) keep extra rows mounted around the viewport so fast scrolling does not flash blanks.

// Content-driven heights: pass null (or 0 / undefined) as :item-size - every
// mounted row is then measured with a ResizeObserver and the layout follows
// the actual content (wrapped text, expandable rows, ...). Give the engine a
// fallback estimate so the first frame and scrollbar are not empty.
const dynamicSizing = ref(false);

<template>
  <VirtualScroll
    virtual-scrollbar
    :items="items"
    :item-size="dynamicSizing ? null : rowSize"
    :default-item-size="64"
    :gap="4"
    :sticky-indices="stickyIndices"
  >
    <template #item="{ item, isStickyActive }">
      &lt;!-- same slot markup as before -->
    </template>
  </VirtualScroll>
</template>

4. Filter by deriving new arrays

Search is not a library feature: on every query, compute a new items array plus a matching sticky-indices list and pass them down - the component re-ranges automatically when the props change. Re-emit a header (cloned, with its count rebuilt from matches) only when its section keeps at least one matching record, because the sticky list must refer to the filtered array's indices; keep the unfiltered arrays as the fallback for an empty query. Then bind the derived arrays - :items="display.rows" with :sticky-indices="display.sticky" - instead of the originals.

<script setup lang="ts">
// Filtering happens BEFORE virtualization: derive a fresh row array and a
// matching sticky-index list; the component re-ranges whenever a prop
// changes. (Add `computed` to the vue import, plus the type import.)
import type { ScrollAlignment } from '@pdanpdan/virtual-scroll';
import { computed } from 'vue';

const query = ref('');
const display = computed(() => {
  const q = query.value.trim().toLowerCase();
  if (!q) return { rows: items.value, sticky: stickyIndices };

  const rows: Row[] = [];
  const sticky: number[] = [];
  let header: Row | null = null;
  let headerIndex = -1;

  for (const row of items.value) {
    if (row.kind === 'header') {
      header = { ...row, count: 0 };
      headerIndex = -1;
      continue;
    }
    if (!row.name.toLowerCase().includes(q)) continue;
    if (headerIndex === -1) {
      headerIndex = rows.length; // header goes live with its first match
      sticky.push(headerIndex);
      rows.push(header!);
    }
    const current = rows[headerIndex];
    if (current && current.kind === 'header') current.count += 1;
    rows.push(row);
  }
  return { rows, sticky };
});

// Programmatic jumps use the methods exposed on the component ref.
const list = ref<InstanceType<typeof VirtualScroll> | null>(null);

function jumpToSection(headerIndex: number) {
  list.value?.scrollToIndex(headerIndex, null, { align: 'start' });
}
function jumpToRow(rowIndex: number, align: ScrollAlignment) {
  list.value?.scrollToIndex(rowIndex, null, { align });
}
</script>

5. Jump to a section or a row

The component instance (template ref) exposes scrollToIndex(rowIndex, colIndex, options) - for a vertical list pass null for the column. Alignment options make jumps land predictably: start puts the row at the top edge, center centers it, end pins it to the bottom, and auto (the default) scrolls only when the row is not already fully visible. Jumping to a section is scrolling to that header row's index, so the section picker, a "jump to #520" input and random-access buttons are all the same call. If a dataset or query change invalidates stored targets (header indices shift after filtering), clear the selection in a watch on those inputs.

Dataset
Size
Index
Section A364 items
Contacts dataset
Section A
Records
9,880
Sections
26
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y