Skip to content

Auto Size Row

The Row Auto Size Plugin enhances RevoGrid by automatically calculating and adjusting row heights based on their content. This ensures optimal display of data, particularly useful for cells containing multi-line text, rich content, or varying amounts of information.

When the plugin is enabled, normal grid cells wrap text so the rendered content matches the height calculation. Row heights are recalculated from the wrapped content after source changes, edits, scrolling into new virtual rows, configuration changes, and manual column resizing.

Source code
TypeScript ts
// src/components/row-autosize/rowAutosize.ts
import { defineCustomElements } from '@revolist/revogrid/loader';
defineCustomElements();

import {
  RowHeaderPlugin,
  RowAutoSizePlugin,
} from '@revolist/revogrid-pro';

import { currentTheme } from '../composables/useRandomData';
import { faker } from '@faker-js/faker';

const { isDark } = currentTheme();
const ROW_COUNT = 1000;
const COLUMN_COUNT = 50;
const STATUSES = ['Active', 'Pending', 'Completed', 'Blocked'];
const BADGE_STYLES: Record<string, Record<string, string>> = {
  Active: { backgroundColor: '#d1fae5', color: '#065f46', borderColor: '#34d399' },
  Pending: { backgroundColor: '#fef3c7', color: '#92400e', borderColor: '#f59e0b' },
  Completed: { backgroundColor: '#dbeafe', color: '#1e40af', borderColor: '#60a5fa' },
  Blocked: { backgroundColor: '#fee2e2', color: '#991b1b', borderColor: '#f87171' },
};

const getColumnKind = (columnIndex: number) => {
  if (columnIndex % 5 === 2) return 'badge';
  if (columnIndex % 5 === 3) return 'date';
  return 'text';
};

const getColumnName = (columnIndex: number) => {
  const group = Math.floor(columnIndex / 5) + 1;
  const names = ['Summary', 'Notes', 'Status', 'Due Date', 'Owner'];
  return `${names[columnIndex % 5]} ${group}`;
};

const getColumnSize = (columnIndex: number) => {
  if (columnIndex % 5 === 1) return 320;
  if (columnIndex % 5 === 2) return 150;
  if (columnIndex % 5 === 3) return 170;
  return 190;
};

const formatDate = (value: unknown) => new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: '2-digit',
  year: 'numeric',
}).format(new Date(String(value)));

// Generate sample data with varying content lengths
const generateData = (count: number) => {
  return Array.from({ length: count }, (_, rowIndex) => {
    const row: Record<string, string> = {};
    for (let columnIndex = 0; columnIndex < COLUMN_COUNT; columnIndex++) {
      const kind = getColumnKind(columnIndex);
      if (kind === 'badge') {
        row[`field${columnIndex}`] = STATUSES[(rowIndex + columnIndex) % STATUSES.length];
      } else if (kind === 'date') {
        row[`field${columnIndex}`] = new Date(2026, columnIndex % 12, (rowIndex % 28) + 1).toISOString();
      } else {
        row[`field${columnIndex}`] = columnIndex % 5 === 1
          ? Array.from(
            { length: Math.floor(Math.random() * 4) + 1 },
            () => faker.lorem.sentence()
          ).join('\n')
          : `${faker.commerce.productName()} ${rowIndex + 1}.${columnIndex + 1}`;
      }
    }
    return row;
  });
};

