Skip to content

Flashing Cells

Cell flashing makes recent changes visible in editable grids, live dashboards, and audit workflows. The Pro CellFlashPlugin can react to edits from EventManagerPlugin, undo/redo changes from HistoryPlugin, manual flashcell events, or direct plugin method calls.

Source code
TypeScript ts
import { defineCustomElements } from '@revolist/revogrid/loader';
import type { ColumnRegular, DataType } from '@revolist/revogrid';
import {
  CellFlashPlugin,
  EventManagerPlugin,
  HistoryPlugin,
  cellFlashArrowTemplate,
  type CellFlashConfig,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
  CELL_FLASH_TOOLBAR_ACTION_EVENT,
  CELL_FLASH_TOOLBAR_OPTIONS_CHANGE_EVENT,
  createCellFlashToolbar,
  type CellFlashToolbarActionEvent,
  type CellFlashToolbarOptions,
  type CellFlashToolbarOptionsChangeEvent,
} from './cell-flash-toolbar';

defineCustomElements();

const { isDark } = currentTheme();

type MarketRow = DataType & {
  symbol: string;
  price: number;
  change: number;
  volume: number;
};

const createRows = (): MarketRow[] => [
  { symbol: 'AAPL', price: 184.12, change: 0.4, volume: 124500 },
  { symbol: 'MSFT', price: 421.34, change: -0.2, volume: 98200 },
  { symbol: 'NVDA', price: 879.7, change: 1.6, volume: 143000 },
  { symbol: 'AMZN', price: 188.8, change: 0.1, volume: 76100 },
  { symbol: 'META', price: 494.2, change: -0.5, volume: 68200 },
];

const columns: ColumnRegular[] = [
  { name: 'Symbol', prop: 'symbol', size: 110 },
  {
    name: 'Price',
    prop: 'price',
    size: 130,
    flash: (_value, context) => ({
      flash: true,
      rowFlash: Math.abs(Number(context.value) - Number(context.previousValue)) > 2,
    }),
    cellTemplate: cellFlashArrowTemplate((h, { value }) => h('span', null, Number(value).toFixed(2))),
  },
  {
    name: 'Change %',
    prop: 'change',
    size: 120,
    flash: true,
    cellTemplate: cellFlashArrowTemplate((h, { value }) => h('span', null, `${Number(value).toFixed(2)}%`)),
  },
  { name: 'Volume', prop: 'volume', size: 130, flash: true },
];

function updateRow(row: MarketRow): MarketRow {
  const delta = Number((Math.random() * 8 - 4).toFixed(2));
  const price = Number(Math.max(1, row.price + delta).toFixed(2));
  return {
    ...row,
    price,
    change: Number(((price - row.price) / row.price * 100).toFixed(2)),
    volume: row.volume + Math.round(Math.random() * 5000),
  };
}

function dispatchRowEdit(grid: HTMLRevoGridElement, rows: MarketRow[], rowIndex: number) {
  const previous = rows[rowIndex];
  const next = updateRow(previous);
  rows[rowIndex] = next;
  grid.dispatchEvent(new CustomEvent('beforeedit', {
    cancelable: true,
    detail: {
      rowIndex,
      prop: 'price',
      val: next.price,
      model: previous,
      type: 'rgRow',
    },
  }));
  grid.dispatchEvent(new CustomEvent('flashcell', {
    detail: {
      data: {
        [rowIndex]: {
          change: next.change,
          volume: next.volume,
        },
      },
      previousData: {
        [rowIndex]: {
          change: previous.change,
          volume: previous.volume,
        },
      },
      type: 'rgRow',
      eventTypes: ['manual-update'],
    },
  }));
  grid.source = [...rows];
}

