Grid Animations
Animate row and column insertions, deletions, and repositioning within the grid.
Animations Overview
LyteNyte Grid can animate rows and columns when they are added, removed, or reordered. Animations use the Web Animations API and run outside the React render cycle, so they do not affect grid rendering performance.
Animations have three separate parts:
- Enter: Runs when a row or column first appears in the grid.
- Exit: Runs when a row or column leaves the grid.
- Move: Runs when a row or column changes position.
Enable animations by setting rowAnimate or columnAnimate to
one of the following values:
true: Use the default animation settings.- Animation Configuration: Customize the default behavior and enable or disable specific animation parts.
The demo below illustrates basic animations using the default configuration settings.
Animation Basics
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";4import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";5import {6 AgeGroup,7 CostCell,8 CountryCell,9 DateCell,10 GenderCell,11 NumberCell,12 ProfitCell,13} from "./components.jsx";14import { useMemo, useState } from "react";15
16export interface GridSpec {17 readonly data: SaleDataItem;18}19
20const initialColumns: Grid.Column<GridSpec>[] = [21 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },22 { id: "age", name: "Age", type: "number", width: 80 },23 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 160 },24 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 100 },25 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150 },26 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },27 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },28 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },29 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },30 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },31 { id: "state", name: "State", width: 150 },32 { id: "product", name: "Product", width: 160 },33 { id: "productCategory", name: "Category", width: 120 },34 { id: "subCategory", name: "Sub-Category", width: 160 },35];36
37const base: Grid.ColumnBase<GridSpec> = { width: 120 };38
39// Stable IDs keyed by object identity so row move animations track correctly across shuffles.40const rowIdMap = new Map(salesData.map((item, i) => [item, String(i)]));41const leafIdFn = (d: SaleDataItem) => rowIdMap.get(d) ?? "unknown";42
43function shuffle<T>(arr: T[]): T[] {44 const a = [...arr];45 for (let i = a.length - 1; i > 0; i--) {46 const j = Math.floor(Math.random() * (i + 1));47 [a[i], a[j]] = [a[j], a[i]];48 }49 return a;50}51
52export default function GridDemo() {53 const [rowData, setRowData] = useState([...salesData]);54 const [hideAdults, setHideAdults] = useState(false);55 const [columns, setColumns] = useState(initialColumns);56 const [hideCountry, setHideCountry] = useState(false);57
58 const displayData = useMemo(59 () => (hideAdults ? rowData.filter((r) => r.ageGroup !== "Adults (35-64)") : rowData),60 [rowData, hideAdults],61 );62
63 const displayColumns = useMemo(64 () => columns.map((c) => (c.id === "country" ? { ...c, hide: hideCountry } : c)),65 [columns, hideCountry],66 );67
68 const ds = useClientDataSource<GridSpec>({ data: displayData, leafIdFn });69
70 return (71 <>72 <div className="border-ln-border flex flex-wrap gap-4 text-nowrap border-b px-2 py-2">73 <button data-ln-button="website" data-ln-size="mx" onClick={() => setRowData((d) => shuffle(d))}>74 Shuffle Rows75 </button>76 <button data-ln-button="website" data-ln-size="mx" onClick={() => setColumns((c) => shuffle(c))}>77 Shuffle Columns78 </button>79 <button data-ln-button="website" data-ln-size="mx" onClick={() => setHideAdults((h) => !h)}>80 {hideAdults ? "Show: Adults (35-64)" : "Hide: Adults (35-64)"}81 </button>82 <button data-ln-button="website" data-ln-size="mx" onClick={() => setHideCountry((h) => !h)}>83 {hideCountry ? "Show: Country" : "Hide: Country"}84 </button>85 </div>86 <div className="ln-grid" style={{ height: 500 }}>87 <Grid columns={displayColumns} columnBase={base} rowSource={ds} rowAnimate columnAnimate />88 </div>89 </>90 );91}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7
8export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {9 const field = api.columnField(column, row);10
11 if (typeof field !== "string") return "-";12
13 const dateField = parse(field as string, "MM/dd/yyyy", new Date());14
15 if (!isValid(dateField)) return "-";16
17 const niceDate = format(dateField, "yyyy MMM dd");18 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;19}20
21export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {22 const field = api.columnField(column, row);23
24 if (field === "Youth (<25)") return <div className="text-[#944cec] dark:text-[#B181EB]">{field}</div>;25 if (field === "Young Adults (25-34)")26 return <div className="text-[#aa6c1a] dark:text-[#E5B474]">{field}</div>;27 if (field === "Adults (35-64)") return <div className="text-[#0f7d4c] dark:text-[#52B086]">{field}</div>;28
29 return "-";30}31
32export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {33 const field = api.columnField(column, row);34
35 if (field === "Male")36 return (37 <div className="flex h-full w-full items-center gap-2">38 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">39 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">40 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>41 </svg>42 </div>43 Male44 </div>45 );46
47 if (field === "Female")48 return (49 <div className="flex h-full w-full items-center gap-2">50 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">51 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">52 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>53 </svg>54 </div>55 Female56 </div>57 );58
59 return "-";60}61
62function tw(...c: ClassValue[]) {63 return twMerge(clsx(...c));64}65
66const formatter = new Intl.NumberFormat("en-US", {67 maximumFractionDigits: 2,68 minimumFractionDigits: 0,69});70export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {71 const field = api.columnField(column, row);72
73 if (typeof field !== "number") return "-";74
75 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);76
77 return (78 <div79 className={tw(80 "flex h-full w-full items-center justify-end tabular-nums",81 field < 0 && "text-red-600 dark:text-red-300",82 field > 0 && "text-green-600 dark:text-green-300",83 )}84 >85 {formatted}86 </div>87 );88}89
90export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {91 const field = api.columnField(column, row);92
93 if (typeof field !== "number") return "-";94
95 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);96
97 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;98}99
100export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {101 const field = api.columnField(column, row);102
103 if (typeof field !== "number") return "-";104
105 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);106
107 return (108 <div109 className={tw(110 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",111 )}112 >113 {formatted}114 </div>115 );116}117
118export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {119 const field = api.columnField(column, row);120
121 const flag = countryFlags[field as keyof typeof countryFlags];122 if (!flag) return "-";123
124 return (125 <div className="flex h-full w-full items-center gap-2">126 <img className="size-4" src={flag} alt={`country flag of ${field}`} />127 <span>{String(field ?? "-")}</span>128 </div>129 );130}Custom Animations
Passing true enables the enter, exit, and move phases with the default fade animation and
duration. For more control, pass an animation config object to customize or disable each phase independently.
1const rowAnimate = {2 move: {3 duration: 500,4 easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", // spring overshoot5 },6 enter: {7 duration: 300,8 easing: "ease-out",9 type: () => [10 { opacity: 0, transform: "translateY(20px)" },11 { opacity: 1, transform: "translateY(0)" },12 ],13 },14 exit: {15 duration: 200,16 easing: "ease-in",17 type: () => [18 { opacity: 1, transform: "translateY(0)" },19 { opacity: 0, transform: "translateY(-20px)" },20 ],21 },22};23
24const columnAnimate = {25 move: {26 duration: 500,27 easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",28 },29 enter: {30 duration: 250,31 easing: "ease-out",32 type: () => [33 { opacity: 0, transform: "translateX(-12px)" },34 { opacity: 1, transform: "translateX(0)" },35 ],36 },37 exit: {38 duration: 200,39 easing: "ease-in",40 type: () => [41 { opacity: 1, transform: "translateX(0)" },42 { opacity: 0, transform: "translateX(12px)" },43 ],44 },45};46
47<Grid rowAnimate={rowAnimate} columnAnimate={columnAnimate} />;The type field on enter and exit accepts a function that receives
the animated element and returns a Keyframe[] in the Web Animations API
format. The move phase always uses a translate transform and does not accept a type property.
The demo below uses a custom animation configuration. The enter and exit phases use
custom keyframes to create a spring effect, while the move phase continues to use
the built-in translate transform.
Custom Animations
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";4import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";5import {6 AgeGroup,7 CostCell,8 CountryCell,9 DateCell,10 GenderCell,11 NumberCell,12 ProfitCell,13} from "./components.jsx";14import { useMemo, useState } from "react";15
16export interface GridSpec {17 readonly data: SaleDataItem;18}19
20const initialColumns: Grid.Column<GridSpec>[] = [21 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },22 { id: "age", name: "Age", type: "number", width: 80 },23 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 160 },24 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 100 },25 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150 },26 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },27 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },28 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },29 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },30 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },31 { id: "state", name: "State", width: 150 },32 { id: "product", name: "Product", width: 160 },33 { id: "productCategory", name: "Category", width: 120 },34 { id: "subCategory", name: "Sub-Category", width: 160 },35];36
37const base: Grid.ColumnBase<GridSpec> = { width: 120 };38
39// Stable IDs keyed by object identity so row move animations track correctly across shuffles.40const rowIdMap = new Map(salesData.map((item, i) => [item, String(i)]));41const leafIdFn = (d: SaleDataItem) => rowIdMap.get(d) ?? "unknown";42
43const rowAnimate = {44 move: {45 duration: 500,46 // Spring-like overshoot so rows settle with a slight bounce.47 easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",48 },49 enter: {50 duration: 300,51 easing: "ease-out",52 type: () => [53 { opacity: 0, transform: "translateY(20px)" },54 { opacity: 1, transform: "translateY(0)" },55 ],56 },57 exit: {58 duration: 200,59 easing: "ease-in",60 type: () => [61 { opacity: 1, transform: "translateY(0)" },62 { opacity: 0, transform: "translateY(-20px)" },63 ],64 },65};66
67const columnAnimate = {68 move: {69 duration: 500,70 easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",71 },72 enter: {73 duration: 250,74 easing: "ease-out",75 type: () => [76 { opacity: 0, transform: "translateX(-12px)" },77 { opacity: 1, transform: "translateX(0)" },78 ],79 },80 exit: {81 duration: 200,82 easing: "ease-in",83 type: () => [84 { opacity: 1, transform: "translateX(0)" },85 { opacity: 0, transform: "translateX(12px)" },86 ],87 },88};89
90function shuffle<T>(arr: T[]): T[] {91 const a = [...arr];92 for (let i = a.length - 1; i > 0; i--) {93 const j = Math.floor(Math.random() * (i + 1));94 [a[i], a[j]] = [a[j], a[i]];95 }96 return a;97}98
99export default function GridDemo() {100 const [rowData, setRowData] = useState([...salesData]);101 const [hideAdults, setHideAdults] = useState(false);102 const [columns, setColumns] = useState(initialColumns);103 const [hideCountry, setHideCountry] = useState(false);104
105 const displayData = useMemo(106 () => (hideAdults ? rowData.filter((r) => r.ageGroup !== "Adults (35-64)") : rowData),107 [rowData, hideAdults],108 );109
110 const displayColumns = useMemo(111 () => columns.map((c) => (c.id === "country" ? { ...c, hide: hideCountry } : c)),112 [columns, hideCountry],113 );114
115 const ds = useClientDataSource<GridSpec>({ data: displayData, leafIdFn });116
117 return (118 <>119 <div className="border-ln-border flex flex-wrap gap-4 text-nowrap border-b px-2 py-2">120 <button data-ln-button="website" data-ln-size="mx" onClick={() => setRowData((d) => shuffle(d))}>121 Shuffle Rows122 </button>123 <button data-ln-button="website" data-ln-size="mx" onClick={() => setColumns((c) => shuffle(c))}>124 Shuffle Columns125 </button>126 <button data-ln-button="website" data-ln-size="mx" onClick={() => setHideAdults((h) => !h)}>127 {hideAdults ? "Show: Adults (35-64)" : "Hide: Adults (35-64)"}128 </button>129 <button data-ln-button="website" data-ln-size="mx" onClick={() => setHideCountry((h) => !h)}>130 {hideCountry ? "Show: Country" : "Hide: Country"}131 </button>132 </div>133 <div className="ln-grid" style={{ height: 500 }}>134 <Grid135 columns={displayColumns}136 columnBase={base}137 rowSource={ds}138 rowAnimate={rowAnimate}139 columnAnimate={columnAnimate}140 />141 </div>142 </>143 );144}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7
8export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {9 const field = api.columnField(column, row);10
11 if (typeof field !== "string") return "-";12
13 const dateField = parse(field as string, "MM/dd/yyyy", new Date());14
15 if (!isValid(dateField)) return "-";16
17 const niceDate = format(dateField, "yyyy MMM dd");18 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;19}20
21export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {22 const field = api.columnField(column, row);23
24 if (field === "Youth (<25)") return <div className="text-[#944cec] dark:text-[#B181EB]">{field}</div>;25 if (field === "Young Adults (25-34)")26 return <div className="text-[#aa6c1a] dark:text-[#E5B474]">{field}</div>;27 if (field === "Adults (35-64)") return <div className="text-[#0f7d4c] dark:text-[#52B086]">{field}</div>;28
29 return "-";30}31
32export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {33 const field = api.columnField(column, row);34
35 if (field === "Male")36 return (37 <div className="flex h-full w-full items-center gap-2">38 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">39 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">40 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>41 </svg>42 </div>43 Male44 </div>45 );46
47 if (field === "Female")48 return (49 <div className="flex h-full w-full items-center gap-2">50 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">51 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">52 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>53 </svg>54 </div>55 Female56 </div>57 );58
59 return "-";60}61
62function tw(...c: ClassValue[]) {63 return twMerge(clsx(...c));64}65
66const formatter = new Intl.NumberFormat("en-US", {67 maximumFractionDigits: 2,68 minimumFractionDigits: 0,69});70export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {71 const field = api.columnField(column, row);72
73 if (typeof field !== "number") return "-";74
75 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);76
77 return (78 <div79 className={tw(80 "flex h-full w-full items-center justify-end tabular-nums",81 field < 0 && "text-red-600 dark:text-red-300",82 field > 0 && "text-green-600 dark:text-green-300",83 )}84 >85 {formatted}86 </div>87 );88}89
90export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {91 const field = api.columnField(column, row);92
93 if (typeof field !== "number") return "-";94
95 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);96
97 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;98}99
100export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {101 const field = api.columnField(column, row);102
103 if (typeof field !== "number") return "-";104
105 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);106
107 return (108 <div109 className={tw(110 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",111 )}112 >113 {formatted}114 </div>115 );116}117
118export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {119 const field = api.columnField(column, row);120
121 const flag = countryFlags[field as keyof typeof countryFlags];122 if (!flag) return "-";123
124 return (125 <div className="flex h-full w-full items-center gap-2">126 <img className="size-4" src={flag} alt={`country flag of ${field}`} />127 <span>{String(field ?? "-")}</span>128 </div>129 );130}Next Steps
- Grid Annotations: Use grid annotations to draw attention to specific areas of the grid.
- Responsive Container: Learn how to configure containers for LyteNyte Grid.
- Grid Reactivity: Learn how LyteNyte Grid enables declarative reactivity.