const cellTemplate = (h: any, { value, prop }: { value: unknown; prop: string }) => {
  const columnIndex = Number(prop.replace('field', ''));
  const kind = getColumnKind(columnIndex);

  if (kind === 'badge') {
    const style = BADGE_STYLES[String(value)] || BADGE_STYLES.Pending;
    return h('span', {
      style: {
        display: 'inline-flex',
        alignItems: 'center',
        border: `1px solid ${style.borderColor}`,
        borderRadius: '999px',
        padding: '2px 8px',
        fontSize: '12px',
        fontWeight: '600',
        lineHeight: '18px',
        backgroundColor: style.backgroundColor,
        color: style.color,
      },
    }, value);
  }

  if (kind === 'date') {
    return h('span', {
      style: {
        color: '#4b5563',
        fontVariantNumeric: 'tabular-nums',
        whiteSpace: 'nowrap',
      },
    }, formatDate(value));
  }

  return h('span', { style: { whiteSpace: 'pre-wrap' } }, value);
};

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

  const grid = document.createElement('revo-grid');
  // Define columns;
  grid.columns = Array.from({ length: COLUMN_COUNT }, (_, columnIndex) => ({
    prop: `field${columnIndex}`,
    name: getColumnName(columnIndex),
    size: getColumnSize(columnIndex),
    readonly: columnIndex % 5 === 4,
    cellTemplate,
  }));
  // Define plugin
  grid.plugins = [RowHeaderPlugin, RowAutoSizePlugin];

  grid.rowAutoSize = {
    minHeight: 24,
    maxHeight: 200,
  };

  grid.theme = isDark() ? 'darkCompact' : 'compact';
  grid.resize = true;
  grid.range = true;
  grid.hideAttribution = true;
  parent.appendChild(grid);
  grid.source = generateData(ROW_COUNT);
}
React tsx
import React, { useState, useMemo } from 'react';
import { RevoGrid, Template, type ColumnDataSchemaModel } from '@revolist/react-datagrid';
import { RowHeaderPlugin, RowAutoSizePlugin } from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import { faker } from '@faker-js/faker';

const ROW_COUNT = 1000;
const COLUMN_COUNT = 50;
const STATUSES = ['Active', 'Pending', 'Completed', 'Blocked'];
const BADGE_STYLES: Record<string, React.CSSProperties> = {
  Active: { backgroundColor: '#d1fae5', color: '#065f46', borderColor: '#34d399' },
  Pending: { backgroundColor: '#fef3c7', color: '#92400e', borderColor: '#f59e0b' },
  Completed: { backgroundColor: '#dbeafe', color: '#1e40af', borderColor: '#60a5fa' },
  Blocked: { backgroundColor: '#fee2e2', color: '#991b1b', borderColor: '#f87171' },
};

const getColumnKind = (columnIndex: number) => {
  if (columnIndex % 5 === 2) return 'badge';
  if (columnIndex % 5 === 3) return 'date';
  return 'text';
};

const getColumnName = (columnIndex: number) => {
  const group = Math.floor(columnIndex / 5) + 1;
  const names = ['Summary', 'Notes', 'Status', 'Due Date', 'Owner'];
  return `${names[columnIndex % 5]} ${group}`;
};

const getColumnSize = (columnIndex: number) => {
  if (columnIndex % 5 === 1) return 320;
  if (columnIndex % 5 === 2) return 150;
  if (columnIndex % 5 === 3) return 170;
  return 190;
};

const formatDate = (value: unknown) => new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: '2-digit',
  year: 'numeric',
}).format(new Date(String(value)));

