List View

Free

Market-ready list patterns: default, checklist, nested tree, virtualized, grouped, template cards, call history and infinite scrolling.

</>sv-list-view listfeeddata

Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.

import { SvListViewComponent } from '@styloviz/list-view';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Grouped & searchable

example.htmlAngular
<sv-list-view [items]="items" [grouped]="true" [searchable]="true" [stickyGroupHeaders]="true" />

Virtualized infinite scroll

example.htmlAngular
<sv-list-view
  [items]="items"
  [virtualized]="true"
  [infiniteScroll]="true"
  [hasMore]="hasMore"
  (loadMore)="fetchNext()" />

Cursor and scroll position that survive a route change

example.htmlAngular
<sv-list-view
  [items]="items"
  [virtualized]="true"
  [(activeItemId)]="cursorId"
  [(scrollOffset)]="scrollTop" />

Grouped checklist with section checkboxes

example.htmlAngular
<sv-list-view
  [items]="items"
  [grouped]="true"
  [checkable]="true"
  [(checkedIds)]="checkedIds" />

When to use

  • Rendering feeds, checklists, contacts or any vertical collection of items.
  • You need grouping, quick/bulk actions, or virtualization for long lists.
  • For tabular, column-aligned data use Data Table.

Async states

  • loading
  • empty
  • error
  • retry
  • partial

Item identity & owned state

Restorable state
  • checkedIds
  • expandedIds
  • selectedIds
  • selectionChange

Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their Change events to store what the user does.

Inputs

NameTypeDefaultDescription
itemsListViewItem[][]Items to render (label, meta, optional children/avatar/actions).
modeListViewMode'default'One preset switch. `checklist`, `template` and `call-history` change what a row shows and compose with everything else. `nested`, `grouped` and `scrolling` are Pro presets and this build ignores them — use `[nested]` / `[grouped]` directly, which work here. `virtualization` windows a flat list, and windowing cannot be combined with `[nested]` or `[grouped]`. The full matrix, and the dev warning that enforces it, is on `_ignoredCombinations`.
variantListViewVariant'default'Visual variant.
densityListViewDensity'comfortable'Row density.
selectionModeListViewSelectionMode'none'Selection behaviour: none, single or multiple.
checkablebooleanfalseShow checkboxes on rows.
nestedbooleanfalseEnable nested (tree) rows.
virtualizedbooleanfalseVirtualize long lists for performance.
groupedbooleanfalseGroup rows under section headers.
searchablebooleanfalseShow a search box to filter items.
stickyGroupHeadersbooleantrueKeep group headers pinned while scrolling.
infiniteScrollbooleanfalseLoad more items as the user scrolls to the end.
hasMorebooleanfalseWhether more items are available to load.
loadingMorebooleanfalseShow a loading indicator while fetching more.
viewportHeightnumber360Viewport height (px) for the virtualized list.
itemHeightnumber68Height of a **single-line** row, in pixels — the base every windowed row is measured from. Windowing has to know where row 4,000 starts without rendering rows 0–3,999, so a row's height is computed rather than measured: this base, plus a line for a `subtitle`/`description` and a strip for `tags` on the rows that carry them. See `rowHeightOf`. The estimate is what the row is then sized to, so the scrollbar and the rows agree exactly.
overscannumber5Extra rows rendered above/below the viewport.
searchPlaceholderstring'Search list items'Placeholder for the search box.
emptyMessagestring'No items match your filters.'Message shown when no items match.
customClassstring''Extra CSS classes for the root.
ariaLabelstring'List view'Accessible label for the list.
announceActionsbooleantrueAnnounce row actions to screen readers.
showQuickActionsbooleanfalseShow per-row quick actions on hover/focus.
quickActionsListViewQuickAction[][]Per-row quick actions.
bulkActionsListViewBulkAction[][]Bulk actions for checked rows.
enableBulkHotkeysbooleanfalseEnable keyboard hotkeys for bulk actions.
bulkHotkeysRecord<string, string>{}Map of hotkey → bulk action id.
enableRovingFocusbooleantrueEnable roving-tabindex keyboard navigation.
loadingbooleanfalseTrue while the first page is being fetched. Replaces the list with a placeholder. For the *next* page use `loadingMore`, which keeps the rows already on screen.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
serverSidebooleanfalseSearch on the server instead of in the component. The supplied `items` are rendered as given — filtering them again would hide rows the server deliberately returned, and client-side search can only ever look at the page already downloaded.
debounceMsnumber300Delay before `searched` fires, in milliseconds. `0` emits immediately.
selectedIdsSet<string | number> (two-way)new Set()Selected row ids (two-way).
checkedIdsSet<string | number> (two-way)new Set()Checked row ids (two-way).
expandedIdsSet<string | number> (two-way)new Set()Which nested items are expanded (two-way). A `model` alongside `selectedIds` and `checkedIds` because it is the same kind of fact and the host has the same reasons to reach it: open the branch a deep link names, restore the tree after a route change, persist what the user had open. Held in a private signal it could be neither set nor observed — the one piece of this component's item state that was unreachable.
activeItemIdstring | number | null (two-way)nullThe row the keyboard cursor is on (two-way) — the roving tab stop. Windowed lists made this unavoidable. Roving focus used to walk the RENDERED buttons, and a windowed list renders twenty of five hundred: the cursor stopped dead at the edge of the window instead of the end of the list, and scrolling the focused row out of the window dropped focus to `<body>`. A keyboard cursor over a list that is only partly in the DOM has to be an id, not an element. Two-way because it is the same kind of fact as `selectedIds`: a host can put the cursor on the row a deep link names, and read where the user left it.
scrollOffsetnumber (two-way)0Scroll position of the list viewport, in pixels (two-way). Written on every scroll, applied to the viewport whenever it changes from the outside — bind it and the position survives what destroys the component: a route change, a tab switch, a `@defer` block re-entering, an infinite-scroll page arriving. Without it, restoring a list meant restoring its rows and landing the user back at the top of them.
querystring (two-way)''The search term. Two-way bindable, so it can be restored from a URL and kept in step with a server-side query.

