Skip to content

Sticky Cells

Sticky Cells keep important checkpoint cells visible while users scroll through a virtualized grid. Mark cells with stickyCell; when the row starts crossing the top visibility edge, the marked row renders through the pinned top row area and is trimmed from the main viewport until the next sticky row replaces it.

Source code
TypeScript ts
import { defineCustomElements } from '@revolist/revogrid/loader';
import type { CellTemplateProp, ColumnGrouping, ColumnRegular } from '@revolist/revogrid';
import {
  AdvanceFilterPlugin,
  ColumnStretchPlugin,
  FilterHeaderPlugin,
  RowOddPlugin,
  StickyCellsPlugin,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';

defineCustomElements();

const { isDark } = currentTheme();

const stickyCell = (props: CellTemplateProp) => props.model.status === 'Sticky checkpoint';

const stickySourceCellProperties = (props: CellTemplateProp) => stickyCell(props)
  ? {
      class: {
        'sticky-source-cell': true,
      },
    }
  : undefined;

const columns: (ColumnRegular | ColumnGrouping)[] = [
  {
    name: 'Pinned Identity',
    children: [
      {
        name: 'Account',
        prop: 'account',
        pin: 'colPinStart',
        size: 150,
        filter: true,
        stickyCell,
        cellProperties: stickySourceCellProperties,
      },
    ],
  },
  {
    name: 'Opportunity Workflow',
    children: [
      {
        name: 'Stage',
        prop: 'stage',
        size: 130,
        filter: ['selection'],
        stickyCell,
        cellProperties: stickySourceCellProperties,
      },
      {
        name: 'Status',
        prop: 'status',
        size: 140,
        filter: ['selection'],
        stickyCell,
        cellProperties: stickySourceCellProperties,
        cellTemplate: (h, props) => h('span', { class: 'sticky-status' }, props.value),
      },
      {
        name: 'Owner',
        prop: 'owner',
        size: 130,
        filter: ['selection'],
      },
      {
        name: 'Amount',
        prop: 'amount',
        size: 120,
        filter: true,
      },
    ],
  },
  {
    name: 'Pinned Market',
    children: [
      {
        name: 'Pinned End',
        prop: 'region',
        pin: 'colPinEnd',
        size: 130,
        filter: ['selection'],
        stickyCell,
        cellProperties: stickySourceCellProperties,
      },
    ],
  },
];

const rows = Array.from({ length: 120 }, (_, index) => ({
  account: `Account ${index}`,
  stage: index % 10 === 0 ? `Milestone ${index / 10 + 1}` : `Stage ${(index % 4) + 1}`,
  status: index % 10 === 0 ? 'Sticky checkpoint' : index % 3 === 0 ? 'Blocked' : 'In progress',
  owner: ['Ada', 'Grace', 'Linus', 'Margaret'][index % 4],
  amount: `$${(1250 + index * 175).toLocaleString()}`,
  region: ['North', 'South', 'West'][index % 3],
}));

export function load(parentSelector: string) {
  const parent = document.querySelector(parentSelector);
  if (!parent) {
    return;
  }

  const grid = document.createElement('revo-grid');
  grid.className = 'sticky-cells-demo';
  grid.style.height = '420px';
  grid.theme = isDark() ? 'darkMaterial' : 'material';
  grid.columns = columns;
  grid.plugins = [AdvanceFilterPlugin, FilterHeaderPlugin, RowOddPlugin, StickyCellsPlugin, ColumnStretchPlugin];
  grid.stretch = 'last';
  grid.filter = {};
  grid.hideAttribution = true;

  parent.appendChild(grid);
  grid.source = rows;

  return () => grid.remove();
}
Vue vue
<template>
  <VGrid
    class="sticky-cells-demo grow"
    :theme="isDark ? 'darkMaterial' : 'material'"
    :columns="columns"
    :source="rows"
    :plugins="plugins"
    stretch="all"
    :filter="filter"
    hide-attribution
  />
</template>

<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import { VGrid } from '@revolist/vue3-datagrid';
import type { CellTemplateProp, ColumnGrouping, ColumnRegular } from '@revolist/revogrid';
import {
  AdvanceFilterPlugin,
  ColumnStretchPlugin,
  FilterHeaderPlugin,
  RowOddPlugin,
  StickyCellsPlugin,
} from '@revolist/revogrid-pro';
import { currentThemeVue } from '../composables/useRandomData';

const { isDark } = currentThemeVue();

const plugins = [AdvanceFilterPlugin, FilterHeaderPlugin, RowOddPlugin, StickyCellsPlugin, ColumnStretchPlugin];
const filter = ref({});

const stickyCell = (props: CellTemplateProp) => props.model.status === 'Sticky checkpoint';
const stickySourceCellProperties = (props: CellTemplateProp) => stickyCell(props)
  ? {
      class: {
        'sticky-source-cell': true,
      },
    }
  : undefined;

const columns = shallowRef<(ColumnRegular | ColumnGrouping)[]>([
  {
    name: 'Pinned Identity',
    children: [
      {
        name: 'Account',
        prop: 'account',
        pin: 'colPinStart',
        size: 150,
        filter: true,
        sortable: true,
        stickyCell,
        cellProperties: stickySourceCellProperties,
      },
    ],
  },
  {
    name: 'Opportunity Workflow',
    children: [
      {
        name: 'Stage',
        prop: 'stage',
        size: 130,
        filter: ['selection'],
        stickyCell,
        cellProperties: stickySourceCellProperties,
      },
      {
        name: 'Status',
        prop: 'status',
        size: 140,
        filter: ['selection'],
        stickyCell,
        cellProperties: stickySourceCellProperties,
        cellTemplate: (h, props) =>
          h('span', { class: 'sticky-status' }, props.value),
      },
      {
        name: 'Owner',
        prop: 'owner',
        filter: ['selection'],
      },
      {
        name: 'Amount',
        prop: 'amount',
        filter: true,
      },
    ],
  },
  {
    name: 'Pinned Market',
    children: [
      {
        name: 'Pinned End',
        prop: 'region',
        pin: 'colPinEnd',
        size: 130,
        filter: ['selection'],
        stickyCell,
        cellProperties: stickySourceCellProperties,
      },
    ],
  },
]);

const rows = ref(
  Array.from({ length: 120 }, (_, index) => ({
    account: `Account ${index}`,
    stage: index % 10 === 0 ? `Milestone ${index / 10 + 1}` : `Stage ${(index % 4) + 1}`,
    status: index % 10 === 0 ? 'Sticky checkpoint' : index % 3 === 0 ? 'Blocked' : 'In progress',
    owner: ['Ada', 'Grace', 'Linus', 'Margaret'][index % 4],
    amount: `$${(1250 + index * 175).toLocaleString()}`,
    region: ['North', 'South', 'West'][index % 3],
  })),
);

</script>

<style scoped>
.sticky-cells-demo {
  display: block;
  height: 420px;
}

:global(.sticky-cells-demo .sticky-status) {
  display: inline-flex;
  align-items: center;
  height: 100%;
  font-weight: 600;
}

</style>
React tsx
import React, { useMemo } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import type { CellTemplateProp, ColumnGrouping, ColumnRegular } from '@revolist/revogrid';
import {
  AdvanceFilterPlugin,
  ColumnStretchPlugin,
  FilterHeaderPlugin,
  RowOddPlugin,
  StickyCellsPlugin,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';

const { isDark } = currentTheme();

function createRows() {
  return Array.from({ length: 120 }, (_, index) => ({
    account: `Account ${index}`,
    stage: index % 10 === 0 ? `Milestone ${index / 10 + 1}` : `Stage ${(index % 4) + 1}`,
    status: index % 10 === 0 ? 'Sticky checkpoint' : index % 3 === 0 ? 'Blocked' : 'In progress',
    owner: ['Ada', 'Grace', 'Linus', 'Margaret'][index % 4],
    amount: `$${(1250 + index * 175).toLocaleString()}`,
    region: ['North', 'South', 'West'][index % 3],
  }));
}

export default function StickyCells() {
  const plugins = useMemo(
    () => [AdvanceFilterPlugin, FilterHeaderPlugin, RowOddPlugin, StickyCellsPlugin, ColumnStretchPlugin],
    [],
  );
  const filter = useMemo(() => ({}), []);
  const rows = useMemo(createRows, []);

  const columns = useMemo<(ColumnRegular | ColumnGrouping)[]>(() => {
    const stickyCell = (props: CellTemplateProp) => props.model.status === 'Sticky checkpoint';
    const stickySourceCellProperties = (props: CellTemplateProp) => stickyCell(props)
      ? {
          class: {
            'sticky-source-cell': true,
          },
        }
      : undefined;

    return [
      {
        name: 'Pinned Identity',
        children: [
          {
            name: 'Account',
            prop: 'account',
            pin: 'colPinStart',
            size: 150,
            filter: true,
            stickyCell,
            cellProperties: stickySourceCellProperties,
          },
        ],
      },
      {
        name: 'Opportunity Workflow',
        children: [
          {
            name: 'Stage',
            prop: 'stage',
            size: 130,
            filter: ['selection'],
            stickyCell,
            cellProperties: stickySourceCellProperties,
          },
          {
            name: 'Status',
            prop: 'status',
            size: 140,
            filter: ['selection'],
            stickyCell,
            cellProperties: stickySourceCellProperties,
            cellTemplate: (h, props) => h('span', { class: 'sticky-status' }, props.value),
          },
          {
            name: 'Owner',
            prop: 'owner',
            size: 130,
            filter: ['selection'],
          },
          {
            name: 'Amount',
            prop: 'amount',
            size: 120,
            filter: true,
          },
        ],
      },
      {
        name: 'Pinned Market',
        children: [
          {
            name: 'Pinned End',
            prop: 'region',
            pin: 'colPinEnd',
            size: 130,
            filter: ['selection'],
            stickyCell,
            cellProperties: stickySourceCellProperties,
          },
        ],
      },
    ];
  }, []);

  return (
    <RevoGrid
      className="sticky-cells-demo grow"
      theme={isDark() ? 'darkMaterial' : 'material'}
      columns={columns}
      source={rows}
      plugins={plugins}
      stretch="all"
      filter={filter}
      hideAttribution
      style={{ height: 420 }}
    />
  );
}
Angular ts
import { CommonModule } from '@angular/common';
import { Component, ViewEncapsulation } from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import type { CellTemplateProp, ColumnGrouping, ColumnRegular } from '@revolist/revogrid';
import {
  AdvanceFilterPlugin,
  ColumnStretchPlugin,
  FilterHeaderPlugin,
  RowOddPlugin,
  StickyCellsPlugin,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';

const { isDark } = currentTheme();

const stickyCell = (props: CellTemplateProp) => props.model.status === 'Sticky checkpoint';
const stickySourceCellProperties = (props: CellTemplateProp) => stickyCell(props)
  ? {
      class: {
        'sticky-source-cell': true,
      },
    }
  : undefined;

@Component({
  selector: 'sticky-cells-grid',
  standalone: true,
  imports: [CommonModule, RevoGrid],
  encapsulation: ViewEncapsulation.None,
  template: `
    <revo-grid
      class="sticky-cells-demo grow"
      [theme]="theme"
      [columns]="columns"
      [source]="rows"
      [plugins]="plugins"
      [stretch]="additionalData.stretch"
      [filter]="filter"
      [hideAttribution]="true"
      style="min-height: 420px;"
    ></revo-grid>
  `,
})
export class StickyCellsGridComponent {
  readonly theme = isDark() ? 'darkMaterial' : 'material';
  readonly plugins = [AdvanceFilterPlugin, FilterHeaderPlugin, RowOddPlugin, StickyCellsPlugin, ColumnStretchPlugin];
  readonly additionalData = {
    stretch: 'all',
  };
  readonly filter = {};

  readonly columns: (ColumnRegular | ColumnGrouping)[] = [
    {
      name: 'Pinned Identity',
      children: [
        {
          name: 'Account',
          prop: 'account',
          pin: 'colPinStart',
          size: 150,
          filter: true,
          stickyCell,
          cellProperties: stickySourceCellProperties,
        },
      ],
    },
    {
      name: 'Opportunity Workflow',
      children: [
        {
          name: 'Stage',
          prop: 'stage',
          size: 130,
          filter: ['selection'],
          stickyCell,
          cellProperties: stickySourceCellProperties,
        },
        {
          name: 'Status',
          prop: 'status',
          size: 140,
          filter: ['selection'],
          stickyCell,
          cellProperties: stickySourceCellProperties,
          cellTemplate: (h, props) => h('span', { class: 'sticky-status' }, props.value),
        },
        {
          name: 'Owner',
          prop: 'owner',
          size: 130,
          filter: ['selection'],
        },
        {
          name: 'Amount',
          prop: 'amount',
          filter: true,
        },
      ],
    },
    {
      name: 'Pinned Market',
      children: [
        {
          name: 'Pinned End',
          prop: 'region',
          pin: 'colPinEnd',
          size: 130,
          filter: ['selection'],
          stickyCell,
          cellProperties: stickySourceCellProperties,
        },
      ],
    },
  ];

  readonly rows = Array.from({ length: 120 }, (_, index) => ({
    account: `Account ${index}`,
    stage: index % 10 === 0 ? `Milestone ${index / 10 + 1}` : `Stage ${(index % 4) + 1}`,
    status: index % 10 === 0 ? 'Sticky checkpoint' : index % 3 === 0 ? 'Blocked' : 'In progress',
    owner: ['Ada', 'Grace', 'Linus', 'Margaret'][index % 4],
    amount: `$${(1250 + index * 175).toLocaleString()}`,
    region: ['North', 'South', 'West'][index % 3],
  }));
}

Add StickyCellsPlugin to the grid and define a column stickyCell predicate.

import { StickyCellsPlugin } from '@revolist/revogrid-pro';
const stickyCell = ({ model }) => model.isCheckpoint === true;
const columns = [
{
name: 'Account',
prop: 'account',
stickyCell,
},
{
name: 'Status',
prop: 'status',
stickyCell,
},
];
grid.plugins = [StickyCellsPlugin];

If any cell in a row is marked as sticky, that row can become the active sticky row.

Sticky Cells render through the header layer, so the plugin can be combined with grouped headers and filter headers. When using the filter header plugin, register filtering before sticky cells so Sticky Cells wraps the final header content:

import {
AdvanceFilterPlugin,
FilterHeaderPlugin,
StickyCellsPlugin,
ColumnStretchPlugin,
} from '@revolist/revogrid-pro';
grid.plugins = [
AdvanceFilterPlugin,
FilterHeaderPlugin,
StickyCellsPlugin,
ColumnStretchPlugin,
];
grid.filter = {};
grid.additionalData = {
stretch: 'all',
};
  • One sticky row is active by default. Set stickyCells.maxRows to keep multiple recent sticky rows pinned.
  • A marked row becomes sticky once 10% of that source row has scrolled above the viewport top.
  • Sticky cells work across colPinStart, rgCol, and colPinEnd.
  • Custom cellTemplate output is reused in the pinned sticky row.
  • Existing pinnedTopSource rows remain before plugin-managed sticky rows.
  • Active sticky source rows are trimmed out of the main row viewport.