Pivot - Analysis workspace
Excel-style pivot analysis: left-rail field picker (search + checkboxes) feeding four wells (Rows / Columns / Data / Filters), live re-pivot on every layout change, click-to-cycle aggregator chips, data-bar Total Spend cells + amber Avg Rating strips, subtotal + grand-total row tints. (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example from the SvGrid gallery (Pivot Grid). See the SvGrid documentation for the full API.
About this example
An Excel-style pivot analysis workspace for loyalty and membership data built on SvPivotDesigner in about 80 lines of glue. The component supplies the left-rail field picker with search and checkboxes, the Rows, Columns, Data and Filters wells, drag and drop, a per-chip aggregator menu, presets and totals toggles, and re-pivots live on every layout change; the seed is six cities by three tiers by eight hourly buckets with spend and rating measures plus extra dimensions to pull in, and value cells get data bars for spend and amber strips for average rating.
A loyalty / membership analytics workspace built on <SvPivotDesigner> (see demo 168 for the canonical sales example). The component handles the whole authoring UX - field rail, four drop wells, drag-and-drop, chip menus, presets, totals toggles, and the inline pivot grid - so a real workspace is now ~80 lines of glue.
The seed dataset is a loyalty shape: 6 cities × 3 membership tiers × 8 hourly time buckets, with spend + rating measures and a few extra dimensions (gender, age band, discount) the user can pull into the layout. ~1800 rows pivots live without virtualisation.
Imports, features and API used
Imports: @svgrid/enterprise
Frequently asked questions
How do I change the aggregator for a measure?
Click the measure chip in the Data well to open its menu and pick sum, average, count, min or max; the same field can sit in the well twice with different aggregators, and the pivot recomputes on each change.
Does the pivot virtualize?
About 1,800 facts pivot into a few hundred rows here, which renders without virtualization; for much larger results the inline grid's row virtualization is available through the designer's grid props.
Can I add my own dimensions?
Yes. Any field in the rows can be listed as a dimension or a measure; gender, age band and discount are included here so they can be dragged into the layout.
Related documentation
Related articles
- What's New in SvGrid Enterprise - Export, Pivot, Import, Print, and AI - A practical look at @svgrid/enterprise - pivot tables, Excel and PDF export, data import, printing, and an AI assistant. Covers when each feature earns its place and what to watch out for in production.
- Pivot Tables in Svelte - Summarize Data Without a Spreadsheet - Run a cross-tab pivot directly inside your Svelte app using @svgrid/enterprise createPivotModel - no Excel, no server-side aggregation, no stale exports.
Source code (166-pivot-analysis-workspace.svelte)
<script lang="ts">
/**
* 166. Pivot - Analysis workspace (Enterprise)
* --------------------------------------------
* A loyalty / membership analytics workspace built on `<SvPivotDesigner>`
* (see demo 168 for the canonical sales example). The component handles
* the whole authoring UX - field rail, four drop wells, drag-and-drop,
* chip menus, presets, totals toggles, and the inline pivot grid - so
* a real workspace is now ~80 lines of glue.
*
* The seed dataset is a loyalty shape: 6 cities × 3 membership tiers ×
* 8 hourly time buckets, with spend + rating measures and a few extra
* dimensions (gender, age band, discount) the user can pull into the
* layout. ~1800 rows pivots live without virtualisation.
*/
import {
SvPivotDesigner,
setLicenseKey,
type PivotField,
type PivotLayout,
type PivotPreset,
} from '@svgrid/enterprise'
setLicenseKey('SVENTERPRISE-DEV-DEMO')
// ---- Domain --------------------------------------------------------
type City = 'Chicago' | 'Houston' | 'Los Angeles' | 'Miami' | 'New York' | 'San Francisco'
type Membership = 'Bronze' | 'Silver' | 'Gold'
type Gender = 'M' | 'F' | 'X'
type AgeBand = '18-24' | '25-34' | '35-44' | '45-54' | '55+'
type Fact = {
age: AgeBand
time: string
city: City
gender: Gender
membership: Membership
spend: number
spendChange: number
rating: number
discount: 'Yes' | 'No'
}
const CITIES: readonly City[] = ['Chicago', 'Houston', 'Los Angeles', 'Miami', 'New York', 'San Francisco']
const MEMBERSHIPS: readonly Membership[] = ['Bronze', 'Silver', 'Gold']
const GENDERS: readonly Gender[] = ['M', 'F', 'X']
const AGES: readonly AgeBand[] = ['18-24', '25-34', '35-44', '45-54', '55+']
const TIMES: readonly string[] = Array.from({ length: 8 }, (_, i) => String(i).padStart(2, '0') + ':00')
const CITY_BASE: Record<City, number> = {
Chicago: 320, Houston: 240, 'Los Angeles': 480,
Miami: 220, 'New York': 580, 'San Francisco': 520,
}
const TIER_MULT: Record<Membership, number> = { Bronze: 1, Silver: 1.6, Gold: 2.4 }
// Deterministic PRNG so the same dataset renders across reloads.
let prng = 0x12345678
function rnd(): number { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }
function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }
function seedFacts(): Fact[] {
const out: Fact[] = []
for (let i = 0; i < 1800; i += 1) {
const city = pick(CITIES)
const membership = pick(MEMBERSHIPS)
const baseline = CITY_BASE[city] * TIER_MULT[membership]
out.push({
age: pick(AGES),
time: pick(TIMES),
city, gender: pick(GENDERS), membership,
spend: Math.round(baseline * (0.55 + rnd() * 1.4)),
spendChange: Math.round((rnd() * 60 - 18) * 10) / 10,
rating: Math.round((2.6 + rnd() * 2.4) * 100) / 100,
discount: rnd() < 0.32 ? 'Yes' : 'No',
})
}
return out
}
const facts: Fact[] = seedFacts()
// ---- Fields offered to the designer's left rail --------------------
const fields: PivotField<Fact>[] = [
{ field: 'city', label: 'City', kind: 'dimension', group: 'Customer' },
{ field: 'membership', label: 'Membership', kind: 'dimension', group: 'Customer' },
{ field: 'age', label: 'Age band', kind: 'dimension', group: 'Customer' },
{ field: 'gender', label: 'Gender', kind: 'dimension', group: 'Customer' },
{ field: 'time', label: 'Time bucket', kind: 'dimension', group: 'Visit' },
{ field: 'discount', label: 'Discount?', kind: 'dimension', group: 'Visit' },
{ field: 'spend', label: 'Total spend', kind: 'measure', defaultAgg: 'sum',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
{ field: 'rating', label: 'Avg rating', kind: 'measure', defaultAgg: 'avg',
format: { type: 'number', options: { maximumFractionDigits: 2 } } },
{ field: 'spendChange', label: 'Spend Δ%', kind: 'measure', defaultAgg: 'avg',
format: { type: 'number', options: { maximumFractionDigits: 1 } } },
]
// Saved layouts shown in the designer's toolbar Presets menu.
const presets: PivotPreset[] = [
{
name: 'Spend by city × hour',
layout: {
rows: ['city'], cols: ['time'],
values: [{ field: 'spend', agg: 'sum', label: 'Spend',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } }],
filters: [],
},
},
{
name: 'Tier scorecard',
layout: {
rows: ['membership', 'city'], cols: [],
values: [
{ field: 'spend', agg: 'sum',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
{ field: 'rating', agg: 'avg', format: { type: 'number', options: { maximumFractionDigits: 2 } } },
{ field: 'spend', agg: 'avg', label: 'Avg ticket',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
],
filters: [],
},
},
{
name: 'Demographics deep-dive',
layout: {
rows: ['age', 'gender'], cols: ['membership'],
values: [{ field: 'spend', agg: 'avg', label: 'Avg spend',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } }],
filters: [{ field: 'discount', allowed: ['Yes'] }],
},
},
]
// Bindable layout - the designer reads + writes this single shape.
let layout = $state<PivotLayout>(presets[0]!.layout)
function onExport(layout: PivotLayout, rows: unknown[]) {
console.log('Pivot export:', { layout, rowCount: rows.length })
alert(`Would export ${rows.length} pivot rows. See console for layout payload.`)
}
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-border); background: var(--sg-header-bg);">
<p class="text-sm font-semibold" style="color: var(--sg-fg);">
Loyalty analytics workspace - built on <SvPivotDesigner>
</p>
<p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
1,800 simulated visits across 6 cities × 3 membership tiers × 8 time buckets. Drag any
field into the wells, change measures' aggregators (sum / avg / min / max / …), or pick a
preset to jump to a curated view. Filters narrow the source rows before the pivot.
</p>
</div>
<div class="flex-1 min-h-0">
<SvPivotDesigner
data={facts}
{fields}
bind:layout
{presets}
{onExport}
/>
</div>
</section>More Pivot Grid examples
- Pivot mode grid - Built on <SvPivotDesigner panelPosition="right">: a docked tool panel with a PIVOT MODE toggle, field checklist, and Columns / Rows / Values wells (drag-and-drop). OFF renders the flat participant grid (column groups, flags, ratings, inline filter row); ON pivots Language -> Country x Game with heat-mapped avg measures.
- Pivot - Table + Designer - Drag-and-drop Pivot Designer with Filters / Rows / Columns / Values zones, multi-level column headers, subtotal + grand-total rows, row-header sort menu.
- Pivot - Sales pipeline - Polished pivot view: KPI strip, region/sales-person rows, quarter columns, two measures, expand-all/collapse-all toolbar, heatmap tinting.
- Pivot - Conditional cells - Function-valued cell and header templates on top of createPivotModel: traffic-light revenue pills, target chips, units data-bars, measure icons in headers, region color dots in row labels.
- Pivot - Drill-through - Click any pivot value cell - leaf, subtotal, or grand total - and the right rail opens with the source facts behind the aggregate. Total + count + average always match the cell.