Outputs

EventPayload typeDescription
itemClickListViewItemClickEventEmitted with the clicked item.
selectionChangeSet<string | number>Emitted with the current selection set.
checkChangeListViewCheckChangeEventEmitted when a row's checkbox changes.
loadMoreListViewLoadMoreEventEmitted when more items should be loaded (infinite scroll).
quickActionClickListViewQuickActionClickEventEmitted when a quick action is clicked.
bulkActionClickListViewBulkActionClickEventEmitted when a bulk action is triggered.
searchedstringThe debounced search term — fetch matching rows. Debounced by `debounceMs` and never emitted twice for the same term. Pair with `serverSide`. Named `searched` rather than `queryChange`, and that is load-bearing: `query` below is a `model()`, which already owns an output of that name and emits it on every keystroke. Declaring it here as well claimed the same binding twice — Angular reports "Output 'queryChange' is bound to both 'query' and 'queryChange'", the component's metadata stops resolving, and every component importing it then fails with "Component imports must be standalone components…". It also made the debounce contract above a lie, since the model's undebounced emission went out under the same name. `sv-search-bar` has the identical shape and solves it the same way: `value = model()` for the two-way text, `searched = output()` for the debounced notification. Enforced by `model-output-collision` in scripts/check-ui-invariants.mjs.
retryvoidThe host should re-run the failed load.
selectedIdsChangeSet<string | number>Emits the new `selectedIds` whenever it changes — the output half of `[(selectedIds)]`.
checkedIdsChangeSet<string | number>Emits the new `checkedIds` whenever it changes — the output half of `[(checkedIds)]`.
expandedIdsChangeSet<string | number>Emits the new `expandedIds` whenever it changes — the output half of `[(expandedIds)]`.
activeItemIdChangestring | number | nullEmits the new `activeItemId` whenever it changes — the output half of `[(activeItemId)]`.
scrollOffsetChangenumberEmits the new `scrollOffset` whenever it changes — the output half of `[(scrollOffset)]`.
queryChangestringEmits the new `query` whenever it changes — the output half of `[(query)]`.

<sv-list-view-pro>

Also in this family — SvListViewProComponent from @styloviz-pro/list-view.

Async states

  • loading
  • empty
  • error
  • retry
  • partial

Item identity & owned state

Restorable state
  • checkedIds
  • expandedIds
  • selectedIds
  • selectionChange

Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their Change events to store what the user does.

Inputs