function CellValue({ value, prop }: Partial<ColumnDataSchemaModel>) {
  const columnIndex = Number(String(prop).replace('field', ''));
  const kind = getColumnKind(columnIndex);

  if (kind === 'badge') {
    return (
      <span
        style={{
          display: 'inline-flex',
          alignItems: 'center',
          border: `1px solid ${(BADGE_STYLES[String(value)] || BADGE_STYLES.Pending).borderColor}`,
          borderRadius: 999,
          padding: '2px 8px',
          fontSize: 12,
          fontWeight: 600,
          lineHeight: '18px',
          ...(BADGE_STYLES[String(value)] || BADGE_STYLES.Pending),
        }}
      >
        {value}
      </span>
    );
  }

  if (kind === 'date') {
    return <span style={{ color: '#4b5563', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>{formatDate(value)}</span>;
  }

  return <span style={{ whiteSpace: 'pre-wrap' }}>{value}</span>;
}

const RowAutosizeGrid = () => {
  const { isDark } = currentTheme();

  const theme = isDark() ? 'darkCompact' : 'compact';
  
  // Generate sample data with varying content lengths
  const generateData = (count: number) => {
    return Array.from({ length: count }, (_, rowIndex) => {
      const row: Record<string, string> = {};
      for (let columnIndex = 0; columnIndex < COLUMN_COUNT; columnIndex++) {
        const kind = getColumnKind(columnIndex);
        if (kind === 'badge') {
          row[`field${columnIndex}`] = STATUSES[(rowIndex + columnIndex) % STATUSES.length];
        } else if (kind === 'date') {
          row[`field${columnIndex}`] = new Date(2026, columnIndex % 12, (rowIndex % 28) + 1).toISOString();
        } else {
          row[`field${columnIndex}`] = columnIndex % 5 === 1
            ? Array.from(
              { length: Math.floor(Math.random() * 4) + 1 },
              () => faker.lorem.sentence()
            ).join('\n')
            : `${faker.commerce.productName()} ${rowIndex + 1}.${columnIndex + 1}`;
        }
      }
      return row;
    });
  };

  const [rows] = useState(() => generateData(ROW_COUNT));

  const columns = useMemo(
    () => {
      const cellTemplate = Template(CellValue);
      return Array.from({ length: COLUMN_COUNT }, (_, columnIndex) => ({
        prop: `field${columnIndex}`,
        name: getColumnName(columnIndex),
        size: getColumnSize(columnIndex),
        readonly: columnIndex % 5 === 4,
        cellTemplate,
      }));
    },
    []
  );

  const plugins = useMemo(() => [RowHeaderPlugin, RowAutoSizePlugin], []);
  
  const rowAutoSize = useMemo(
    () => ({
      minHeight: 24,
      maxHeight: 200,
    }),
    [],
  );

  return (
    <RevoGrid
      theme={theme}
      columns={columns}
      source={rows}
      plugins={plugins}
      rowAutoSize={rowAutoSize}
      resize
      hideAttribution
    />
  );
};

export default RowAutosizeGrid;
Vue vue
<template>
  <RevoGrid
    class="overflow-hidden cell-border"
    :theme="isDark ? 'darkMaterial' : 'material'"
    :columns="columns"
    :source="rows"
    :plugins="plugins"
    :row-auto-size.prop="rowAutoSize"
    range
    resize
    hide-attribution
  />
</template>

<script setup lang="ts">
import { defineComponent, h, ref, shallowRef } from 'vue';
import RevoGrid, { VGridVueTemplate, type ColumnDataSchemaModel } from '@revolist/vue3-datagrid';
import {
  RowHeaderPlugin,
  RowOddPlugin,
  RowAutoSizePlugin,
} from '@revolist/revogrid-pro';
import { currentThemeVue } from '../composables/useRandomData';
import { faker } from '@faker-js/faker';

const { isDark } = currentThemeVue();
const ROW_COUNT = 1000;
const COLUMN_COUNT = 50;
const STATUSES = ['Active', 'Pending', 'Completed', 'Blocked'];
const BADGE_STYLES: Record<string, Record<string, string>> = {
  Active: { backgroundColor: '#d1fae5', color: '#065f46', borderColor: '#34d399' },
  Pending: { backgroundColor: '#fef3c7', color: '#92400e', borderColor: '#f59e0b' },
  Completed: { backgroundColor: '#dbeafe', color: '#1e40af', borderColor: '#60a5fa' },
  Blocked: { backgroundColor: '#fee2e2', color: '#991b1b', borderColor: '#f87171' },
};

const getColumnKind = (columnIndex: number) => {
  if (columnIndex % 5 === 2) return 'badge';
  if (columnIndex % 5 === 3) return 'date';
  return 'text';
};

const getColumnName = (columnIndex: number) => {
  const group = Math.floor(columnIndex / 5) + 1;
  const names = ['Summary', 'Notes', 'Status', 'Due Date', 'Owner'];
  return `${names[columnIndex % 5]} ${group}`;
};

const getColumnSize = (columnIndex: number) => {
  if (columnIndex % 5 === 1) return 320;
  if (columnIndex % 5 === 2) return 150;
  if (columnIndex % 5 === 3) return 170;
  return 190;
};

const formatDate = (value: unknown) => new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: '2-digit',
  year: 'numeric',
}).format(new Date(String(value)));