async function getCellFlashPlugin(grid: HTMLRevoGridElement) {
  const plugins = await grid.getPlugins();
  return plugins.find(plugin => plugin?.constructor?.name === 'CellFlashPlugin') as CellFlashPlugin | undefined;
}

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

  const rows = createRows();
  let liveTimer: ReturnType<typeof setInterval> | undefined;
  const root = document.createElement('div');
  root.style.cssText = 'display:grid;gap:10px;';
  let toolbarOptions: CellFlashToolbarOptions = {
    rowFlash: false,
    duration: 1000,
    live: false,
  };
  const toolbar = createCellFlashToolbar(toolbarOptions);

  const grid = document.createElement('revo-grid');
  const config = (): CellFlashConfig => ({
    duration: toolbarOptions.duration,
    rowDuration: toolbarOptions.duration,
    queue: 'merge',
    mode: toolbarOptions.rowFlash ? 'cell-and-row' : 'cell',
    clearOnSourceChange: false,
    aria: true,
    labels: {
      cellUpdated: state => `Market cell ${String(state.prop)} in row ${state.rowIndex + 1} changed`,
      rowUpdated: state => `Market row ${state.rowIndex + 1} changed`,
    },
  });

  const applyConfig = () => {
    grid.cellFlash = config();
  };

  const setToolbarOptions = (patch: Partial<CellFlashToolbarOptions>) => {
    toolbarOptions = {
      ...toolbarOptions,
      ...patch,
    };
    toolbar.options = toolbarOptions;
  };

  const stopFeed = () => {
    if (liveTimer) {
      clearInterval(liveTimer);
    }
    liveTimer = undefined;
    setToolbarOptions({ live: false });
  };

  toolbar.addEventListener(CELL_FLASH_TOOLBAR_OPTIONS_CHANGE_EVENT, (event: CellFlashToolbarOptionsChangeEvent) => {
    setToolbarOptions(event.detail);
    applyConfig();
  });

  toolbar.addEventListener(CELL_FLASH_TOOLBAR_ACTION_EVENT, async (event: CellFlashToolbarActionEvent) => {
    if (event.detail === 'update-row') {
      dispatchRowEdit(grid, rows, Math.floor(Math.random() * rows.length));
      return;
    }
    if (event.detail === 'burst-update') {
      rows.forEach((_row, index) => dispatchRowEdit(grid, rows, index));
      return;
    }
    if (event.detail === 'start-feed') {
      if (!liveTimer) {
        liveTimer = setInterval(() => dispatchRowEdit(grid, rows, Math.floor(Math.random() * rows.length)), 900);
        setToolbarOptions({ live: true });
      }
      return;
    }
    if (event.detail === 'stop-feed') {
      stopFeed();
      return;
    }
    if (event.detail === 'manual-row-flash') {
      const plugin = await getCellFlashPlugin(grid);
      plugin?.flashRows({ rowIndex: 0, rowClassName: 'manual-row-flash' });
      return;
    }
    const plugins = await grid.getPlugins();
    const history = plugins.find(plugin => plugin?.constructor?.name === 'HistoryPlugin') as HistoryPlugin | undefined;
    if (event.detail === 'undo') {
      history?.undo();
      return;
    }
    history?.redo();
  });

  grid.columns = columns;
  grid.eventManager = { applyEventsToSource: true };
  grid.plugins = [EventManagerPlugin, HistoryPlugin, CellFlashPlugin];
  grid.theme = isDark() ? 'darkCompact' : 'compact';
  grid.hideAttribution = true;
  grid.style.cssText = 'min-height:420px;';
  applyConfig();

  root.append(toolbar, grid);
  parent.appendChild(root);
  grid.source = rows;

  return () => {
    if (liveTimer) clearInterval(liveTimer);
    grid.remove();
    root.remove();
  };
}
Vue vue
<template>
  <div ref="rootRef" style="display: grid; gap: 10px;">
    <cell-flash-toolbar
      ref="toolbarRef"
      @cell-flash-toolbar-action="handleToolbarAction"
      @cell-flash-toolbar-options-change="handleToolbarOptionsChange"
    />
    <VGrid
      style="min-height: 420px;"
      :theme="isDark ? 'darkMaterial' : 'material'"
      :columns="columns"
      :source="rows"
      :plugins="plugins"
      :event-manager="eventManager"
      :cell-flash="cellFlash"
      hide-attribution
      resize
    />
  </div>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watchEffect } from 'vue';
import { currentThemeVue } from '../composables/useRandomData';
import { VGrid, type ColumnRegular, type DataType } from '@revolist/vue3-datagrid';
import {
  CellFlashPlugin,
  EventManagerPlugin,
  HistoryPlugin,
  cellFlashArrowTemplate,
} from '@revolist/revogrid-pro';
import {
  defineCellFlashToolbarElement,
  type CellFlashToolbarActionEvent,
  type CellFlashToolbarElement,
  type CellFlashToolbarOptionsChangeEvent,
} from './cell-flash-toolbar';

