Sticky Rows Plugin
The Sticky Rows plugin keeps selected data rows pinned just under the header while the user scrolls past them. Useful for section markers, group headers in flat lists, “you are here” anchors, and any scenario where you want a row to remain visible after it would naturally scroll out of view.
Stuck rows are clones of the real rows — the originals stay in the data
flow, remain interactive, and continue to participate in keyboard navigation.
Clones are decorative, marked aria-hidden="true", and inherit the row’s
column-template alignment so they line up perfectly with the data below.
Installation
Section titled “Installation”import '@toolbox-web/grid/features/sticky-rows';Or use the plugin directly:
import { StickyRowsPlugin } from '@toolbox-web/grid/plugins/sticky-rows';Basic Usage
Section titled “Basic Usage”Provide an isSticky predicate — either the name of a boolean field on
your row data, or a function receiving (row, index) and returning a
truthy value when the row should be sticky.
import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/sticky-rows';
const grid = queryGrid('tbw-grid');grid.gridConfig = { columns: [ { field: 'label', header: 'Label' }, { field: 'value', header: 'Value' }, ], features: { // Field-name shorthand: any row whose `isSection` is truthy is sticky. stickyRows: { isSticky: 'isSection' }, },};import '@toolbox-web/grid-react/features/sticky-rows';import { DataGrid } from '@toolbox-web/grid-react';import type { GridConfig } from '@toolbox-web/grid-react';
const gridConfig: GridConfig = { columns: [ { field: 'label', header: 'Label' }, { field: 'value', header: 'Value' }, ], features: { stickyRows: { isSticky: 'isSection' } },};
function MyGrid({ rows }) { return <DataGrid rows={rows} gridConfig={gridConfig} style={{ height: '400px' }} />;}<script setup>import '@toolbox-web/grid-vue/features/sticky-rows';import { TbwGrid } from '@toolbox-web/grid-vue';import type { GridConfig } from '@toolbox-web/grid-vue';
const gridConfig: GridConfig = { columns: [ { field: 'label', header: 'Label' }, { field: 'value', header: 'Value' }, ], features: { stickyRows: { isSticky: 'isSection' } },};</script>
<template> <TbwGrid :rows="rows" :grid-config="gridConfig" style="height: 400px" /></template>import '@toolbox-web/grid-angular/features/sticky-rows';import { Component } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { GridConfig } from '@toolbox-web/grid-angular';
@Component({ selector: 'app-my-grid', imports: [Grid], template: ` <tbw-grid [rows]="rows" [gridConfig]="gridConfig" style="height: 400px; display: block;"> </tbw-grid> `,})export class MyGridComponent { rows = [/* ... */];
gridConfig: GridConfig = { columns: [ { field: 'label', header: 'Label' }, { field: 'value', header: 'Value' }, ], // Field-name shorthand: any row whose `isSection` is truthy is sticky. features: { stickyRows: { isSticky: 'isSection' } }, };}Scroll the grid below. Section-marker rows (— A —, — B —, …) pin under
the header as they scroll past. Switch between push and stack mode
in the controls to compare behaviors, and increase Rows per section to
see longer scroll runs between markers.
'push' (default)
Section titled “'push' (default)”Only one sticky row is shown at a time. As the next sticky row approaches from below, it slides the previous one upward and out of view (iOS section-header behavior).
features: { stickyRows: { isSticky: 'isSection', mode: 'push' } }Best for: long flat lists where each section anchor should be transient.
'stack'
Section titled “'stack'”Sticky rows accumulate below the header up to maxStacked. When the cap is
reached, the oldest (lowest-index) entry is evicted from the top so the
most recently passed marker is always visible.
features: { stickyRows: { isSticky: 'isSection', mode: 'stack', maxStacked: 3, },}Best for: hierarchical breadcrumbs where multiple levels of context should remain visible.
Predicate Function
Section titled “Predicate Function”For more nuanced selection (computed flags, derived fields, every Nth row, etc.), pass a function:
features: { stickyRows: { isSticky: (row, index) => row.priority === 'critical' || index % 50 === 0, },}The predicate runs once per row whenever the row set changes (via
afterRender), so keep it cheap.
Configuration Options
Section titled “Configuration Options”See StickyRowsConfig for the full list of options and
defaults. isSticky is the only required option — pass a field name as shorthand, or a
predicate for anything more involved.
Styling
Section titled “Styling”The plugin renders a single <div class="tbw-sticky-rows"> between the
header and the rows region. Each clone carries class="tbw-sticky-row" and
the data attribute data-sticky-row="<rowIndex>".
The container reads three CSS custom properties from the active theme:
--tbw-z-layer-sticky-rows— z-index (defaults to22)--tbw-color-bg/--tbw-color-panel-bg— background fill--tbw-color-border— bottom-shadow color separating clones from data
Override with your own scoped rules:
tbw-grid .tbw-sticky-row { background: var(--my-section-bg); font-weight: 600;}Accessibility
Section titled “Accessibility”- Clones are marked
aria-hidden="true"so screen readers don’t double-read. - Focus styles and
tabindexare stripped from clones — keyboard navigation goes through the underlying rows only. - The originals retain their
aria-rowindex,role="row", and full cell semantics, so screen readers describe row position relative to the dataset.
Focus Not Obscured
Section titled “Focus Not Obscured”The plugin satisfies WCAG 2.2 SC 2.4.11 Focus Not Obscured (Minimum).
The clone overlay is absolutely positioned over the top of the rows viewport, so a row scrolled to the exact top would land underneath it and its focus indicator would be completely hidden. The plugin reports the overlay’s height to the grid, which offsets keyboard scrolling by that much — arrowing upwards always brings the focused row clear of the stuck clones.
When the focused row is one of the stuck clones it is already pinned in view, so the grid is told to skip scrolling entirely: scrolling to it would only push the row it is stuck above out of reach.
This is automatic and needs no configuration.
Bundle Size
Section titled “Bundle Size”The plugin is ≈4 kB gzipped (ESM) / ≈2 kB gzipped (UMD). It does not pull in any other plugins or core internals.
See Also
Section titled “See Also”- Pinned Rows — for non-data totals/aggregation rows
- Master / Detail — for expanded child rows
- Tree — for hierarchical data