NameTypeDefaultDescription
itemsListViewItem[][]Rows to render. Ids must be unique; `children` builds the tree in nested mode and `group` the sections in grouped mode.
modeListViewMode'default'One preset switch. `checklist`, `template` and `call-history` change what a row shows and compose with everything else; `scrolling`, `virtualization`, `grouped` and `nested` change how the list itself is built, and those four do NOT all compose — `nested` beats `grouped`, and neither can be windowed. The full matrix, and the dev warning that enforces it, is on `_ignoredCombinations`.
variantListViewVariant'default'Container style: plain border, tinted `soft` surface, or a heavier `outlined` border.
densityListViewDensity'comfortable'Row padding: `compact`, `comfortable` or `spacious`.
selectionModeListViewSelectionMode'none'Row selection: `single` replaces the selection on click, `multiple` toggles each clicked row. Selection lives in `selectedIds`.
checkablebooleanfalseShow a checkbox on every row plus a "Select all" box; checked rows live in `checkedIds`. Also on in `checklist` mode.
nestedbooleanfalseRender `items` as an expandable tree using each item's `children`. Same as `mode="nested"`; takes precedence over grouping and windowing.
virtualizedbooleanfalseRender only the rows in view (windowing) inside a `viewportHeight` scroller. Flat lists only; ignored when nested or grouped.
groupedbooleanfalseSplit rows into sections by each item's `group` (missing groups fall under "Ungrouped"). Same as `mode="grouped"`.
searchablebooleanfalseShow the search box that filters rows by their text. Always shown in `scrolling` and `virtualization` modes.
stickyGroupHeadersbooleantrueKeep each section header pinned to the top while its rows scroll, in grouped mode.
infiniteScrollbooleanfalseEmit `loadMore` when the list is scrolled near its end, and show the paging footer.
hasMorebooleanfalseWhether more pages exist. While false `loadMore` is not emitted and the footer shows the end-of-list marker.
loadingMorebooleanfalseTrue while the next page is being fetched. Shows a "Loading more" footer and holds back further `loadMore` events.
viewportHeightnumber360Height in pixels of a flat list's scroll area: fixed when virtualized or in `scrolling` mode, a maximum otherwise. Also sets the PageUp/PageDown step.
itemHeightnumber68Height of a **single-line** row, in pixels — the base every windowed row is measured from. Windowing has to know where row 4,000 starts without rendering rows 0–3,999, so a row's height is computed rather than measured: this base, plus a line for a `subtitle`/`description` and a strip for `tags` on the rows that carry them. See `rowHeightOf`. The estimate is what the row is then sized to, so the scrollbar and the rows agree exactly.
overscannumber5Extra rows rendered above and below the visible window when virtualized, to avoid blank flashes while scrolling.
searchPlaceholderstring'Search list items'Placeholder and accessible label of the search box.
emptyMessagestring'No items match your filters.'Message in the empty state when there are no rows and no search term is active.
customClassstring''Extra CSS classes added to the list container.
ariaLabelstring'List view'Accessible name of the list region.
announceActionsbooleantrueAnnounce selection, check, action and paging changes to screen readers through a polite live region.
showQuickActionsbooleanfalseShow the `quickActions` buttons on every row.
quickActionsListViewQuickAction[][]Per-row action buttons, shown when `showQuickActions` is true. Clicks emit `quickActionClick`.
bulkActionsListViewBulkAction[][]Actions in the bar that appears while rows are checked (or selected, when not checkable). Clicks emit `bulkActionClick`.
enableBulkHotkeysbooleanfalseLet single keys trigger bulk actions while rows are selected. Keys map via `bulkHotkeys`, falling back to Delete/Backspace, A and R.
bulkHotkeysRecord<string, string>{}Map of lowercase key to bulk action `id`, used when `enableBulkHotkeys` is on. Overrides the built-in `delete`, `archive` and `mark-read` keys.
enableRovingFocusbooleantrueMake the list a single tab stop: arrow, Home/End and PageUp/PageDown keys plus typeahead move between rows.
loadingbooleanfalseTrue while the first page is being fetched. Replaces the list with a placeholder. For the *next* page use `loadingMore`, which keeps the rows already on screen.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
serverSidebooleanfalseSearch on the server instead of in the component. The supplied `items` are rendered as given — filtering them again would hide rows the server deliberately returned, and client-side search can only ever look at the page already downloaded.
debounceMsnumber300Delay before `searched` fires, in milliseconds. `0` emits immediately.
selectedIdsSet<string | number> (two-way)new Set()Ids of the selected rows (two-way). Used when `selectionMode` is not `none`.
checkedIdsSet<string | number> (two-way)new Set()Ids of the checked rows (two-way). Used when the list is checkable.
expandedIdsSet<string | number> (two-way)new Set()Which nested items are expanded (two-way). A `model` alongside `selectedIds` and `checkedIds` because it is the same kind of fact and the host has the same reasons to reach it: open the branch a deep link names, restore the tree after a route change, persist what the user had open. Held in a private signal it could be neither set nor observed — the one piece of this component's item state that was unreachable.
activeItemIdstring | number | null (two-way)nullThe row the keyboard cursor is on (two-way) — the roving tab stop. Windowed lists made this unavoidable. Roving focus used to walk the RENDERED buttons, and a windowed list renders twenty of five hundred: the cursor stopped dead at the edge of the window instead of the end of the list, and scrolling the focused row out of the window dropped focus to `<body>`. A keyboard cursor over a list that is only partly in the DOM has to be an id, not an element. Two-way because it is the same kind of fact as `selectedIds`: a host can put the cursor on the row a deep link names, and read where the user left it.
scrollOffsetnumber (two-way)0Scroll position of the list viewport, in pixels (two-way). Written on every scroll, applied to the viewport whenever it changes from the outside — bind it and the position survives what destroys the component: a route change, a tab switch, a `@defer` block re-entering, an infinite-scroll page arriving. Without it, restoring a list meant restoring its rows and landing the user back at the top of them.
querystring (two-way)''The search term. Two-way bindable, so it can be restored from a URL and kept in step with a server-side query.

