Search & Highlight
How to build a feature like this
Only the visible window of a virtualized list exists in the DOM, so search cannot scan rendered content the way a browser search scans a document. Search the data instead, then reconcile the results with the virtual window. Two shapes cover most needs: filtering, where a computed subset replaces :items and the results become the whole list; and find-and-jump, where :items stays the full dataset, matches are stored as indices, and navigation moves the viewport with scrollToIndex(). Highlighting applies to whatever rows are mounted at the moment and is repainted as the window moves - the CSS Custom Highlight API does that without touching row markup.
1. Filter: replace :items with a computed subset
Derive the visible rows from the query with a computed and bind it to :items. Every keystroke yields a new array instance, which is the supported update path: the engine watches the identity and length of :items, re-initializes sizes, and re-reads the scroll offset after the DOM updates. Rows are positioned by index, so after a replacement the same pixel offset lands on the same index - which now holds a different record; when the filtered list is shorter than the current offset, the viewport clamps to the new end. Keep rows uniform (numeric item-size) so the offset math stays exact, and remember that the rendered index is a position in the filtered array, not in the original data - keep an id on each row when you need the source record. This shape fits search-as-filter UIs where the results are the list.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import { computed, ref } from 'vue';
import '@pdanpdan/virtual-scroll/style.css';
const query = ref('');
const rows = ref(
Array.from({ length: 50_000 }, (_, i) => ({
id: i,
text: `Item #${ i } ${ i % 3 === 0 ? 'alpha' : i % 3 === 1 ? 'beta' : 'gamma' }`,
})),
);
// Every keystroke produces a NEW filtered array. VirtualScroll watches the
// :items identity (and length), re-initializes sizes and re-reads the scroll
// offset, so passing a fresh array each time is the supported update path.
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
return q
? rows.value.filter((row) => row.text.toLowerCase().includes(q))
: rows.value;
});
</script>
<template>
<div class="page">
<input v-model="query" type="search" placeholder="Filter rows..." />
<VirtualScroll class="list" :items="filtered">
<!-- Rows are keyed by index: after a filter the same pixel offset lands
on the same index, which now holds a different record. -->
<template #item="{ index, item }">
<div class="row">{{ index }} - {{ item.text }}</div>
</template>
</VirtualScroll>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list {
height: 480px;
border: 1px solid oklch(50% 0 0 / 0.2);
}
.row {
box-sizing: border-box;
height: 40px;
display: flex;
align-items: center;
padding-inline: 1rem;
}
</style>2. Find: keep the full dataset, navigate between matches
When matches must be found anywhere in the dataset while original numbering and positions stay put, keep :items untouched and compute matching indices over the data; per-row state and absolute indices then stay stable while the user walks the hits. Move the viewport with the exposed scrollToIndex(row, col, options) - vertical lists pass null for the column; align: 'auto' scrolls only when the row is offscreen, and the default behavior is already 'smooth'. Enforce a minimum query length so single characters do not scan the whole dataset, and lowercase both sides for a case-insensitive match. On a query change, reset the cursor to the first match and jump to it.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import { computed, ref } from 'vue';
import '@pdanpdan/virtual-scroll/style.css';
const listRef = ref<InstanceType<typeof VirtualScroll> | null>(null);
const query = ref('');
const current = ref(-1); // index into `matches`
// matches = ORIGINAL indices into the full :items array (search the data,
// not the DOM - only the visible window is ever mounted).
const rows = ref(Array.from({ length: 50_000 }, (_, i) => ({
id: i, text: `Item #${ i }${ i % 100 === 42 ? ' - ULTIMATE ANSWER' : '' }`,
})));
const matches = computed(() => {
const q = query.value.trim().toLowerCase();
if (q.length < 2) return [];
const out: number[] = [];
for (let i = 0; i < rows.value.length; i++) if (rows.value[i]!.text.toLowerCase().includes(q)) out.push(i);
return out;
});
function jump(step: 1 | -1) {
if (matches.value.length === 0) return;
current.value = (current.value + step + matches.value.length) % matches.value.length;
listRef.value?.scrollToIndex(matches.value[current.value], null, { align: 'auto' }); // (row, col, options)
}
</script>
<template>
<div class="page">
<div class="bar">
<input
v-model="query"
type="search"
placeholder="Search..."
@keydown.enter="jump(1)"
/>
<span>{{ matches.length ? current + 1 : 0 }}/{{ matches.length }}</span>
<button :disabled="!matches.length" @click="jump(-1)">Prev</button> <button :disabled="!matches.length" @click="jump(1)">Next</button>
</div>
<VirtualScroll
ref="listRef"
class="list"
:items="rows"
:item-size="60"
>
<template #item="{ item, index }">
<div class="row" :class="{ 'row--current': index === matches[current] }">
#{{ index }} {{ item.text }}
</div>
</template>
</VirtualScroll>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list {
height: 480px;
border: 1px solid oklch(50% 0 0 / 0.2);
}
.row {
box-sizing: border-box;
height: 60px;
display: flex;
align-items: center;
padding-inline: 1rem;
}
.row--current {
outline: 2px solid oklch(60% 0.2 25 / 0.7);
outline-offset: -2px;
}
</style>3. Highlight the rows that are actually mounted
A match becomes visible only after its row mounts, so highlight application must run against the live DOM of the mounted window. Walk the text nodes under the container root with a TreeWalker, turn each query occurrence into a Range, and register the ranges with the CSS Custom Highlight API under named highlights. Every virtualized row wrapper carries data-index and the class .virtual-scroll-item, which lets you classify a range as the current match versus the other results. Re-run the walk whenever the query or the current match changes, and whenever scrolling changes the mounted window (watch the rendered range exposed by the @scroll event); apply after nextTick(). Only mounted rows can produce ranges, so the pass is bounded by the window size, not the dataset size.
// CSS Custom Highlight API: paint-level marks that never touch row markup.
const supportsHighlight = typeof CSS !== 'undefined' && 'highlights' in CSS;
export function applyHighlights(
container: HTMLElement,
query: string,
currentMatchIndex: number | null,
) {
if (!supportsHighlight) {
return;
}
const q = query.trim().toLowerCase();
CSS.highlights.clear();
if (q.length < 2) {
return;
}
const results: Range[] = [];
const current: Range[] = [];
// Walk only the mounted text nodes. Each hit becomes a Range; the owning
// row is identified through the data-index attribute every virtualized row
// wrapper carries, so the active match can be styled differently.
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = (node.textContent ?? '').toLowerCase();
let at = text.indexOf(q);
while (at !== -1) {
const range = new Range();
range.setStart(node, at);
range.setEnd(node, at + q.length);
const row = (node.parentElement as HTMLElement | null)?.closest('.virtual-scroll-item');
const rowIndex = row ? Number.parseInt(row.getAttribute('data-index') ?? '-1', 10) : -1;
(rowIndex === currentMatchIndex ? current : results).push(range);
at = text.indexOf(q, at + q.length);
}
}
CSS.highlights.set('search-results', new Highlight(...results));
CSS.highlights.set('search-current', new Highlight(...current));
}4. Style the marks and cover older engines
::highlight() rules apply at paint time, so matches are colored without inserting elements and rows do not re-render while scrolling. For engines without the Custom Highlight API, fall back to wrapping matches in a <mark> during row rendering: escape the query's regular-expression metacharacters, build a RegExp, and emit the highlighted HTML through v-html. Keep server and initial client markup identical - for example, render the raw text until the component has mounted - so hydration does not mismatch.
/* ::highlight paints only the matched text inside the named Highlight
object - no markup is inserted, so nothing re-renders on scroll. */
::highlight(search-results) {
background-color: oklch(80% 0.1 230 / 0.55);
color: inherit;
}
::highlight(search-current) {
background-color: oklch(55% 0.2 25 / 0.85);
color: white;
}
/* Fallback styling for engines without the Custom Highlight API: the row
template then emits <mark class="search-hit"> around matches instead. */
mark.search-hit {
background-color: oklch(80% 0.1 230 / 0.55);
color: inherit;
border-radius: 2px;
}- Scroll Status
- Directionvertical
- Current Item #-
- Rendered Range #0:0
- Total Size (px)0h
- Viewport Size (px)0h
- Scroll Offset (px)0y