// Generate sample data with varying content lengths
const generateData = (count: number) => {
  return Array.from({ length: count }, (_, rowIndex) => {
    const row: Record<string, string> = {};
    for (let columnIndex = 0; columnIndex < COLUMN_COUNT; columnIndex++) {
      const kind = getColumnKind(columnIndex);
      if (kind === 'badge') {
        row[`field${columnIndex}`] = STATUSES[(rowIndex + columnIndex) % STATUSES.length];
      } else if (kind === 'date') {
        row[`field${columnIndex}`] = new Date(2026, columnIndex % 12, (rowIndex % 28) + 1).toISOString();
      } else {
        row[`field${columnIndex}`] = columnIndex % 5 === 1
          ? Array.from(
            { length: Math.floor(Math.random() * 4) + 1 },
            () => faker.lorem.sentence()
          ).join('\n')
          : `${faker.commerce.productName()} ${rowIndex + 1}.${columnIndex + 1}`;
      }
    }
    return row;
  });
};

const rows = shallowRef(generateData(ROW_COUNT));

const CellValue = defineComponent({
  props: ['value', 'prop'],
  setup(cellProps: Partial<ColumnDataSchemaModel>) {
    return () => {
      const columnIndex = Number(String(cellProps.prop).replace('field', ''));
      const kind = getColumnKind(columnIndex);

      if (kind === 'badge') {
        const style = BADGE_STYLES[String(cellProps.value)] || BADGE_STYLES.Pending;
        return h('span', {
          style: {
            display: 'inline-flex',
            alignItems: 'center',
            border: `1px solid ${style.borderColor}`,
            borderRadius: '999px',
            padding: '2px 8px',
            fontSize: '12px',
            fontWeight: '600',
            lineHeight: '18px',
            backgroundColor: style.backgroundColor,
            color: style.color,
          },
        }, cellProps.value);
      }

      if (kind === 'date') {
        return h('span', {
          style: {
            color: '#4b5563',
            fontVariantNumeric: 'tabular-nums',
            whiteSpace: 'nowrap',
          },
        }, formatDate(cellProps.value));
      }

      return h('span', { style: { whiteSpace: 'pre-wrap' } }, cellProps.value);
    };
  },
});

const cellTemplate = VGridVueTemplate(CellValue);
const columns = ref(Array.from({ length: COLUMN_COUNT }, (_, columnIndex) => ({
  prop: `field${columnIndex}`,
  name: getColumnName(columnIndex),
  size: getColumnSize(columnIndex),
  readonly: columnIndex % 5 === 4,
  cellTemplate,
})));

const plugins = [RowHeaderPlugin, RowAutoSizePlugin, RowOddPlugin];

const rowAutoSize = {
  minHeight: 24,
  maxHeight: 200,
};
</script>
Angular ts
import { Component, Input, ViewEncapsulation, OnInit, NO_ERRORS_SCHEMA } from '@angular/core';
import { RevoGrid, Template } from '@revolist/angular-datagrid';
import type { ColumnDataSchemaModel } from '@revolist/revogrid';
import {
  RowHeaderPlugin,
  RowAutoSizePlugin,
} from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import { faker } from '@faker-js/faker';

const ROW_COUNT = 1000;
const COLUMN_COUNT = 50;
const STATUSES = ['Active', 'Pending', 'Completed', 'Blocked'];
const BADGE_STYLES: Record<string, Record<string, string>> = {
  Active: { backgroundColor: '#d1fae5', color: '#065f46', borderColor: '#34d399' },
  Pending: { backgroundColor: '#fef3c7', color: '#92400e', borderColor: '#f59e0b' },
  Completed: { backgroundColor: '#dbeafe', color: '#1e40af', borderColor: '#60a5fa' },
  Blocked: { backgroundColor: '#fee2e2', color: '#991b1b', borderColor: '#f87171' },
};

const getColumnKind = (columnIndex: number) => {
  if (columnIndex % 5 === 2) return 'badge';
  if (columnIndex % 5 === 3) return 'date';
  return 'text';
};

const getColumnName = (columnIndex: number) => {
  const group = Math.floor(columnIndex / 5) + 1;
  const names = ['Summary', 'Notes', 'Status', 'Due Date', 'Owner'];
  return `${names[columnIndex % 5]} ${group}`;
};

const getColumnSize = (columnIndex: number) => {
  if (columnIndex % 5 === 1) return 320;
  if (columnIndex % 5 === 2) return 150;
  if (columnIndex % 5 === 3) return 170;
  return 190;
};