Outputs

EventPayload typeDescription
itemClickListViewItemClickEventA row was activated by click or keyboard. Not emitted for disabled rows.
selectionChangeSet<string | number>The user changed the selection. Carries the new set of selected ids.
checkChangeListViewCheckChangeEventA single row's checkbox was toggled. Carries the item and its new checked state; "Select all" and section checkboxes do not emit it.
loadMoreListViewLoadMoreEventThe list was scrolled near its end with `infiniteScroll` on, `hasMore` true and no page loading. Carries rendered and total row counts.
quickActionClickListViewQuickActionClickEventA row's quick action was clicked. Carries the item and the action.
bulkActionClickListViewBulkActionClickEventA bulk action was triggered by its button or hotkey. Carries the action and a copy of the checked (or selected) ids.
searchedstringThe debounced search term — fetch matching rows. Debounced by `debounceMs` and never emitted twice for the same term. Pair with `serverSide`. Named `searched` rather than `queryChange`, and that is load-bearing: `query` below is a `model()`, which already owns an output of that name and emits it on every keystroke. Declaring it here as well claimed the same binding twice — Angular reports "Output 'queryChange' is bound to both 'query' and 'queryChange'", the component's metadata stops resolving, and every component importing it then fails with "Component imports must be standalone components…". It also made the debounce contract above a lie, since the model's undebounced emission went out under the same name. `sv-search-bar` has the identical shape and solves it the same way: `value = model()` for the two-way text, `searched = output()` for the debounced notification. Enforced by `model-output-collision` in scripts/check-ui-invariants.mjs.
retryvoidThe host should re-run the failed load.
selectedIdsChangeSet<string | number>Emits the new `selectedIds` whenever it changes — the output half of `[(selectedIds)]`.
checkedIdsChangeSet<string | number>Emits the new `checkedIds` whenever it changes — the output half of `[(checkedIds)]`.
expandedIdsChangeSet<string | number>Emits the new `expandedIds` whenever it changes — the output half of `[(expandedIds)]`.
activeItemIdChangestring | number | nullEmits the new `activeItemId` whenever it changes — the output half of `[(activeItemId)]`.
scrollOffsetChangenumberEmits the new `scrollOffset` whenever it changes — the output half of `[(scrollOffset)]`.
queryChangestringEmits the new `query` whenever it changes — the output half of `[(query)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — it just saves you writing the debounce, the request guard and the retry policy by hand.

Template

Bound to the names this component actually exposes.

usage.htmlAngular
<sv-list-view
  [serverSide]="true"
  [items]="data.items()"
  [loading]="data.loading()"
  [error]="data.error()"
  [hasMore]="data.hasMore()"
  (queryChange)="data.setQuery($event)"
  (retry)="data.reload()"
/>

Host class

One controller handles debounce, request-guarding, cancellation and retry.

host.component.tsTypeScript
import { svDataController, createHttpDataSource } from '@styloviz/data';

readonly data = svDataController(
  createHttpDataSource<Row>({ url: '/api/rows' }),
);
  • `queryChange` carries an `SvDataQuery` — page, page size, sorts, filters, search and cursor in one object. One handler covers every way the user can change what they are asking for.
  • `serverSide` switches **off** the built-in filtering, sorting and paging. Leave it false and the component works entirely from the array you pass — which is what you want for a few hundred rows.
  • `debounceMs` (default 300) collapses a burst of typing into one request. Set it to 0 only when the query is driven by clicks.
  • `hasMore` and `loadMore` drive incremental paging; bind `hasMore` from the controller's own signal of the same name.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Swap between flat, grouped, checklist and card layouts.
  • Virtualization keeps long lists fast.
  • Quick actions and bulk actions per row.
  • Every mode renders the same row: avatar, badge, trailing amount, quick actions and the selection control are present under `virtualized` and `grouped` too, not only on a flat list.
  • `[(activeItemId)]` is the keyboard cursor and `[(scrollOffset)]` the scroll position — both two-way, so a host can deep-link to a row, restore either after a route change, or read back where the user left off.
  • **Feature compatibility.** Row presets (`default`, `checklist`, `template`, `call-history`) compose with everything. The structural ones do not: `virtualization` windows flat lists only, `grouped` renders every section in full, and `nested` renders the tree in full and beats `grouped`.
  • Under `virtualized`, a row's height is computed from `itemHeight` plus a line for a `subtitle`/`description` and a strip for `tags`, and the row is then sized to that number — so the scrollbar and the rows always agree.

Best practices

  • Do: Enable `virtualized` for lists beyond a few hundred items.
  • Do: Pair `infiniteScroll` with `hasMore`/`loadingMore` so the end state is clear.
  • Do: Bind `[(activeItemId)]` and `[(scrollOffset)]` on any list a user can navigate away from and come back to. Restoring the rows alone puts them back at the top of the list with no keyboard cursor.
  • Do: Set `itemHeight` to the height of a **single-line** row under `virtualized`. Rows with a `subtitle`, a `description` or `tags` are measured taller automatically; setting the tallest row's height as the base leaves gaps under every short row instead.
  • Tip: Use `grouped` with `stickyGroupHeaders` to keep context while scrolling.
  • Don't: Don't combine `virtualized` with `nested` or `grouped`, or `nested` with `grouped` — one wins and the other silently does nothing. Each ignored pair logs a dev-mode warning naming what to drop.

Accessibility

  • Keyboard at scale (X-10): the list is ONE tab stop however many rows it holds — the region around the rows is not a second one. ↑/↓ move a row and stop at the ends, Home/End jump to them, PageUp/PageDown move one viewport of rows and clamp (they never wrap), and typing a few letters moves to the next row whose title starts with them. Documented ceiling: 2,000 rendered rows (`SV_UNVIRTUALIZED_ROW_CEILING`) — above it, turn on `virtualized` (flat lists); a nested or grouped list past it warns once in dev mode.
  • Rows are keyboard navigable (roving focus with arrow keys, Home/End) and have clear focus.
  • Under `virtualized` the cursor walks the whole list, not the rendered window: ArrowDown past the last visible row scrolls the next one in, and End reaches the true last row. Exactly one row is a tab stop, so Tab enters and leaves the list once.
  • Scrolling the focused row out of the window moves focus to the scroller rather than losing it to the page, and the next arrow key carries on from the row the cursor was on.
  • Each row declares `aria-posinset`/`aria-setsize` — against the whole list when windowed, against its own section when grouped — so a screen reader announces "row 140 of 241" rather than the size of the window.
  • A grouped, checkable list gives each section header a checkbox that reports `indeterminate` as "mixed" while the section is partly checked; disabled rows are left out of it.
  • A nested list publishes its shape rather than drawing it: depth reached the page as `padding-left` alone, so every branch read as one flat pile. Each row is now a `listitem` carrying `aria-level` and an `aria-posinset`/`aria-setsize` counted against its own siblings, and each expander carries `aria-expanded` plus a label naming the branch ("Collapse src"), not a generic "Expand node".
  • A paged list says where it ends. The footer used to disappear on the last page, so "the data ends here" and "the next page is slow" looked identical; it now holds an end marker naming the total, and the live region speaks each page arriving ("Loaded 20 more items.") and the list running out ("End of list. All 241 items loaded."). A list that arrives already complete stays silent — nothing happened.
  • Quick/bulk action buttons and per-row selection controls are individually labelled.
  • The search field is labelled; selection changes are announced via a polite live region.

Feedback on List View

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.