Virtual list
A list of 10,000 rows where only a screenful is ever in the DOM. It’s built entirely from kerf’s optional, tree-shakeable companion subpaths — none of which touch the ~12 KB core until you import them:
kerfjs/list— virtualization.bindList(scrollEl, source, { virtualize: { rowHeight } })renders only the rows in the scroll window (plus a smalloverscan) into an inner sizer, with padding that keeps the scrollbar honest. Scroll through 10,000 items and the DOM never holds more than a screenful.kerfjs/timing— debounced search. The search box drives asignal, anddebouncedSignal(query, 200)trails it by 200 ms. The filtered list is acomputed()over the debounced query, so the filter recomputes only after typing settles — not on every keystroke.kerfjs/overlay— confirm + toast. Each row’s Delete opens a promise-basedconfirm()dialog; on confirm, the row is removed from the source and atoast()acknowledges it. kerf ships no CSS — the overlay and toast are styled in the app’s own stylesheet.
What to look at:
- The list source is a
computed().filteredderives from the data signal + the debounced query. A filter or a delete just reassigns a signal; kerf re-windows the visible slice. No manual DOM bookkeeping. - Fixed-height windowing is O(1). With
rowHeight: 36kerf mapsscrollTopstraight to the first visible index — no per-row measurement. (For content whose height is only known after layout,bindListalso takesrowHeight: { estimate }+observeRowHeights; see the API reference.) delegate()for both flows. One delegatedinputlistener updates the query signal; one delegatedclicklistener matches every row’s Delete button by walk-upclosest()— so the handlers survive the rows scrolling in and out of the window.
// site/src/examples/complete/virtual-list/main.tsx (excerpt — full source on GitHub)import { computed, delegate, mount, signal } from 'kerfjs';import { bindList } from 'kerfjs/list';import { confirm, toast } from 'kerfjs/overlay';import { debouncedSignal } from 'kerfjs/timing';
const query = signal('');const debouncedQuery = debouncedSignal(query, 200); // trails the input by 200 msconst filtered = computed(() => debouncedQuery.value ? all.value.filter((r) => r.name.includes(debouncedQuery.value)) : all.value,);
// A one-shot mount with a bound text hole — the header count updates without// touching the list.const countText = computed(() => `${filtered.value.length} of ${all.value.length} rows`);mount(countEl, () => <span>{countText}</span>);
bindList(listEl, filtered, { key: (r) => r.id, render: (r) => <div class="vl-row" style="height:36px">{r.name}</div>, virtualize: { rowHeight: 36, overscan: 4 }, // only the visible slice renders});
delegate(listEl, 'click', '[data-del]', (_e, el) => { const row = all.value.find((r) => r.id === Number(el.getAttribute('data-del')))!; void confirm(`Delete “${row.name}”?`, { danger: true, okText: 'Delete' }).then((ok) => { if (ok) { all.value = all.value.filter((r) => r !== row); toast(`Deleted ${row.name}`); } });});