Top N / Bottom N filter
BI-style "show me top 10 by revenue" toolbar: pick a metric, pick N, top vs bottom. Live KPI strip shows revenue coverage of the slice.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Filtering & Search). See the SvGrid documentation for the full API.
About this example
A BI-style top N and bottom N toolbar for the Svelte 5 data grid: pick a metric such as revenue, pick N, pick top or bottom, and the grid shows those rows while a KPI strip reports how much of the total the slice covers. It composes with the column-menu filters, so top 5 in EMEA works, and it is plain user-land code: sort a copy of the visible data and slice.
The "show me the top 10 by revenue" toolbar that every BI tool ships. Pick a metric, pick N, pick top / bottom; the grid filters to those rows ranked by that metric. Composes with the column-menu filters - top-N runs on whatever's already visible, so "top 5 in EMEA" works.
Pure user-land: we sort a copy of the data and slice. No library change required.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: salesperson (Salesperson), region (Region), industry (Industry), revenue (Revenue), deals (Deals), pipeline (Pipeline), winRate (Win rate), avgDealSize (Avg deal)
Frequently asked questions
How is top N implemented?
A $derived sorts a copy of the rows by the chosen metric, takes the first or last N and passes that array as data. No grid feature is involved beyond rendering.
Does it work together with column filters?
Yes. The column-menu filters apply on top of the sliced rows, so you can rank first and narrow by region after, or the other way round by filtering before choosing N.
What does the KPI strip show?
The slice's share of the metric's total across all rows, so top 10 by revenue is reported as the percentage of revenue those ten rows account for.
Related documentation
Related articles
- Excel-Style Filtering for Your Svelte Data Grid - Per-column filter menus, a filter row, global search, and server-side filtering - all wired through a single columnFilteringFeature in SvGrid.
- How We Built Excel-Style Filters - How SvGrid implements per-column filter menus, type-aware operators, and a shared filter model that works identically for in-memory and server-side data.
- Server-Side Data - Pagination, Sorting, and Filtering on the Backend - Keep 100,000+ rows on the server. SvGrid owns the UI state for sort, filter, and pagination controls - your API owns the data.
Source code (99-top-n-filter.svelte)
<script lang="ts">
/**
* 99. Top N / Bottom N filter
* ---------------------------
* The "show me the top 10 by revenue" toolbar that every BI tool ships.
* Pick a metric, pick N, pick top / bottom; the grid filters to those
* rows ranked by that metric. Composes with the column-menu filters -
* top-N runs on whatever's already visible, so "top 5 in EMEA" works.
*
* Pure user-land: we sort a copy of the data and slice. No library
* change required.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type GridColumns,
} from '@svgrid/grid'
type Sales = {
id: string
salesperson: string
region: 'Americas' | 'EMEA' | 'APAC'
industry: 'SaaS' | 'Retail' | 'Manufacturing' | 'Finance' | 'Healthcare'
revenue: number
deals: number
pipeline: number
winRate: number // 0..1
avgDealSize: number
}
// Seeded data
let prng = 0xFEEDCAFE
function rand() { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }
function pick<T>(a: readonly T[]): T { return a[Math.floor(rand() * a.length)]! }
function int(min: number, max: number) { return Math.floor(min + rand() * (max - min + 1)) }
function rangef(min: number, max: number) { return min + rand() * (max - min) }
const FIRST = ['Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Mason', 'Sophia', 'Lucas', 'Mia', 'Ethan',
'Charlotte', 'Henry', 'Amelia', 'Wyatt', 'Harper', 'Sebastian', 'Evelyn', 'Owen',
'Abigail', 'Carter', 'Scarlett', 'Levi', 'Aria', 'Julian', 'Aurora', 'Hudson']
const LAST = ['Thompson', 'Park', 'Singh', 'Garcia', 'Chen', 'Rivera', 'Brown', 'Patel', 'Johnson',
'Wright', 'Khan', 'Sato', 'Volkov', 'Schmidt', 'Andersson', 'Costa', 'Rossi', 'Dubois']
const REGIONS = ['Americas', 'EMEA', 'APAC'] as const
const INDUSTRIES = ['SaaS', 'Retail', 'Manufacturing', 'Finance', 'Healthcare'] as const
let rows = $state<Sales[]>(Array.from({ length: 80 }, (_, i) => {
const revenue = int(45_000, 980_000)
const deals = int(8, 95)
return {
id: `S-${(2000 + i).toString()}`,
salesperson: `${pick(FIRST)} ${pick(LAST)}`,
region: pick(REGIONS),
industry: pick(INDUSTRIES),
revenue,
deals,
pipeline: revenue + int(20_000, 600_000),
winRate: rangef(0.18, 0.74),
avgDealSize: Math.round(revenue / deals),
}
}))
// ---- Metric definitions ----------------------------------------------
type MetricId = 'revenue' | 'deals' | 'pipeline' | 'winRate' | 'avgDealSize'
type MetricDef = { id: MetricId; label: string; fmt: (n: number) => string }
const fmtUsd = (n: number) =>
n >= 1_000_000 ? `$${(n / 1_000_000).toFixed(2)}M`
: n >= 1_000 ? `$${(n / 1_000).toFixed(0)}k`
: `$${n.toFixed(0)}`
const METRICS: MetricDef[] = [
{ id: 'revenue', label: 'Revenue', fmt: fmtUsd },
{ id: 'deals', label: 'Deals closed', fmt: (n) => `${n}` },
{ id: 'pipeline', label: 'Pipeline', fmt: fmtUsd },
{ id: 'winRate', label: 'Win rate', fmt: (n) => `${(n * 100).toFixed(1)}%` },
{ id: 'avgDealSize', label: 'Avg deal size', fmt: fmtUsd },
]
// ---- Filter state ----------------------------------------------------
let enabled = $state(true)
let metric = $state<MetricId>('revenue')
let mode = $state<'top' | 'bottom'>('top')
let n = $state(10)
// The visible rows: sort by chosen metric and slice. Stays sorted by
// metric so the user can see the ranking; the grid's own header-sort
// would clobber the order, so we disable interactive sort.
const displayed = $derived.by(() => {
if (!enabled) return rows
const sorted = rows.slice().sort((a, b) =>
mode === 'top'
? (b[metric] as number) - (a[metric] as number)
: (a[metric] as number) - (b[metric] as number),
)
return sorted.slice(0, n)
})
// ---- KPI panel -------------------------------------------------------
const summary = $derived.by(() => {
const totalRev = rows.reduce((s, r) => s + r.revenue, 0)
const visRev = displayed.reduce((s, r) => s + r.revenue, 0)
return {
rows: displayed.length,
total: rows.length,
coverage: totalRev > 0 ? visRev / totalRev : 0,
visRev,
}
})
// Sticky quick-pick chips
const N_OPTIONS = [3, 5, 10, 20, 30]
function bumpN(d: number) { n = Math.max(1, Math.min(rows.length, n + d)) }
// ---- Columns ---------------------------------------------------------
const columns: GridColumns<Sales> = [
{ field: 'salesperson', header: 'Salesperson', width: 180, editable: false },
{ field: 'region', header: 'Region', width: 100, editable: false },
{ field: 'industry', header: 'Industry', width: 130, editable: false },
{ field: 'revenue', header: 'Revenue', width: 130, editable: false, align: 'right',
format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } } },
{ field: 'deals', header: 'Deals', width: 90, editable: false, align: 'right' },
{ field: 'pipeline', header: 'Pipeline', width: 140, editable: false, align: 'right',
format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } } },
{ field: 'winRate', header: 'Win rate', width: 110, editable: false, align: 'right',
format: { type: 'number', options: { style: 'percent', maximumFractionDigits: 1 } } },
{ field: 'avgDealSize', header: 'Avg deal', width: 130, editable: false, align: 'right',
format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } } },
]
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
const currentMetric = $derived(METRICS.find((m) => m.id === metric)!)
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<!-- KPI strip ---------------------------------------------------- -->
<div class="kpi-strip shrink-0">
<div class="kpi">
<div class="kpi-label">Showing</div>
<div class="kpi-value">{summary.rows} <span class="kpi-of">/ {summary.total}</span></div>
</div>
<div class="kpi">
<div class="kpi-label">Visible {currentMetric.label}</div>
<div class="kpi-value">{currentMetric.fmt(displayed.reduce((s, r) => s + (r[metric] as number), 0))}</div>
</div>
<div class="kpi">
<div class="kpi-label">Revenue coverage</div>
<div class="kpi-value accent">{(summary.coverage * 100).toFixed(1)}%</div>
<div class="coverage-bar">
<span style:width={`${summary.coverage * 100}%`}></span>
</div>
</div>
</div>
<!-- Top-N bar ---------------------------------------------------- -->
<div class="topn-bar shrink-0">
<label class="topn-toggle">
<input type="checkbox" bind:checked={enabled} />
<span>Top-N filter</span>
</label>
<div class={`topn-controls ${enabled ? '' : 'is-dimmed'}`}>
<div class="seg">
<button class={mode === 'top' ? 'is-on' : ''} onclick={() => mode = 'top'}>Top</button>
<button class={mode === 'bottom' ? 'is-on' : ''} onclick={() => mode = 'bottom'}>Bottom</button>
</div>
<div class="n-wrap">
<button class="step" onclick={() => bumpN(-1)} aria-label="Decrease N">−</button>
<input class="n-input" type="number" min="1" max={rows.length}
bind:value={n} />
<button class="step" onclick={() => bumpN(1)} aria-label="Increase N">+</button>
<span class="n-quick">
{#each N_OPTIONS as opt (opt)}
<button class={n === opt ? 'is-on' : ''} onclick={() => n = opt}>{opt}</button>
{/each}
</span>
</div>
<span class="by">by</span>
<select class="metric" bind:value={metric}>
{#each METRICS as m (m.id)}<option value={m.id}>{m.label}</option>{/each}
</select>
</div>
<div class="topn-summary">
{#if enabled}
Showing <strong>{mode === 'top' ? 'top' : 'bottom'} {n}</strong>
salespeople by <strong>{currentMetric.label}</strong>.
{:else}
Showing all {rows.length} salespeople. Toggle the filter to rank.
{/if}
</div>
</div>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={displayed}
columns={columns}
features={features}
filterMode="menu"
selectionMode="cell"
enableInlineEditing={false}
enableCellSelection={true}
rowHeight={32}
containerHeight="100%"
fitColumns={true}
/>
</div>
</section>
<style>
/* KPI strip */
.kpi-strip { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 10px; }
.kpi {
border: 1px solid var(--sg-border, #e2e8f0);
background: var(--sg-bg, #fff);
border-radius: 8px; padding: 10px 14px;
display: flex; flex-direction: column; gap: 4px;
}
.kpi-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--sg-muted, #64748b); }
.kpi-value { font-size: 22px; font-weight: 700; color: var(--sg-fg, #0f172a);
font-variant-numeric: tabular-nums; line-height: 1.1; }
.kpi-value.accent { color: var(--sg-accent, #6366f1); }
.kpi-of { font-size: 14px; color: var(--sg-muted, #94a3b8); font-weight: 500; }
.coverage-bar {
height: 6px; background: var(--sg-row-hover-bg, rgba(148,163,184,0.15));
border-radius: 3px; overflow: hidden; margin-top: 6px;
}
.coverage-bar span {
display: block; height: 100%;
background: var(--sg-accent, #6366f1);
transition: width 200ms ease-out;
}
/* Top-N bar */
.topn-bar {
display: grid; grid-template-columns: auto 1fr auto; align-items: center;
gap: 16px;
border: 1px solid var(--sg-border, #e2e8f0);
background: linear-gradient(180deg, color-mix(in oklab, var(--sg-accent, #6366f1) 4%, var(--sg-bg, #fff)), var(--sg-bg, #fff));
border-radius: 10px; padding: 10px 14px;
}
/* Phone: toggle / controls / summary in one row squeezed the select and
the 3-5-10-20-30 slider into ~80px. Stack the three cells. */
@media (max-width: 639px) {
.topn-bar { grid-template-columns: minmax(0, 1fr); gap: 10px; }
}
.topn-toggle {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 700; color: var(--sg-fg, #0f172a);
text-transform: uppercase; letter-spacing: 0.06em;
}
.topn-toggle input { accent-color: var(--sg-accent, #6366f1); }
.topn-controls {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
transition: opacity 150ms;
}
.topn-controls.is-dimmed { opacity: 0.3; pointer-events: none; }
.seg {
display: inline-flex;
border: 1px solid var(--sg-border, #cbd5e1); border-radius: 6px;
overflow: hidden;
}
.seg button {
background: var(--sg-bg, #fff); border: 0; cursor: pointer;
padding: 5px 12px; font-size: 12px; font-weight: 700;
color: var(--sg-muted, #64748b);
}
.seg button.is-on {
background: var(--sg-accent, #6366f1); color: var(--sg-on-accent, #fff);
}
.seg button + button { border-left: 1px solid var(--sg-border, #cbd5e1); }
.n-wrap { display: inline-flex; align-items: center; gap: 6px; }
.step {
width: 26px; height: 26px;
background: var(--sg-bg, #fff);
border: 1px solid var(--sg-border, #cbd5e1);
color: var(--sg-fg, #0f172a);
border-radius: 6px; font-size: 14px; font-weight: 700; cursor: pointer;
}
.step:hover { background: var(--sg-row-hover-bg, rgba(148,163,184,0.1)); }
.n-input {
width: 60px;
border: 1px solid var(--sg-input-border, #cbd5e1);
background: var(--sg-input-bg, #fff);
color: var(--sg-fg, #0f172a);
border-radius: 6px; padding: 4px 8px;
font-size: 14px; font-variant-numeric: tabular-nums;
font-weight: 700; text-align: center;
}
.n-quick { display: inline-flex; gap: 3px; margin-left: 6px; }
.n-quick button {
background: transparent; border: 1px solid transparent;
color: var(--sg-muted, #64748b);
border-radius: 999px; padding: 2px 9px;
font-size: 11px; font-weight: 700; cursor: pointer;
}
.n-quick button:hover { background: var(--sg-row-hover-bg, rgba(148,163,184,0.1)); }
.n-quick button.is-on {
background: var(--sg-accent, #6366f1); color: var(--sg-on-accent, #fff);
border-color: var(--sg-accent, #6366f1);
}
.by { font-size: 11px; color: var(--sg-muted, #64748b);
font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
.metric {
border: 1px solid var(--sg-input-border, #cbd5e1);
background: var(--sg-input-bg, #fff);
color: var(--sg-fg, #0f172a);
border-radius: 6px; padding: 5px 10px;
font-size: 13px; font-weight: 600;
font-family: inherit;
}
.topn-summary {
font-size: 12px; color: var(--sg-muted, #64748b);
text-align: right;
}
.topn-summary strong { color: var(--sg-accent, #6366f1); }
</style>More Filtering & Search examples
- Sort, filter, paginate - Three most-asked-for features wired together against ~5k rows.
- Excel-style filters - Per-column operator dropdown with active-filter chips and clear.
- Filter - between operator - Number + date columns expose a "Between" operator with From/To inputs. Drive it via the menu or imperatively with api.setFilter(id, { operator: "between", value, valueTo }).
- Highlighted search matches - External search input + a custom cell snippet that wraps matched substrings in <mark>. Filters the dataset AND visually flags hits.
- Find in grid (Ctrl+F) - Built-in find overlay with next / previous navigation. Scans every visible cell value; matches activate + scroll into view.