defineCellFlashToolbarElement();

type MarketRow = DataType & {
  symbol: string;
  price: number;
  change: number;
  volume: number;
};

const { isDark } = currentThemeVue();
const rootRef = ref<HTMLElement>();
const toolbarRef = ref<CellFlashToolbarElement | null>(null);
const rowFlash = ref(false);
const duration = ref(1000);
const live = ref(false);
let liveTimer: ReturnType<typeof setInterval> | undefined;

const createRows = (): MarketRow[] => [
  { symbol: 'AAPL', price: 184.12, change: 0.4, volume: 124500 },
  { symbol: 'MSFT', price: 421.34, change: -0.2, volume: 98200 },
  { symbol: 'NVDA', price: 879.7, change: 1.6, volume: 143000 },
  { symbol: 'AMZN', price: 188.8, change: 0.1, volume: 76100 },
  { symbol: 'META', price: 494.2, change: -0.5, volume: 68200 },
];

const rows = ref<MarketRow[]>(createRows());

const columns = ref<ColumnRegular[]>([
  { name: 'Symbol', prop: 'symbol', size: 110 },
  {
    name: 'Price',
    prop: 'price',
    size: 130,
    flash: (_value, context) => ({
      flash: true,
      rowFlash: Math.abs(Number(context.value) - Number(context.previousValue)) > 2,
    }),
    cellTemplate: cellFlashArrowTemplate((h, { value }) => h('span', null, Number(value).toFixed(2))),
  },
  {
    name: 'Change %',
    prop: 'change',
    size: 120,
    flash: true,
    cellTemplate: cellFlashArrowTemplate((h, { value }) => h('span', null, `${Number(value).toFixed(2)}%`)),
  },
  { name: 'Volume', prop: 'volume', size: 130, flash: true },
]);

const plugins = [
  EventManagerPlugin,
  HistoryPlugin,
  CellFlashPlugin,
];

const eventManager = { applyEventsToSource: true };

const cellFlash = computed(() => ({
  duration: duration.value,
  rowDuration: duration.value,
  queue: 'merge',
  mode: rowFlash.value ? 'cell-and-row' : 'cell',
  clearOnSourceChange: false,
  aria: true,
  labels: {
    cellUpdated: state => `Market cell ${String(state.prop)} in row ${state.rowIndex + 1} changed`,
    rowUpdated: state => `Market row ${state.rowIndex + 1} changed`,
  },
}));

watchEffect(() => {
  if (toolbarRef.value) {
    toolbarRef.value.options = {
      rowFlash: rowFlash.value,
      duration: duration.value,
      live: live.value,
    };
  }
});

function gridElement() {
  return rootRef.value?.querySelector('revo-grid') as HTMLRevoGridElement | null;
}

function updateRow(row: MarketRow): MarketRow {
  const delta = Number((Math.random() * 8 - 4).toFixed(2));
  const price = Number(Math.max(1, row.price + delta).toFixed(2));
  return {
    ...row,
    price,
    change: Number(((price - row.price) / row.price * 100).toFixed(2)),
    volume: row.volume + Math.round(Math.random() * 5000),
  };
}

function updateAt(rowIndex: number) {
  const previous = rows.value[rowIndex];
  const next = updateRow(previous);
  const grid = gridElement();
  grid?.dispatchEvent(new CustomEvent('beforeedit', {
    cancelable: true,
    detail: {
      rowIndex,
      prop: 'price',
      val: next.price,
      model: previous,
      type: 'rgRow',
    },
  }));
  grid?.dispatchEvent(new CustomEvent('flashcell', {
    detail: {
      data: {
        [rowIndex]: {
          price: next.price,
          change: next.change,
          volume: next.volume,
        },
      },
      previousData: {
        [rowIndex]: {
          price: previous.price,
          change: previous.change,
          volume: previous.volume,
        },
      },
      type: 'rgRow',
      eventTypes: ['manual-update'],
    },
  }));
  rows.value = rows.value.map((row, index) => index === rowIndex ? next : row);
}

function updateRandomRow() {
  updateAt(Math.floor(Math.random() * rows.value.length));
}

function burstUpdate() {
  rows.value.forEach((_row, index) => updateAt(index));
}

function startFeed() {
  if (!liveTimer) {
    liveTimer = setInterval(updateRandomRow, 900);
    live.value = true;
  }
}

