Appearance
Live stock prices
A simulated price feed updates the grid up to 50 times per second.
How it works
- Immutable updates: every tick creates a new array in which the changed stock is replaced by a new object. With
rowId: (stock) => stock.symbolthe grid reuses the existing rows and only the changed cells render again. Sorting is kept, so a sorted column reorders live. - Cell flashing: columns with
flashOnChange: truehighlight changed values — green for increasing and red for decreasing numbers. The colors are the CSS variables--ut-flash-up-colorand--ut-flash-down-color. - Sparklines: the
historycolumn uses a tiny function component which renders an inline SVG. - Aggregations: the total row shows the total volume and the average change, group by sector (column menu) to see it per sector.
- Fixed row height:
rowHeight: 34avoids measuring rows, which is recommended for frequently updated grids.
vue
<template>
<div class="demo">
<div class="demo-toolbar">
<button @click="running = !running">{{ running ? "⏸ Pause" : "▶ Resume" }}</button>
<label>
Updates / second
<select v-model.number="rate">
<option :value="2">2</option>
<option :value="10">10</option>
<option :value="50">50</option>
</select>
</label>
<span>{{ updates.toLocaleString() }} updates</span>
</div>
<Datagrid :columns="columns" :rows="stocks" :options="options" />
</div>
</template>
<script setup lang="ts">
import { h, onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
type Stock = {
symbol: string;
name: string;
sector: string;
price: number;
previous: number;
change: number;
volume: number;
history: Array<number>;
};
const companies: Array<[string, string, string]> = [
["AAPL", "Apple", "Technology"], ["MSFT", "Microsoft", "Technology"], ["NVDA", "Nvidia", "Technology"],
["AMZN", "Amazon", "Consumer"], ["TSLA", "Tesla", "Consumer"], ["NKE", "Nike", "Consumer"],
["JPM", "JPMorgan", "Finance"], ["GS", "Goldman Sachs", "Finance"], ["V", "Visa", "Finance"],
["XOM", "Exxon", "Energy"], ["CVX", "Chevron", "Energy"], ["SHEL", "Shell", "Energy"],
["PFE", "Pfizer", "Health"], ["JNJ", "Johnson & Johnson", "Health"], ["MRK", "Merck", "Health"],
["SAP", "SAP", "Technology"], ["SIE", "Siemens", "Industrial"], ["BA", "Boeing", "Industrial"],
["CAT", "Caterpillar", "Industrial"], ["DHL", "DHL Group", "Industrial"],
];
const stocks = shallowRef<Array<Stock>>(
companies.map(([symbol, name, sector], i) => {
const price = 50 + ((i * 37) % 400);
return { symbol, name, sector, price, previous: price, change: 0, volume: 0, history: Array(30).fill(price) };
})
);
const running = ref(true);
const rate = ref(10);
const updates = ref(0);
// simulates a price feed: every tick one stock gets a new price
function tick() {
const index = Math.floor(Math.random() * stocks.value.length);
const next = stocks.value.map((stock, i) => {
if (i !== index) return stock;
const price = Math.max(1, stock.price * (1 + (Math.random() - 0.5) * 0.02));
return {
...stock,
previous: stock.price,
price,
change: ((price - stock.history[0]) / stock.history[0]) * 100,
volume: stock.volume + Math.round(Math.random() * 10000),
history: [...stock.history.slice(1), price],
};
});
// a new array with new objects for changed rows, rows are matched by rowId
stocks.value = next;
updates.value++;
}
let timer: ReturnType<typeof setInterval> | undefined;
watch(
[running, rate],
() => {
clearInterval(timer);
if (running.value) timer = setInterval(tick, 1000 / rate.value);
},
{ immediate: true }
);
onBeforeUnmount(() => clearInterval(timer));
// tiny inline svg sparkline
function sparkline(values: Array<number>) {
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${20 - ((v - min) / range) * 18 - 1}`).join(" ");
return `<svg viewBox="0 0 100 20" preserveAspectRatio="none" width="100%" height="20"><polyline fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" points="${points}"/></svg>`;
}
const SparklineCell = (props: { modelValue: Array<number>; item: { data: Stock } }) => {
const up = props.item.data.change >= 0;
return h("span", { class: ["sparkline", up ? "up" : "down"], innerHTML: sparkline(props.modelValue) });
};
const columns: Array<ColumnConfig<Stock>> = [
{ field: "symbol", text: "Symbol", width: 90, pinned: "left", cellClass: "symbol" },
{ field: "name", text: "Company", width: 170 },
{ field: "sector", text: "Sector", width: 120, groupable: true, filterType: "set" },
{
field: "price",
text: "Price",
type: "number",
width: 110,
valueFormatter: formatters.currency("USD"),
// green / red highlight when the price changes
flashOnChange: true,
},
{
field: "change",
text: "Change",
type: "number",
width: 100,
aggFunc: "avg",
valueFormatter: ({ value }) => (value == null ? "" : `${value >= 0 ? "▲" : "▼"} ${Math.abs(value).toFixed(2)} %`),
cellClass: ({ value }) => (value > 0 ? "positive" : value < 0 ? "negative" : undefined),
flashOnChange: true,
},
{ field: "volume", text: "Volume", type: "number", width: 120, aggFunc: "sum", valueFormatter: formatters.number(), flashOnChange: true },
{ field: "history", text: "Trend", width: 160, component: SparklineCell, sortable: false, filterable: false },
];
const options: GridOptions<Stock> = {
// rows are identified by the symbol, so the grid can update them in place
rowId: (stock) => stock.symbol,
rowHeight: 34,
totalRow: "bottom",
columnMenu: true,
defaultColumn: { sortable: true, filterable: true, resizeable: true },
};
</script>
<style>
.symbol {
font-weight: 700;
font-family: var(--vp-font-family-mono, monospace);
}
.positive {
color: #1a7f37;
}
.negative {
color: #cf222e;
}
.sparkline {
display: block;
width: 100%;
}
.sparkline.up {
color: #1a7f37;
}
.sparkline.down {
color: #cf222e;
}
</style>tsx
import { useEffect, useState } from "react";
import {
Datagrid,
formatters,
type ColumnConfig,
type DatagridProps,
type GridOptions,
} from "@datagrid/react-ui";
type Stock = {
symbol: string;
name: string;
sector: string;
price: number;
previous: number;
change: number;
volume: number;
history: Array<number>;
};
const companies: Array<[string, string, string]> = [
["AAPL", "Apple", "Technology"], ["MSFT", "Microsoft", "Technology"], ["NVDA", "Nvidia", "Technology"],
["AMZN", "Amazon", "Consumer"], ["TSLA", "Tesla", "Consumer"], ["NKE", "Nike", "Consumer"],
["JPM", "JPMorgan", "Finance"], ["GS", "Goldman Sachs", "Finance"], ["V", "Visa", "Finance"],
["XOM", "Exxon", "Energy"], ["CVX", "Chevron", "Energy"], ["SHEL", "Shell", "Energy"],
["PFE", "Pfizer", "Health"], ["JNJ", "Johnson & Johnson", "Health"], ["MRK", "Merck", "Health"],
["SAP", "SAP", "Technology"], ["SIE", "Siemens", "Industrial"], ["BA", "Boeing", "Industrial"],
["CAT", "Caterpillar", "Industrial"], ["DHL", "DHL Group", "Industrial"],
];
const initialStocks: Array<Stock> = companies.map(([symbol, name, sector], i) => {
const price = 50 + ((i * 37) % 400);
return { symbol, name, sector, price, previous: price, change: 0, volume: 0, history: Array(30).fill(price) };
});
// simulates a price feed: every tick one stock gets a new price
function tick(stocks: Array<Stock>): Array<Stock> {
const index = Math.floor(Math.random() * stocks.length);
// a new array with new objects for changed rows, rows are matched by rowId
return stocks.map((stock, i) => {
if (i !== index) return stock;
const price = Math.max(1, stock.price * (1 + (Math.random() - 0.5) * 0.02));
return {
...stock,
previous: stock.price,
price,
change: ((price - stock.history[0]) / stock.history[0]) * 100,
volume: stock.volume + Math.round(Math.random() * 10000),
history: [...stock.history.slice(1), price],
};
});
}
// tiny inline svg sparkline
function Sparkline({ values, up }: { values: Array<number>; up: boolean }) {
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${20 - ((v - min) / range) * 18 - 1}`).join(" ");
return (
<span className={`sparkline ${up ? "up" : "down"}`}>
<svg viewBox="0 0 100 20" preserveAspectRatio="none" width="100%" height="20">
<polyline fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" points={points} />
</svg>
</span>
);
}
const cellRenderers: DatagridProps<Stock>["cellRenderers"] = {
history: ({ value, data }) => <Sparkline values={value} up={data.change >= 0} />,
};
const columns: Array<ColumnConfig<Stock>> = [
{ field: "symbol", text: "Symbol", width: 90, pinned: "left", cellClass: "symbol" },
{ field: "name", text: "Company", width: 170 },
{ field: "sector", text: "Sector", width: 120, groupable: true, filterType: "set" },
{
field: "price",
text: "Price",
type: "number",
width: 110,
valueFormatter: formatters.currency("USD"),
// green / red highlight when the price changes
flashOnChange: true,
},
{
field: "change",
text: "Change",
type: "number",
width: 100,
aggFunc: "avg",
valueFormatter: ({ value }) => (value == null ? "" : `${value >= 0 ? "▲" : "▼"} ${Math.abs(value).toFixed(2)} %`),
cellClass: ({ value }) => (value > 0 ? "positive" : value < 0 ? "negative" : undefined),
flashOnChange: true,
},
{ field: "volume", text: "Volume", type: "number", width: 120, aggFunc: "sum", valueFormatter: formatters.number(), flashOnChange: true },
// rendered by cellRenderers.history
{ field: "history", text: "Trend", width: 160, sortable: false, filterable: false },
];
const options: GridOptions<Stock> = {
// rows are identified by the symbol, so the grid can update them in place
rowId: (stock) => stock.symbol,
rowHeight: 34,
totalRow: "bottom",
columnMenu: true,
defaultColumn: { sortable: true, filterable: true, resizeable: true },
};
export default function ShowcaseStocks() {
const [stocks, setStocks] = useState(initialStocks);
const [running, setRunning] = useState(true);
const [rate, setRate] = useState(10);
const [updates, setUpdates] = useState(0);
// restart the timer when running or rate change, stop it on unmount
useEffect(() => {
if (!running) return;
const timer = setInterval(() => {
setStocks(tick);
setUpdates((count) => count + 1);
}, 1000 / rate);
return () => clearInterval(timer);
}, [running, rate]);
return (
<div className="demo">
<div className="demo-toolbar">
<button onClick={() => setRunning(!running)}>{running ? "⏸ Pause" : "▶ Resume"}</button>
<label>
Updates / second{" "}
<select value={rate} onChange={(e) => setRate(Number(e.target.value))}>
<option value={2}>2</option>
<option value={10}>10</option>
<option value={50}>50</option>
</select>
</label>
<span>{updates.toLocaleString()} updates</span>
</div>
<Datagrid columns={columns} rows={stocks} options={options} cellRenderers={cellRenderers} />
<style>{`
.symbol {
font-weight: 700;
font-family: var(--vp-font-family-mono, monospace);
}
.positive {
color: #1a7f37;
}
.negative {
color: #cf222e;
}
.sparkline {
display: block;
width: 100%;
}
.sparkline.up {
color: #1a7f37;
}
.sparkline.down {
color: #cf222e;
}
`}</style>
</div>
);
}