const formatDate = (value: unknown) => new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: '2-digit',
  year: 'numeric',
}).format(new Date(String(value)));

@Component({
  selector: 'row-autosize-cell-value',
  standalone: true,
  template: `
    @if (kind === 'badge') {
      <span
        [style.display]="'inline-flex'"
        [style.align-items]="'center'"
        [style.border]="'1px solid ' + badgeStyle.borderColor"
        [style.border-radius]="'999px'"
        [style.padding]="'2px 8px'"
        [style.font-size]="'12px'"
        [style.font-weight]="'600'"
        [style.line-height]="'18px'"
        [style.background-color]="badgeStyle.backgroundColor"
        [style.color]="badgeStyle.color"
      >{{ value }}</span>
    } @else if (kind === 'date') {
      <span
        [style.color]="'#4b5563'"
        [style.font-variant-numeric]="'tabular-nums'"
        [style.white-space]="'nowrap'"
      >{{ formattedDate }}</span>
    } @else {
      <span [style.white-space]="'pre-wrap'">{{ value }}</span>
    }
  `,
})
export class RowAutosizeCellValueComponent {
  @Input() props!: ColumnDataSchemaModel;

  get value() {
    return this.props?.value;
  }

  get kind() {
    const columnIndex = Number(String(this.props?.prop).replace('field', ''));
    return getColumnKind(columnIndex);
  }

  get badgeStyle() {
    return BADGE_STYLES[String(this.value)] || BADGE_STYLES.Pending;
  }

  get formattedDate() {
    return formatDate(this.value);
  }
}

@Component({
  selector: 'row-autosize-grid',
  standalone: true,
  imports: [RevoGrid],
  template: `
    <revo-grid
      [theme]="theme"
      [columns]="columns"
      [source]="rows"
      [plugins]="plugins"
      [rowAutoSize]="rowAutoSize"
      [resize]="true"
      [hideAttribution]="true"
      style="min-height: 400px; min-width: 600px"
    ></revo-grid>
  `,
  encapsulation: ViewEncapsulation.None,
  // Allows Angular demos to bind RevoGrid plugin props that are not wrapper inputs.
  schemas: [NO_ERRORS_SCHEMA],
})
export class RowAutosizeGridComponent implements OnInit {
  theme = currentTheme().isDark() ? 'darkCompact' : 'compact';

  // Generate sample data with varying content lengths
  generateData(count: number) {
    return Array.from({ length: count }, (_, rowIndex) => {
      const row: Record<string, string> = {};
      for (let columnIndex = 0; columnIndex < COLUMN_COUNT; columnIndex++) {
        const kind = getColumnKind(columnIndex);
        if (kind === 'badge') {
          row[`field${columnIndex}`] = STATUSES[(rowIndex + columnIndex) % STATUSES.length];
        } else if (kind === 'date') {
          row[`field${columnIndex}`] = new Date(2026, columnIndex % 12, (rowIndex % 28) + 1).toISOString();
        } else {
          row[`field${columnIndex}`] = columnIndex % 5 === 1
            ? Array.from(
              { length: Math.floor(Math.random() * 4) + 1 },
              () => faker.lorem.sentence()
            ).join('\n')
            : `${faker.commerce.productName()} ${rowIndex + 1}.${columnIndex + 1}`;
        }
      }
      return row;
    });
  }

  rows = this.generateData(ROW_COUNT);

  cellTemplate = Template(RowAutosizeCellValueComponent);

  columns = Array.from({ length: COLUMN_COUNT }, (_, columnIndex) => ({
    prop: `field${columnIndex}`,
    name: getColumnName(columnIndex),
    size: getColumnSize(columnIndex),
    readonly: columnIndex % 5 === 4,
    cellTemplate: this.cellTemplate,
  }));

  plugins = [RowHeaderPlugin, RowAutoSizePlugin];


  rowAutoSize = {
    minHeight: 24,
    maxHeight: 200,
  };

  ngOnInit() {
    // Any additional setup if needed
  }
} 