function stopFeed() {
  if (liveTimer) clearInterval(liveTimer);
  liveTimer = undefined;
  live.value = false;
}

async function manualRowFlash() {
  const pluginsList = await gridElement()?.getPlugins();
  const plugin = pluginsList?.find(item => item?.constructor?.name === 'CellFlashPlugin') as CellFlashPlugin | undefined;
  plugin?.flashRows({ rowIndex: 0, rowClassName: 'manual-row-flash' });
}

async function historyAction(method: 'undo' | 'redo') {
  const pluginsList = await gridElement()?.getPlugins();
  const history = pluginsList?.find(item => item?.constructor?.name === 'HistoryPlugin') as HistoryPlugin | undefined;
  history?.[method]();
}

function handleToolbarOptionsChange(event: CellFlashToolbarOptionsChangeEvent) {
  rowFlash.value = event.detail.rowFlash;
  duration.value = event.detail.duration;
}

function handleToolbarAction(event: CellFlashToolbarActionEvent) {
  if (event.detail === 'update-row') {
    updateRandomRow();
    return;
  }
  if (event.detail === 'burst-update') {
    burstUpdate();
    return;
  }
  if (event.detail === 'start-feed') {
    startFeed();
    return;
  }
  if (event.detail === 'stop-feed') {
    stopFeed();
    return;
  }
  if (event.detail === 'manual-row-flash') {
    void manualRowFlash();
    return;
  }
  void historyAction(event.detail);
}

onBeforeUnmount(stopFeed);
</script>
React tsx
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { RevoGrid, type ColumnRegular, type DataType, BasePlugin } from '@revolist/react-datagrid';
import {
  CellFlashPlugin,
  EventManagerPlugin,
  HistoryPlugin,
  cellFlashArrowTemplate,
  type CellFlashConfig,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
  CELL_FLASH_TOOLBAR_ACTION_EVENT,
  CELL_FLASH_TOOLBAR_OPTIONS_CHANGE_EVENT,
  CELL_FLASH_TOOLBAR_TAG,
  defineCellFlashToolbarElement,
  type CellFlashToolbarActionEvent,
  type CellFlashToolbarElement,
  type CellFlashToolbarOptionsChangeEvent,
} from './cell-flash-toolbar';

const { isDark } = currentTheme();
defineCellFlashToolbarElement();

type MarketRow = DataType & {
  symbol: string;
  price: number;
  change: number;
  volume: number;
};

const createRows = (): MarketRow[] => [
  { symbol: 'AAPL', price: 184.12, change: 0.4, volume: 124500 },
  { symbol: 'MSFT', price: 421.34, change: -0.2, volume: 98200 },
  { symbol: 'NVDA', price: 879.7, change: 1.6, volume: 143000 },
  { symbol: 'AMZN', price: 188.8, change: 0.1, volume: 76100 },
  { symbol: 'META', price: 494.2, change: -0.5, volume: 68200 },
];

function updateRow(row: MarketRow): MarketRow {
  const delta = Number((Math.random() * 8 - 4).toFixed(2));
  const price = Number(Math.max(1, row.price + delta).toFixed(2));
  return {
    ...row,
    price,
    change: Number(((price - row.price) / row.price * 100).toFixed(2)),
    volume: row.volume + Math.round(Math.random() * 5000),
  };
}

