Row Spanning
Cells in LyteNyte Grid can span multiple rows. A spanning cell extends into the rows below it, and the grid skips rendering any cells it covers.
The rowSpan property on a column definition controls how many rows a cell spans. It accepts either a
number or a function.
- Provide a number to apply the same span to every row.
- Provide a function that returns a number to vary the span per row.
Cells always span downward toward the bottom of the viewport. A spanning cell covers cells in subsequent rows, never previous rows.
Uniform Row Spans
When rowSpan is a number, every cell in the column spans the same number of rows. The grid skips
rendering any cells covered by the span. The demo below shows the Symbol column spanning two rows.
The first row in each span represents the current value, while the second row represents the previous
value.
The meaning of a span depends on your use case, but fixed numeric spans are relatively uncommon and should be used with caution.
Uniform Row Spans
"use client";import { Grid, useClientRowDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { historicalCompareDataset as stockData } from "@1771technologies/grid-sample-data/stock-data-smaller";import { useId } from "react";import {PercentCell,CurrencyCell,SymbolCell,CompactNumberCell,HeaderRenderer,AnalystRatingCell,PriceCell,} from "./components";type StockData = (typeof stockData)[number];const columns: Column<StockData>[] = [{ field: 0, id: "symbol", name: "Symbol", cellRenderer: SymbolCell, width: 220, rowSpan: 2 },{ field: 2, id: "analyst-rating", cellRenderer: AnalystRatingCell, width: 130 },{field: 3,id: "price",name: "USD Price",type: "number",cellRenderer: PriceCell,width: 110,},{ field: 5, id: "change", type: "number", cellRenderer: PercentCell, width: 130 },{ field: 11, id: "eps", name: "EPS", type: "number", cellRenderer: CurrencyCell, width: 130 },{ field: 6, id: "volume", type: "number", cellRenderer: CompactNumberCell, width: 130 },];export default function ColumnFieldNumberIndex() {const ds = useClientRowDataSource({ data: stockData });const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,columnBase: {headerRenderer: HeaderRenderer,widthFlex: 1,},});const view = grid.view.useValue();return (<div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 capitalize"/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainer><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cell key={c.id} cell={c} className="text-xs! flex text-nowrap" />);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type { stockData } from "@1771technologies/grid-sample-data/stock-data-smaller";import { memo, type ReactNode } from "react";import { logos } from "@1771technologies/grid-sample-data/stock-data-smaller";import type {CellRendererParams,HeaderCellRendererParams,} from "@1771technologies/lytenyte-pro/types";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";type StockData = (typeof stockData)[number];export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}function AnalystRatingCellImpl({ grid, row, column }: CellRendererParams<StockData>) {const field = grid.api.columnField(column, row) as string;let Icon: (() => ReactNode) | null = null;const label = field || "-";let clx = "";if (label === "Strong buy") {Icon = StrongBuy;clx = "text-green-500";} else if (label === "Strong Sell") {Icon = StrongSell;clx = "text-red-500";} else if (label === "Neutral") {Icon = Minus;} else if (label === "Buy") {Icon = Buy;clx = "text-green-500";} else if (label === "Sell") {Icon = Sell;clx = "text-red-500";}const aggModel = grid.state.aggModel.useValue();const activeAgg = aggModel[column.id];const isCount = activeAgg?.fn === "count";const isGroupRow = grid.api.rowIsGroup(row);return (<divclassName={tw("grid h-full grid-cols-[16px_1fr] items-center gap-4 text-nowrap px-3",clx,isGroupRow && isCount && "flex justify-end",)}>{Icon && <Icon />}<div>{label}{isCount && <span className="pl-1">Ratings</span>}</div></div>);}function Minus() {return (<svgxmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"strokeWidth={1.5}stroke="currentColor"width={16}><path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" /></svg>);}function Sell() {return (<svgxmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"strokeWidth={1.5}stroke="currentColor"width={16}><path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>);}function Buy() {return (<svgxmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"strokeWidth={1.5}stroke="currentColor"width={16}><path strokeLinecap="round" strokeLinejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" /></svg>);}function StrongSell() {return (<svgxmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"strokeWidth={1.5}stroke="currentColor"width={16}><pathstrokeLinecap="round"strokeLinejoin="round"d="m4.5 5.25 7.5 7.5 7.5-7.5m-15 6 7.5 7.5 7.5-7.5"/></svg>);}function StrongBuy() {return (<svgxmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"strokeWidth={1.5}stroke="currentColor"width={16}><path strokeLinecap="round" strokeLinejoin="round" d="m4.5 18.75 7.5-7.5 7.5 7.5" /><path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 7.5-7.5 7.5 7.5" /></svg>);}export const AnalystRatingCell = memo(AnalystRatingCellImpl);function CompactNumberCellImpl({ row, grid, column }: CellRendererParams<StockData>) {const field = grid.api.columnField(column, row);const isGroup = grid.api.rowIsGroup(row);const aggregations = grid.state.aggModel.useValue();const isCount = aggregations[column.id]?.fn === "count";const [label, suffix] =typeof field === "number"? isGroup && isCount? [Math.round(field), ""]: formatCompactNumber(field): ["-", ""];return (<div className="flex h-full w-full items-center justify-end gap-1 text-nowrap px-3 tabular-nums"><span>{label}</span><span className="font-semibold">{suffix}</span></div>);}export const CompactNumberCell = memo(CompactNumberCellImpl);function formatCompactNumber(n: number) {const suffixes = ["", "K", "M", "B", "T"];let magnitude = 0;let num = Math.abs(n);while (num >= 1000 && magnitude < suffixes.length - 1) {num /= 1000;magnitude++;}const decimals = 2;const formatted = num.toFixed(decimals);return [`${n < 0 ? "-" : ""}${formatted}`, suffixes[magnitude]];}const formatter = new Intl.NumberFormat("en-US", {minimumFractionDigits: 2,maximumFractionDigits: 2,});function CurrencyCellImpl({ grid, row, column }: CellRendererParams<StockData>) {const field = grid.api.columnField(column, row);const label = typeof field === "number" ? formatter.format(field) : "-";const currency = "USD";const aggregations = grid.state.aggModel.useValue();const isCount = aggregations[column.id]?.fn === "count";return (<div className="flex h-full w-full items-center justify-end text-nowrap px-3 tabular-nums"><div className="flex items-baseline gap-1"><span>{label}</span>{!isCount && <span className="text-[9px]">{currency}</span>}</div></div>);}export const CurrencyCell = memo(CurrencyCellImpl);function PriceCellImpl({ grid, row, column }: CellRendererParams<StockData>) {const field = grid.api.columnField(column, row);const label = typeof field === "number" ? formatter.format(field) : "-";const aggregations = grid.state.aggModel.useValue();const isCount = aggregations[column.id]?.fn === "count";return (<div className="flex h-full w-full items-center justify-end gap-1 text-nowrap px-3 tabular-nums"><div className="flex items-baseline gap-1"><span>{label}</span>{!isCount && <span className="text-[9px]">USD</span>}</div></div>);}export const PriceCell = memo(PriceCellImpl);function CurrencyCellGBPImpl({ grid, row, column }: CellRendererParams<StockData>) {const field = grid.api.columnField(column, row);const label = typeof field === "number" ? formatter.format(field) : "-";const currency = "GBP";const aggregations = grid.state.aggModel.useValue();const isCount = aggregations[column.id]?.fn === "count";return (<div className="flex h-full w-full items-center justify-end text-nowrap px-3 tabular-nums"><div className="flex items-baseline gap-1"><span>{label}</span>{!isCount && <span className="text-[9px]">{currency}</span>}</div></div>);}export const CurrencyCellGBP = memo(CurrencyCellGBPImpl);function PercentCellImpl({ grid, row, column }: CellRendererParams<StockData>) {const field = grid.api.columnField(column, row) as number;const isGroup = grid.api.rowIsGroup(row);const aggregations = grid.state.aggModel.useValue();const isCount = aggregations[column.id]?.fn === "count";const label =typeof field === "number"? isGroup && isCount? Math.round(field): formatter.format(field) + "%": "-";return (<divclassName={tw("flex h-full w-full items-center justify-end text-nowrap px-3 tabular-nums")}>{label}</div>);}export const PercentCell = memo(PercentCellImpl);function SymbolCellImpl({ grid, row, column }: CellRendererParams<StockData>) {if (grid.api.rowIsGroup(row)) {const data = row.data[column.id];const agg = grid.state.aggModel.get()[column.id];return (<div className="flex h-full items-center text-nowrap px-3">{data as string} {agg.fn === "count" ? (data === 1 ? "Symbol" : "Symbols") : ""}</div>);}if (!grid.api.rowIsLeaf(row)) return null;const symbol = grid.api.columnField(column, row) as string;const desc = row.data?.[1];return (<div className="grid h-full w-full grid-cols-[32px_1fr] items-center gap-3 overflow-hidden text-nowrap px-3"><div className="flex h-8 min-h-8 w-8 min-w-8 items-center justify-center overflow-hidden rounded-full"><imgsrc={logos[symbol]}alt=""width={26}height={26}className="pointer-events-none h-[26px] min-h-[26px] w-[26px] min-w-[26] rounded-full bg-black p-1"/></div><div className="overflow-hidden text-ellipsis">{desc}</div></div>);}export const SymbolCell = memo(SymbolCellImpl);export function HeaderRenderer({ column, grid }: HeaderCellRendererParams<StockData>) {const name = column.name ?? column.id;const isNumber = column.type === "number";const aggModel = grid.state.aggModel.useValue();const isGrouped = grid.state.rowGroupModel.useValue().length > 0;const agg = (aggModel[column.id]?.fn ?? "") as string;return (<divclassName={clsx("text-ln-gray-80 group relative flex h-full w-full items-center gap-1 overflow-hidden text-nowrap px-2 text-xs",isNumber && "justify-end",)}><span className={tw(isNumber && "order-2")}>{name}</span>{isGrouped && agg && (<div className={tw("text-ln-primary-50 text-xs", isNumber && "order-1")}>({agg})</div>)}</div>);}
Define a two-row span directly on the column definition:
const columns = [// other columns{ id: "symbol", rowSpan: 2 },];
Function Column Spans
Set rowSpan to a function to compute spans dynamically per row. This allows spans based on row
data. The demo below groups cryptocurrencies by exchange, with the Exchange column spanning all
rows that share the same exchange value.
Function Row Spans
"use client";import { Grid, useClientRowDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import {ExchangeCell,makePerfHeaderCell,NetworkCell,PercentCell,PercentCellPositiveNegative,SymbolCell,tw,} from "./components";import type { DEXPerformanceData } from "@1771technologies/grid-sample-data/dex-pairs-performance";import { data as rawData } from "@1771technologies/grid-sample-data/dex-pairs-performance";const exchangeCounts: Record<string, number> = {};const data = Object.values(Object.groupBy(rawData, (x) => {return x.exchange;}),).flatMap((x) => {exchangeCounts[x![0].exchange] = Math.min(x!.length, 5);return x!.slice(0, 5); // Only take the first 5}) as DEXPerformanceData[];const columns: Column<DEXPerformanceData>[] = [{id: "exchange",cellRenderer: ExchangeCell,width: 220,name: "Exchange",rowSpan: (r) => {const exchange = r.row.data?.exchange as string;return exchangeCounts[exchange] ?? 1;},},{ id: "Symbol", cellRenderer: SymbolCell, width: 220, field: "symbol" },{ id: "Network", cellRenderer: NetworkCell, width: 220, field: "network" },{id: "Change % 24h",cellRenderer: PercentCellPositiveNegative,headerRenderer: makePerfHeaderCell("Change", "24h"),type: "number,",field: "change24h",},{field: "perf1w",cellRenderer: PercentCellPositiveNegative,headerRenderer: makePerfHeaderCell("Perf %", "1w"),id: "Perf % 1W",type: "number,",},{field: "perf1m",cellRenderer: PercentCellPositiveNegative,headerRenderer: makePerfHeaderCell("Perf %", "1m"),id: "Perf % 1M",type: "number,",},{field: "perf3m",cellRenderer: PercentCellPositiveNegative,headerRenderer: makePerfHeaderCell("Perf %", "3m"),id: "Perf % 3M",type: "number,",},{field: "perf6m",cellRenderer: PercentCellPositiveNegative,headerRenderer: makePerfHeaderCell("Perf %", "6m"),id: "Perf % 6M",type: "number,",},{field: "perfYtd",cellRenderer: PercentCellPositiveNegative,headerRenderer: makePerfHeaderCell("Perf %", "YTD"),id: "Perf % YTD",type: "number",},{ field: "volatility", cellRenderer: PercentCell, id: "Volatility", type: "number" },{field: "volatility1m",cellRenderer: PercentCell,headerRenderer: makePerfHeaderCell("Volatility", "1m"),id: "Volatility 1M",type: "number",},];export default function RowFullWidth() {const ds = useClientRowDataSource({ data: data });const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,columnBase: { width: 80 },rowScanDistance: 100,});const view = grid.view.useValue();return (<><div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className={tw("text-ln-gray-60! dark:text-ln-gray-70! flex h-full w-full items-center text-nowrap px-2 text-xs capitalize",c.column.type === "number" && "justify-end",)}/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainer><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width")return <Grid.RowFullWidth row={row} className="border-t-none" />;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className="text-xs! flex h-full w-full items-center px-2"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import type {CellRendererParams,HeaderCellRendererParams,} from "@1771technologies/lytenyte-pro/types";import { ToggleGroup as TG } from "radix-ui";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";import type { DEXPerformanceData } from "@1771technologies/grid-sample-data/dex-pairs-performance";import {exchanges,networks,symbols,} from "@1771technologies/grid-sample-data/dex-pairs-performance";export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}export function SymbolCell({ grid: { api }, row }: CellRendererParams<DEXPerformanceData>) {if (!api.rowIsLeaf(row) || !row.data) return null;const ticker = row.data.symbolTicker;const symbol = row.data.symbol;const image = symbols[row.data.symbolTicker];return (<div className="grid grid-cols-[20px_auto_auto] items-center gap-1.5"><div><imgsrc={image}alt={`Logo for symbol ${symbol}`}className="h-full w-full overflow-hidden rounded-full"/></div><div className="bg-ln-gray-20 text-ln-gray-100 flex h-fit items-center justify-center rounded-lg px-2 py-px text-[10px]">{ticker}</div><div className="w-full overflow-hidden text-ellipsis">{symbol.split("/")[0]}</div></div>);}export function NetworkCell({ grid: { api }, row }: CellRendererParams<DEXPerformanceData>) {if (!api.rowIsLeaf(row) || !row.data) return null;const name = row.data.network;const image = networks[name];return (<div className="grid grid-cols-[20px_1fr] items-center gap-1.5"><div><imgsrc={image}alt={`Logo for network ${name}`}className="h-full w-full overflow-hidden rounded-full"/></div><div className="w-full overflow-hidden text-ellipsis">{name}</div></div>);}export function ExchangeCell({ grid: { api }, row }: CellRendererParams<DEXPerformanceData>) {if (!api.rowIsLeaf(row) || !row.data) return null;const name = row.data.exchange;const image = exchanges[name];return (<div className="grid grid-cols-[20px_1fr] items-center gap-1.5"><div><imgsrc={image}alt={`Logo for exchange ${name}`}className="h-full w-full overflow-hidden rounded-full"/></div><div className="w-full overflow-hidden text-ellipsis">{name}</div></div>);}export function PercentCellPositiveNegative({grid: { api },column,row,}: CellRendererParams<DEXPerformanceData>) {if (!api.rowIsLeaf(row) || !row.data) return null;const field = api.columnField(column, row);if (typeof field !== "number") return "-";const value = (field > 0 ? "+" : "") + (field * 100).toFixed(2) + "%";return (<divclassName={tw("h-ful flex w-full items-center justify-end tabular-nums",field < 0 ? "text-red-600 dark:text-red-300" : "text-green-600 dark:text-green-300",)}>{value}</div>);}export function PercentCell({grid: { api },column,row,}: CellRendererParams<DEXPerformanceData>) {if (!api.rowIsLeaf(row) || !row.data) return null;const field = api.columnField(column, row);if (typeof field !== "number") return "-";const value = (field > 0 ? "+" : "") + (field * 100).toFixed(2) + "%";return <div className="h-ful flex w-full items-center justify-end tabular-nums">{value}</div>;}export const makePerfHeaderCell = (name: string, subname: string) => {return (_: HeaderCellRendererParams<DEXPerformanceData>) => {return (<div className="flex h-full w-full flex-col items-end justify-center"><div>{name}</div><div className="font-mono uppercase">{subname}</div></div>);};};export function ToggleGroup(props: Parameters<typeof TG.Root>[0]) {return (<TG.Root{...props}className={tw("bg-ln-gray-20 flex items-center gap-2 rounded-xl px-2 py-1", props.className)}></TG.Root>);}export function ToggleItem(props: Parameters<typeof TG.Item>[0]) {return (<TG.Item{...props}className={tw("text-ln-gray-70 flex items-center justify-center px-2 py-1 text-xs font-bold outline-none focus:outline-none","data-[state=on]:text-ln-gray-90 data-[state=on]:bg-linear-to-b from-ln-gray-02 to-ln-gray-05 data-[state=on]:rounded-md",props.className,)}></TG.Item>);}
The span for each exchange is computed ahead of time. The column then uses a span function to return the correct value, as shown below:
const columns: Column<DEXPerformanceData>[] = [// Other columns{id: "exchange",cellRenderer: ExchangeCell,name: "Exchange",rowSpan: (r) => {const exchange = r.row.data?.exchange as string;// Lookup the precomputed spanreturn exchangeCounts[exchange] ?? 1;},},];
Row Scan Distance
For performance, LyteNyte Grid does not precompute spans for all rows. Instead, it scans backward from
the first visible row based on the current scroll position. Control this lookback using the
rowScanDistance property:
const grid = Grid.useLyteNyte({rowScanDistance: 100,});
The rowScanDistance property must be at least the maximum possible span in the grid. Ensure that no span exceeds
this value to guarantee correct rendering.
LyteNyte Grid uses a scan-based approach to balance correctness and performance. Many grids either precompute spans, which limits scalability, or render spans incorrectly during scrolling. LyteNyte Grid requires you to specify a maximum span, but it maintains both accuracy and performance.
In most real-world scenarios, a scan distance of 100 is more than sufficient, since spans rarely reach this size.
Next Steps
- Row Height: Change row height, including variable-height and fill-height rows.
- Row Pinning: Freeze rows at the top or bottom of the viewport.
- Row Sorting: Learn how to sort and order rows.
- Row Selection: Select single or multiple rows.