Key Features

  • Automatic Height Calculation: Dynamically adjusts row heights based on content.
  • Content-Aware: Considers line breaks, text wrapping, and column widths.
  • Resize-Aware: Recalculates visible row heights after manual column resizing.
  • Configurable Limits: Set minimum and maximum heights to maintain consistency.
  • Custom Calculator Support: Implement your own height calculation logic.
  • Efficient Caching: Caches calculated heights to optimize performance.
  • Precise Mode: Optional precise calculations using DOM measurements.

To enable the Row Auto Size Plugin in your RevoGrid setup:

import { RowAutoSizePlugin } from '@revolist/revogrid-pro';
const grid = document.createElement('revo-grid');
grid.plugins = [RowAutoSizePlugin];
grid.rowAutoSize = {
minHeight: 24,
maxHeight: 200,
preciseSize: true, // Use DOM-based precise calculations
precalculate: true, // Warm approximate row sizes ahead of scroll
precalculateBatchSize: 500, // Rows processed per frame during warmup
};
grid.resize = true; // Optional: row heights adapt after user column resizing

The plugin supports several configuration options to customize its behavior:

type RowAutoSizeConfig = {
// Minimum row height in pixels (default: 24)
minHeight?: number;
// Maximum row height in pixels (default: 200)
maxHeight?: number;
// Use precise DOM-based calculations (slower but more accurate)
preciseSize?: boolean;
// Warm all approximate row heights in chunks before they are scrolled into view (default: true)
precalculate?: boolean;
// Rows processed per animation frame during non-precise warmup (default: 500)
precalculateBatchSize?: number;
// Custom height calculator function
calculateHeight?: (rowData: DataType, columns: ColumnRegular[]) => number | Promise<number>;
};

You can provide your own height calculation logic through the calculateHeight function:

grid.rowAutoSize = {
calculateHeight: (rowData, columns) => {
// Custom logic to determine row height
const maxLines = columns.reduce((max, col) => {
const content = rowData[col.prop]?.toString() || '';
const lines = content.split('\n').length;
return Math.max(max, lines);
}, 1);
return maxLines * 24; // 24px per line
}
};

The plugin automatically responds to various grid events to maintain accurate row heights:

  • Data Changes: Recalculates heights when the source data is updated
  • Cell Edits: Updates heights for edited rows
  • Viewport Changes: Calculates heights for newly visible rows
  • Configuration Changes: Recomputes all heights when plugin settings change
  • Column Resizing: Recomputes visible row heights after resized widths are rendered

The plugin is designed with performance in mind, but there are some considerations:

  1. Precise Mode: Using preciseSize: true provides more accurate calculations but is slower as it requires DOM operations.
  2. Precalculation: In non-precise mode, precalculate is enabled by default. The plugin warms row height cache in chunks so rows already have a height when they are scrolled into view.
  3. Batch Size: precalculateBatchSize controls how many rows are processed per animation frame. Larger values warm the cache faster but make each frame heavier.
  4. Large Datasets: For very large datasets, set precalculate: false to calculate only visible rows and preserve demand-driven behavior.
  5. Custom Calculators: When implementing a custom calculator, ensure it's efficient as it will be called frequently during scrolling.

To disable background warmup:

grid.rowAutoSize = {
minHeight: 24,
maxHeight: 200,
precalculate: false,
};
  1. Default Mode for Large Datasets: Use the default approximate calculation mode for large datasets to maintain performance.
  2. Custom Height Logic: Use calculateHeight when your data needs application-specific sizing.
  3. Reasonable Limits: Set appropriate minHeight and maxHeight to prevent extreme row sizes.
  4. Cache Management: The plugin automatically manages its cache, but consider clearing it when making major data changes.
  1. Multi-line Text:
grid.columns = [{
prop: 'description',
name: 'Description',
size: 200,
}];
grid.source = [{
description: 'This is a long description\nthat spans multiple lines\nand needs appropriate height'
}];
  1. Rich Content:
grid.rowAutoSize = {
calculateHeight: (rowData) => {
if (rowData.hasImage) {
return 100; // Taller rows for rows with images
}
return 24; // Default height for other rows
}
};

The Row Auto Size Plugin provides a powerful solution for handling varying content heights in RevoGrid. By automatically adjusting row heights while maintaining performance, it significantly improves the presentation and usability of your grid, especially when dealing with dynamic or rich content.