function CellFlash() {
  const gridRef = useRef<HTMLRevoGridElement>(null);
  const toolbarRef = useRef<CellFlashToolbarElement>(null);
  const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
  const [rows, setRows] = useState<MarketRow[]>(() => createRows());
  const [rowFlash, setRowFlash] = useState(false);
  const [duration, setDuration] = useState(1000);
  const [live, setLive] = useState(false);

  const columns: ColumnRegular[] = useMemo(
    () => [
      { name: 'Symbol', prop: 'symbol', size: 110 },
      {
        name: 'Price',
        prop: 'price',
        size: 130,
        flash: (_value, context) => ({
          flash: true,
          rowFlash: Math.abs(Number(context.value) - Number(context.previousValue)) > 2,
        }),
        cellTemplate: cellFlashArrowTemplate((h, { value }) => h('span', null, Number(value).toFixed(2))),
      },
      {
        name: 'Change %',
        prop: 'change',
        size: 120,
        flash: true,
        cellTemplate: cellFlashArrowTemplate((h, { value }) => h('span', null, `${Number(value).toFixed(2)}%`)),
      },
      { name: 'Volume', prop: 'volume', size: 130, flash: true },
    ],
    [],
  );

  const cellFlash = useMemo<CellFlashConfig>(
    () => ({
      duration,
      rowDuration: duration,
      queue: 'merge',
      mode: rowFlash ? 'cell-and-row' : 'cell',
      clearOnSourceChange: false,
      aria: true,
      labels: {
        cellUpdated: state => `Market cell ${String(state.prop)} in row ${state.rowIndex + 1} changed`,
        rowUpdated: state => `Market row ${state.rowIndex + 1} changed`,
      },
    }),
    [duration, rowFlash],
  );

  const eventManager = useMemo(
    () => ({ applyEventsToSource: true }),
    [],
  );

  const plugins = useMemo(
    () => [EventManagerPlugin, HistoryPlugin, CellFlashPlugin],
    [],
  ) as any as (typeof BasePlugin)[];

  const updateAt = useCallback((rowIndex: number) => {
    setRows(current => {
      const previous = current[rowIndex];
      const next = updateRow(previous);
      const updated = [...current];
      updated[rowIndex] = next;
      const grid = gridRef.current;
      grid?.dispatchEvent(new CustomEvent('beforeedit', {
        cancelable: true,
        detail: {
          rowIndex,
          prop: 'price',
          val: next.price,
          model: previous,
          type: 'rgRow',
        },
      }));
      grid?.dispatchEvent(new CustomEvent('flashcell', {
        detail: {
          data: { [rowIndex]: { change: next.change, volume: next.volume } },
          previousData: { [rowIndex]: { change: previous.change, volume: previous.volume } },
          type: 'rgRow',
          eventTypes: ['manual-update'],
        },
      }));
      return updated;
    });
  }, []);

  const stopFeed = useCallback(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = undefined;
    }
    setLive(false);
  }, []);

  useEffect(() => stopFeed, [stopFeed]);

  const withPlugin = async (callback: (plugin: any) => void) => {
    const pluginsList = await gridRef.current?.getPlugins();
    const plugin = pluginsList?.find(item => item?.constructor?.name === 'CellFlashPlugin');
    if (plugin) callback(plugin);
  };

  const withHistory = async (method: 'undo' | 'redo') => {
    const pluginsList = await gridRef.current?.getPlugins();
    const history = pluginsList?.find(item => item?.constructor?.name === 'HistoryPlugin');
    history?.[method]?.();
  };

  useEffect(() => {
    if (toolbarRef.current) {
      toolbarRef.current.options = { rowFlash, duration, live };
    }
  }, [duration, live, rowFlash]);

  useEffect(() => {
    const toolbar = toolbarRef.current;
    if (!toolbar) {
      return;
    }
    const handleOptionsChange = (event: Event) => {
      const { rowFlash: nextRowFlash, duration: nextDuration } = (event as CellFlashToolbarOptionsChangeEvent).detail;
      setRowFlash(nextRowFlash);
      setDuration(nextDuration);
    };
    const handleAction = (event: Event) => {
      const action = (event as CellFlashToolbarActionEvent).detail;
      if (action === 'update-row') {
        updateAt(Math.floor(Math.random() * rows.length));
        return;
      }
      if (action === 'burst-update') {
        rows.forEach((_row, index) => updateAt(index));
        return;
      }
      if (action === 'start-feed') {
        if (!timerRef.current) {
          timerRef.current = setInterval(() => updateAt(Math.floor(Math.random() * rows.length)), 900);
          setLive(true);
        }
        return;
      }
      if (action === 'stop-feed') {
        stopFeed();
        return;
      }
      if (action === 'manual-row-flash') {
        void withPlugin(plugin => plugin.flashRows({ rowIndex: 0, rowClassName: 'manual-row-flash' }));
        return;
      }
      void withHistory(action);
    };
    toolbar.addEventListener(CELL_FLASH_TOOLBAR_OPTIONS_CHANGE_EVENT, handleOptionsChange);
    toolbar.addEventListener(CELL_FLASH_TOOLBAR_ACTION_EVENT, handleAction);
    return () => {
      toolbar.removeEventListener(CELL_FLASH_TOOLBAR_OPTIONS_CHANGE_EVENT, handleOptionsChange);
      toolbar.removeEventListener(CELL_FLASH_TOOLBAR_ACTION_EVENT, handleAction);
    };
  }, [rows, stopFeed, updateAt]);

  const RevoGridWithPluginProps = RevoGrid as any;

  return (
    <div style={{ display: 'grid', gap: 10 }}>
      {React.createElement(CELL_FLASH_TOOLBAR_TAG, { ref: toolbarRef })}
      <RevoGridWithPluginProps
        ref={gridRef}
        style={{ minHeight: 420 }}
        columns={columns}
        source={rows}
        eventManager={eventManager}
        cellFlash={cellFlash}
        plugins={plugins}
        theme={isDark() ? 'darkCompact' : 'compact'}
        hide-attribution
      />
    </div>
  );
}

