Conditional formatting (engine)
Excel-style value-driven cell coloring as a declarative `conditionalFormats` engine prop: gradient heat maps (alpha ramp, zero-centred, banded, column-comparison), in-cell data bars, icon sets, and predicate rules - scoped per column, no per-cell snippet.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Rows & Cells). See the SvGrid documentation for the full API.
About this example
Excel-style conditional formatting as a declarative engine prop of the Svelte 5 data grid. Pass every rule in one conditionalFormats array, later entries win, and no per-cell snippet is needed: dataBar draws a gradient-filled in-cell bar, colorScale paints heat maps as an alpha ramp, zero-centred, banded or compared against another column, iconSet adds arrows, traffic lights or triangles by threshold, and rule applies a style when a predicate matches. conditionalStatScope rescales the ramp to the visible rows.
Excel-style, value-driven cell coloring as a declarative engine prop - no per-cell snippet. Pass every rule in one conditionalFormats array; later entries win. This demo showcases the gradient engine:
dataBar in-cell proportional bar (now gradient-filled) colorScale alpha heat map · zero-centred · N-stop bands · compareColumn iconSet arrows / traffic / triangles by threshold rule apply a style when a predicate matches
Two live toggles show the headline additions: the alpha ramp (a translucent tint that composites over zebra/selection) and conditionalStatScope, which rescales the ramp to the visible rows.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: columnFilteringFeature
Columns: rep (Rep), region (Region), revenue (Revenue), filled (Filled / target), target (Target), pnl (P&L), growth (Growth %), score (Health score), risk (Risk)
SvGridApi methods called: api.clearFilter(), api.getDisplayedRows(), api.setFilter()
Frequently asked questions
How do I add a heat map to a column?
Add { type: 'colorScale', columns: ['revenue'] } with min, mid and max colours to the conditionalFormats array. mode: 'alpha' with a base colour tints the cell with a translucent ramp that composites over zebra stripes and selection, and compareColumn scales one column against another.
What does conditionalStatScope do?
It chooses whether min and max for a scale come from all rows or only the rows currently displayed. With 'visible', filtering the grid rescales the ramp to what is on screen; the demo toggles it live.
How do rules combine?
Entries are applied in array order and later ones win for the same cell, so { type: 'rule' } with a predicate can sit on top of a colour scale for the same column, and { type: 'iconSet', columns: ['growth'], set: 'arrows', thresholds: [0, 10] } adds the icon without touching the fill.
Related documentation
Related articles
- Conditional Formatting - Color Cells by Their Value - Four rule types, one prop - add heatmaps, data bars, icon sets, and threshold highlights to any SvGrid column without custom cell renderers.
Source code (141-conditional-formatting.svelte)
<!-- Documented in: docs/help/cells/conditional-formatting.md -->
<script lang="ts">
/**
* 141. Conditional formatting
* ---------------------------
* Excel-style, value-driven cell coloring as a declarative engine prop -
* no per-cell snippet. Pass every rule in one `conditionalFormats` array;
* later entries win. This demo showcases the gradient engine:
*
* dataBar in-cell proportional bar (now gradient-filled)
* colorScale alpha heat map · zero-centred · N-stop bands · compareColumn
* iconSet arrows / traffic / triangles by threshold
* rule apply a style when a predicate matches
*
* Two live toggles show the headline additions: the alpha ramp (a
* translucent tint that composites over zebra/selection) and
* `conditionalStatScope`, which rescales the ramp to the visible rows.
*/
import {
SvGrid,
tableFeatures,
columnFilteringFeature,
type GridColumns,
type ConditionalFormat,
type SvGridApi,
} from '@svgrid/grid'
// Filtering must be enabled for `conditionalStatScope` to be observable and
// for the in-grid filter (below) to take effect.
const features = tableFeatures({ columnFilteringFeature })
type Row = {
id: number
rep: string
region: string
revenue: number
filled: number
target: number
pnl: number
growth: number
score: number
risk: number
}
let seed = 0x51ed
const rnd = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff)
const REPS = ['Ada L.', 'Grace H.', 'Alan T.', 'Margaret H.', 'Linus T.', 'Donald K.', 'Brian K.', 'Dennis R.', 'Barbara L.', 'Ken T.']
const REGIONS = ['Americas', 'EMEA', 'APAC']
const rows: Row[] = REPS.map((rep, id) => {
const target = Math.round(40 + rnd() * 60)
return {
id,
rep,
region: REGIONS[id % 3]!,
revenue: Math.round(20_000 + rnd() * 380_000),
target,
filled: Math.round(target * (0.5 + rnd())), // ~50%..150% of target
pnl: Math.round((rnd() - 0.45) * 90_000), // spans negative + positive
growth: Math.round((rnd() - 0.4) * 60),
score: Math.round(rnd() * 100),
risk: Math.round(rnd() * 100),
}
})
// --- Live controls -------------------------------------------------------
let alphaMode = $state(true)
let statScope = $state<'visible' | 'all'>('all')
let topOnly = $state(false)
let api = $state<SvGridApi<typeof features, Row> | null>(null)
// Whether ANY filter currently narrows the grid - the demo's own toggle OR
// a column menu. Fed by `onFiltersChange` so it stays true to what's really
// on screen. `conditionalStatScope` only has a visible effect while some
// rows are hidden: with every row visible, "visible" and "all" describe the
// same set, so the ramp is identical and the toggle looks inert. Gating the
// control on this makes that relationship obvious instead of surprising.
let rowsHidden = $state(0)
const anyFilter = $derived(rowsHidden > 0)
function onFiltersChange(f: { global: string; columns: unknown[] }) {
const active = f.global.trim() !== '' || f.columns.length > 0
// Count how many rows the filter removed, for the live status line.
rowsHidden = active ? rows.length - (api?.getDisplayedRows().length ?? rows.length) : 0
}
// Filter INSIDE the grid (not by swapping `data`) so the two stat scopes
// actually differ: the grid narrows the visible rows while the full dataset
// stays intact behind them. With `visible` the ramp rescales to the rows on
// screen; with `all` it stays anchored to every row. Driven from the
// checkbox handler (not a reactive `$effect`) so a single user action maps
// to a single imperative filter call.
function applyTopOnly(next: boolean) {
topOnly = next
if (next) api?.setFilter('score', { operator: 'greaterThan', value: '55' })
else api?.clearFilter('score')
}
const columns: GridColumns<Row> = [
{ field: 'rep', header: 'Rep', width: 130 },
{ field: 'region', header: 'Region', width: 110 },
{
field: 'revenue',
header: 'Revenue',
width: 190,
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },
},
{ field: 'filled', header: 'Filled / target', width: 150, align: 'right' },
{ field: 'target', header: 'Target', width: 110, align: 'right' },
{
field: 'pnl',
header: 'P&L',
width: 150,
align: 'right',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },
},
{ field: 'growth', header: 'Growth %', width: 120, align: 'right' },
{ field: 'score', header: 'Health score', width: 150, align: 'center' },
{ field: 'risk', header: 'Risk', width: 130, align: 'center' },
]
const conditionalFormats = $derived<ConditionalFormat<Row>[]>([
// Gradient-filled data bar across the revenue range.
{ type: 'dataBar', columns: ['revenue'], color: '#3b82f6', gradient: true },
// Column comparison: tint each cell by filled / target on the same row.
{
type: 'colorScale',
columns: ['filled'],
mode: 'alpha',
base: '#2563eb',
compareColumn: 'target',
tooltip: 'percent',
},
// Zero-centred diverging scale: red below 0, green above, neutral at 0.
{
type: 'colorScale',
columns: ['pnl'],
min: '#ef4444',
mid: '#f8fafc',
max: '#22c55e',
zeroCentred: true,
},
// Health score - toggle between the alpha heat map and a hue ramp.
alphaMode
? { type: 'colorScale', columns: ['score'], mode: 'alpha', base: '#0ea5e9', tooltip: 'percent' }
: { type: 'colorScale', columns: ['score'], min: '#fca5a5', mid: '#fde68a', max: '#86efac', minValue: 0, maxValue: 100 },
// Banded / traffic-light: green -> amber -> red across the risk range.
{
type: 'colorScale',
columns: ['risk'],
stops: [
{ offset: 0, color: '#22c55e' },
{ offset: 0.5, color: '#f59e0b' },
{ offset: 1, color: '#ef4444' },
],
},
// Arrows by growth threshold (down < 0 <= flat < 10 <= up)...
{ type: 'iconSet', columns: ['growth'], set: 'arrows', thresholds: [0, 10] },
// ...and a rule reddening negative growth.
{
type: 'rule',
columns: ['growth'],
when: ({ value }) => Number(value) < 0,
color: '#dc2626',
fontWeight: 700,
},
])
</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);">
Gradient conditional formatting via <code>conditionalFormats</code>
</p>
<p class="mt-1 text-xs" style="color: var(--sg-muted);">
Revenue = gradient data bar · Filled = alpha tint vs target (hover for %)
· P&L = zero-centred red/green · Growth = arrows + red negatives ·
Health = heat map · Risk = banded traffic-light. One array, no per-cell
snippets.
</p>
<div class="mt-3 flex flex-wrap items-center gap-4 text-xs">
<label class="inline-flex items-center gap-2" style="color: var(--sg-fg);">
<input type="checkbox" bind:checked={alphaMode} />
Alpha heat map on Health score
</label>
<label class="inline-flex items-center gap-2" style="color: var(--sg-fg);">
<input
type="checkbox"
checked={topOnly}
onchange={(e) => applyTopOnly(e.currentTarget.checked)}
/>
Show top performers only
</label>
<span
class="inline-flex items-center gap-2"
style="color: var(--sg-fg); opacity: {anyFilter ? 1 : 0.45};"
title={anyFilter ? '' : 'Apply a filter first - with every row visible, "visible" and "all" describe the same set, so the ramp does not change.'}
>
Stat scope:
<button
type="button"
class="rounded px-2 py-0.5 border"
disabled={!anyFilter}
style="border-color: var(--sg-border); background: {statScope === 'visible' ? 'var(--sg-accent, #2563eb)' : 'transparent'}; color: {statScope === 'visible' ? 'var(--sg-on-accent, #fff)' : 'var(--sg-fg)'}; cursor: {anyFilter ? 'pointer' : 'not-allowed'};"
onclick={() => (statScope = 'visible')}
>visible</button>
<button
type="button"
class="rounded px-2 py-0.5 border"
disabled={!anyFilter}
style="border-color: var(--sg-border); background: {statScope === 'all' ? 'var(--sg-accent, #2563eb)' : 'transparent'}; color: {statScope === 'all' ? 'var(--sg-on-accent, #fff)' : 'var(--sg-fg)'}; cursor: {anyFilter ? 'pointer' : 'not-allowed'};"
onclick={() => (statScope = 'all')}
>all</button>
</span>
</div>
<p class="mt-2 text-[11px]" style="color: var(--sg-muted);">
{#if anyFilter}
<strong>{rowsHidden}</strong> row{rowsHidden === 1 ? '' : 's'} hidden by the
filter. Now the scope matters: <strong>visible</strong> rescales the ramp
across the {rows.length - rowsHidden} rows on screen, while
<strong>all</strong> keeps it anchored to the full {rows.length}.
{:else}
Filter the grid first (toggle <em>Show top performers only</em>, or any
column menu). The stat scope only rescales the ramp once some rows are
hidden - with every row visible, <strong>visible</strong> and
<strong>all</strong> are the same set.
{/if}
</p>
</div>
<div class="flex-1 min-h-0">
<SvGrid
columnResize
responsive={true}
data={rows}
columns={columns}
features={features}
conditionalFormats={conditionalFormats}
conditionalStatScope={statScope}
filterMode="menu"
selectionMode="none"
rowHeight={38}
containerHeight="100%"
fitColumns={true}
onFiltersChange={onFiltersChange}
onApiReady={(next) => (api = next)}
/>
</div>
</section>More Rows & Cells examples
- Managed row dragging (grid-to-grid) - Reorder rows by dragging their grip, or move a row from one grid into another - both grids share a rowDragGroup, so the row leaves the source and lands in the target. The grid mutates its own data on drop and fires onRowDragEnd on the receiver.
- External drop zones (row drag) - Drag a row out of the grid onto any element - an Archive or Delete bucket - via the rowDropZone action. The row leaves the grid and the zone's onDrop handles it. In-grid reorder still works.
- Custom cells + themes - Avatars, sparklines, progress bars, density toggle, dark mode, full a11y.
- Sparkline cells - In-cell mini charts as a first-class column type: set `sparkline` on a number-array column and the grid paints an inline SVG. Line, area, bar (with +/- coloring), and win/loss - no chart library, no custom snippet.
- Conditional styling - Support-ticket triage board: rowClass highlights SLA breach + at-risk rows with side-bar accents; cellClass paints priority pills, status badges, agent-load progress bars, and CSAT highlights.