export default CellFlash;
Angular ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, ViewChild, ViewEncapsulation } from '@angular/core';
import type { AfterViewInit, OnDestroy } from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import type { ColumnRegular, DataType } from '@revolist/revogrid';
import {
  CellFlashPlugin,
  EventManagerPlugin,
  HistoryPlugin,
  cellFlashArrowTemplate,
  type CellFlashConfig,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
  defineCellFlashToolbarElement,
  type CellFlashToolbarActionEvent,
  type CellFlashToolbarElement,
  type CellFlashToolbarOptionsChangeEvent,
} from './cell-flash-toolbar';

defineCellFlashToolbarElement();

type MarketRow = DataType & {
  symbol: string;
  price: number;
  change: number;
  volume: number;
};

@Component({
  selector: 'cell-flash-grid',
  standalone: true,
  imports: [RevoGrid],
  template: `
    <div style="display: grid; gap: 10px;">
      <cell-flash-toolbar
        #toolbarRef
        (cell-flash-toolbar-action)="handleToolbarAction($event)"
        (cell-flash-toolbar-options-change)="handleToolbarOptionsChange($event)"
      ></cell-flash-toolbar>
      <revo-grid
        #gridRef
        [columns]="columns"
        [source]="source"
        [eventManager]="eventManager"
        [cellFlash]="cellFlash"
        [plugins]="plugins"
        [theme]="theme"
        [hideAttribution]="true"
        style="min-height: 420px; min-width: 600px"
      ></revo-grid>
    </div>
  `,
  encapsulation: ViewEncapsulation.None,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class CellFlashGridComponent implements AfterViewInit, OnDestroy {
  @ViewChild('gridRef', { static: true }) gridRef!: ElementRef<HTMLRevoGridElement>;
  @ViewChild('toolbarRef', { read: ElementRef }) toolbarRef?: ElementRef<CellFlashToolbarElement>;

  theme = currentTheme().isDark() ? 'darkCompact' : 'compact';
  source: MarketRow[] = this.createRows();
  rowFlash = false;
  duration = 1000;
  live = false;
  liveTimer?: ReturnType<typeof setInterval>;

  columns: ColumnRegular[] = [
    { name: 'Symbol', prop: 'symbol', size: 110 },
    {
      name: 'Price',
      prop: 'price',
      size: 130,
      flash: (_value, context) => ({
        flash: true,
        rowFlash: Math.abs(Number(context.value) - Number(context.previousValue)) > 2,
      }),
      cellTemplate: cellFlashArrowTemplate((h, { value }) => h('span', null, Number(value).toFixed(2))),
    },
    {
      name: 'Change %',
      prop: 'change',
      size: 120,
      flash: true,
      cellTemplate: cellFlashArrowTemplate((h, { value }) => h('span', null, `${Number(value).toFixed(2)}%`)),
    },
    { name: 'Volume', prop: 'volume', size: 130, flash: true },
  ];

  plugins = [EventManagerPlugin, HistoryPlugin, CellFlashPlugin];

  eventManager = {
    applyEventsToSource: true,
  };

  get cellFlash(): CellFlashConfig {
    return {
      duration: this.duration,
      rowDuration: this.duration,
      queue: 'merge',
      mode: this.rowFlash ? 'cell-and-row' : 'cell',
      clearOnSourceChange: false,
      aria: true,
      labels: {
        cellUpdated: state => `Market cell ${String(state.prop)} in row ${state.rowIndex + 1} changed`,
        rowUpdated: state => `Market row ${state.rowIndex + 1} changed`,
      },
    };
  }

  createRows(): MarketRow[] {
    return [
      { symbol: 'AAPL', price: 184.12, change: 0.4, volume: 124500 },
      { symbol: 'MSFT', price: 421.34, change: -0.2, volume: 98200 },
      { symbol: 'NVDA', price: 879.7, change: 1.6, volume: 143000 },
      { symbol: 'AMZN', price: 188.8, change: 0.1, volume: 76100 },
      { symbol: 'META', price: 494.2, change: -0.5, volume: 68200 },
    ];
  }

  ngAfterViewInit() {
    this.syncToolbar();
  }

  syncToolbar() {
    if (this.toolbarRef?.nativeElement) {
      this.toolbarRef.nativeElement.options = {
        rowFlash: this.rowFlash,
        duration: this.duration,
        live: this.live,
      };
    }
  }

  updateRow(row: MarketRow): MarketRow {
    const delta = Number((Math.random() * 8 - 4).toFixed(2));
    const price = Number(Math.max(1, row.price + delta).toFixed(2));
    return {
      ...row,
      price,
      change: Number(((price - row.price) / row.price * 100).toFixed(2)),
      volume: row.volume + Math.round(Math.random() * 5000),
    };
  }

  updateAt(rowIndex: number) {
    const previous = this.source[rowIndex];
    const next = this.updateRow(previous);
    const grid = this.gridRef.nativeElement;
    grid.dispatchEvent(new CustomEvent('beforeedit', {
      cancelable: true,
      detail: {
        rowIndex,
        prop: 'price',
        val: next.price,
        model: previous,
        type: 'rgRow',
      },
    }));
    grid.dispatchEvent(new CustomEvent('flashcell', {
      detail: {
        data: {
          [rowIndex]: {
            change: next.change,
            volume: next.volume,
          },
        },
        previousData: {
          [rowIndex]: {
            change: previous.change,
            volume: previous.volume,
          },
        },
        type: 'rgRow',
        eventTypes: ['manual-update'],
      },
    }));
    this.source = this.source.map((row, index) => index === rowIndex ? next : row);
  }

  updateRandomRow() {
    this.updateAt(Math.floor(Math.random() * this.source.length));
  }

  burstUpdate() {
    this.source.forEach((_row, index) => this.updateAt(index));
  }

  startFeed() {
    if (!this.liveTimer) {
      this.liveTimer = setInterval(() => this.updateRandomRow(), 900);
      this.live = true;
      this.syncToolbar();
    }
  }

  stopFeed() {
    if (this.liveTimer) clearInterval(this.liveTimer);
    this.liveTimer = undefined;
    this.live = false;
    this.syncToolbar();
  }

  async historyAction(method: 'undo' | 'redo') {
    const plugins = await this.gridRef.nativeElement.getPlugins();
    const history = plugins.find(item => item?.constructor?.name === 'HistoryPlugin') as HistoryPlugin | undefined;
    history?.[method]();
  }

  async manualRowFlash() {
    const plugins = await this.gridRef.nativeElement.getPlugins();
    const plugin = plugins.find(item => item?.constructor?.name === 'CellFlashPlugin') as CellFlashPlugin | undefined;
    plugin?.flashRows({ rowIndex: 0, rowClassName: 'manual-row-flash' });
  }

  handleToolbarOptionsChange(event: CellFlashToolbarOptionsChangeEvent) {
    this.rowFlash = event.detail.rowFlash;
    this.duration = event.detail.duration;
    this.syncToolbar();
  }

  handleToolbarAction(event: CellFlashToolbarActionEvent) {
    if (event.detail === 'update-row') {
      this.updateRandomRow();
      return;
    }
    if (event.detail === 'burst-update') {
      this.burstUpdate();
      return;
    }
    if (event.detail === 'start-feed') {
      this.startFeed();
      return;
    }
    if (event.detail === 'stop-feed') {
      this.stopFeed();
      return;
    }
    if (event.detail === 'manual-row-flash') {
      void this.manualRowFlash();
      return;
    }
    void this.historyAction(event.detail);
  }

  ngOnDestroy() {
    this.stopFeed();
  }
}

Add CellFlashPlugin and mark columns with flash. Existing boolean predicates still work, and EventManagerPlugin keeps edit events connected to the source update flow.

import { CellFlashPlugin, EventManagerPlugin } from '@revolist/revogrid-pro';
grid.plugins = [EventManagerPlugin, CellFlashPlugin];
grid.eventManager = { applyEventsToSource: true };
grid.columns = [
{
name: 'Price',
prop: 'price',
flash: () => true,
},
];

The flash callback receives the new value and a context object with the previous value, row model, row index, column, row type, and edit metadata. Return a boolean for simple behavior or a decision object to customize the flash.

grid.columns = [
{
name: 'Price',
prop: 'price',
flash: (value, context) => ({
flash: true,
rowFlash: Math.abs(Number(value) - Number(context.previousValue)) > 2,
direction: Number(value) > Number(context.previousValue) ? 'up' : 'down',
duration: 800,
rowDuration: 1200,
className: 'price-flash',
rowClassName: 'large-move-row',
}),
cellTemplate: cellFlashArrowTemplate({
symbols: { up: '+', down: '-', changed: '~' },
className: 'price-flash-arrow',
arrowClassName: 'price-flash-symbol',
directionClassNames: { up: 'positive', down: 'negative', changed: 'neutral' },
}),
},
];

Use grid.cellFlash or additionalData.cellFlash to tune behavior. Defaults preserve legacy behavior: enabled, cell-only, 1000ms duration, replace active flashes on each flash event, and clear on source reset.

grid.cellFlash = {
duration: 700,
rowDuration: 1000,
mode: 'cell-and-row',
queue: 'merge',
maxActive: 300,
clearOnSourceChange: true,
respectReducedMotion: true,
aria: {
enabled: true,
live: 'polite',
atomic: true,
liveRegionClassName: 'rv-cell-flash-live-region',
},
labels: {
cellUpdated: state => `Updated ${String(state.prop)} in row ${state.rowIndex + 1}`,
rowUpdated: state => `Updated row ${state.rowIndex + 1}`,
},
className: 'cell-flash-active',
rowClassName: 'row-flash-active',
};

Queue modes:

  • replace clears current flash state before applying the next batch.
  • merge keeps existing flashes active until their own timers expire.

Announcement priority is: per-change ariaLabel, aria.label, labels.cellUpdated or labels.rowUpdated, then the built-in defaults. Use labels for normal localization and aria.label when one callback should fully own every announcement. The live-region politeness, atomic flag, and live-region class are also configurable through aria.

Manual methods are available from the plugin instance. This is useful when updates come from a websocket, polling loop, or external calculation.

const plugins = await grid.getPlugins();
const cellFlash = plugins.find(plugin => plugin.constructor.name === 'CellFlashPlugin');
cellFlash.flashCell({
rowIndex: 0,
prop: 'price',
previousValue: 100,
value: 104,
direction: 'up',
});
cellFlash.flashRows({
rowIndex: 0,
rowClassName: 'manual-row-flash',
});
cellFlash.clearFlash({ rowIndex: 0, prop: 'price' });

The existing event API is still supported:

grid.dispatchEvent(new CustomEvent('flashcell', {
detail: {
data: { 0: { price: 104 } },
previousData: { 0: { price: 100 } },
type: 'rgRow',
},
}));

The plugin sets flash, flash-direction, data-flash-direction, and duration CSS variables on active cells. Row flashes receive flash-row and the configured row class.

revo-grid {
--rv-change-highlight: rgba(255, 238, 0, 0.6);
--rv-row-change-highlight: rgba(255, 238, 0, 0.22);
--rv-cell-flash-up-color: #15803d;
--rv-cell-flash-down-color: #dc2626;
--rv-cell-flash-arrow-transition-duration: 0.3s;
--rv-cell-flash-arrow-transition-timing: ease-in-out;
}
revo-grid[theme^='dark'] {
--rv-change-highlight: rgba(250, 204, 21, 0.34);
--rv-row-change-highlight: rgba(250, 204, 21, 0.16);
--rv-cell-flash-up-color: #4ade80;
--rv-cell-flash-down-color: #f87171;
}
revo-grid .rgCell[flash][flash-direction="up"] {
color: var(--rv-cell-flash-up-color);
}
revo-grid .rgRow[flash-row].large-move-row {
font-weight: 600;
}

When respectReducedMotion is enabled, users with reduced-motion preferences keep the flash attributes and final highlight state without the animation.