List + chips editors
Two built-in editors with single & multi-select: dropdown (list) and removable tokens (chips), with options or free-form.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Editing). See the SvGrid documentation for the full API.
About this example
The list and chips editors of the Svelte 5 data grid, in single and multi-select forms. editorType: 'list' is a native select that becomes a multi-select with editorMultiple and stores an array; editorType: 'chips' edits removable tokens, with a picker when editorOptions are given and free-form typing when they are not. editorSeparator controls how array values are joined for display, and onCellValueChange reports each commit.
Two new built-in editors:
editorType: 'list' - native <select>. With editorMultiple it becomes <select multiple>; the cell stores an array of option values.
editorType: 'chips' - removable-token editor. With options it shows a picker; without options it is free-form (type, Enter to add). With editorMultiple the cell stores an array and renders chips when not editing.
Both honor editorOptions (string|number|{value,label}[]) and editorSeparator (display join for list/single-array cells).
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: name (Task), priority (Priority (list, single)), teams (Teams (list, multi)), owner (Owner (chips, single)), tags (Tags (chips, freeform)), skills (Skills (chips, multi))
Frequently asked questions
How do I store several values in one cell?
Set editorMultiple on a list or chips column. The cell value becomes an array of option values; a chips column renders them as tokens when not editing, and editorSeparator sets the join string for a list column's display.
Can users type their own tags?
Yes. A chips column without editorOptions is free-form: type a value and press Enter to add a token, click the x on a token to remove it.
What shapes can editorOptions take?
An array of strings, of numbers, or of { value, label } objects. The label is what the dropdown and the chips show; the value is what lands in the row.
Related documentation
Related articles
- Paste from Excel into a Svelte Data Grid - How to wire up clipboard paste so users can drop a copied Excel or Google Sheets block directly into SvGrid - TSV parsing, type coercion, validation, and row growth all covered.
- A Fill Handle (Drag to Fill) in SvGrid - Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.
- An Editable Select / Dropdown Cell in SvGrid - Build a dropdown cell editor in SvGrid using a Svelte 5 snippet - constrain user input to a fixed option set while keeping sort, filter, and undo working on the underlying value.
Source code (26-list-chips-editors.svelte)
<!-- Documented in: docs/help/recipes.md -->
<script lang="ts">
/**
* 26. List + chips editors (single & multi-select)
* ------------------------------------------------
* Two new built-in editors:
*
* editorType: 'list' - native <select>. With editorMultiple it
* becomes <select multiple>; the cell stores
* an array of option values.
*
* editorType: 'chips' - removable-token editor. With options it
* shows a picker; without options it is
* free-form (type, Enter to add). With
* editorMultiple the cell stores an array
* and renders chips when not editing.
*
* Both honor `editorOptions` (string|number|{value,label}[]) and
* `editorSeparator` (display join for list/single-array cells).
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type GridColumns,
} from '@svgrid/grid'
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
})
type Row = {
id: string
name: string
/** Single value picked from a list - stored as scalar. */
priority: 'low' | 'medium' | 'high' | 'urgent'
/** Multiple values from a list - stored as array. */
teams: string[]
/** Single value via chip-style editor (picker). */
owner: string
/** Multiple chips, free-form (no preset options). */
tags: string[]
/** Multiple chips picked from options. */
skills: string[]
}
// Plain values for the list editors that don't need colors.
const TEAMS = ['Engineering', 'Design', 'Product', 'Sales', 'Support', 'Operations']
const OWNERS = ['Ada Lovelace', 'Grace Hopper', 'Alan Turing', 'Margaret Hamilton', 'Linus Torvalds']
// Colorful option lists - { value, label, color } gets rendered as a
// soft-pill chip tinted with `color-mix` so it stays readable on
// both light AND dark themes. Color can be any CSS color string.
const PRIORITIES = [
{ value: 'low', label: 'low', color: '#64748b' },
{ value: 'medium', label: 'medium', color: '#f59e0b' },
{ value: 'high', label: 'high', color: '#ef4444' },
{ value: 'urgent', label: 'urgent', color: '#dc2626' },
]
const SKILLS = [
{ value: 'TypeScript', label: 'TypeScript', color: '#3178c6' },
{ value: 'Svelte', label: 'Svelte', color: '#ff3e00' },
{ value: 'CSS', label: 'CSS', color: '#1572b6' },
{ value: 'Accessibility', label: 'Accessibility', color: '#10b981' },
{ value: 'Performance', label: 'Performance', color: '#eab308' },
{ value: 'Testing', label: 'Testing', color: '#8b5cf6' },
{ value: 'Docs', label: 'Docs', color: '#06b6d4' },
]
const TASKS = [
'Add list editor', 'Polish chips editor', 'Write demo + docs',
'Triage bug reports', 'Plan v1.1 roadmap', 'Review API surface',
'Migrate test suite', 'Profile virtual scroll', 'Document column pinning',
'Fix dark theme', 'Audit a11y', 'Set up CI workflow', 'Tune row height',
'Ship grouping feature', 'Add CSV export', 'Refactor cell editor',
'Polish summary row', 'Stress test 100k rows', 'Localize headers',
'Add right-click menu',
]
const TAG_POOL = ['feature', 'editors', 'polish', 'docs', 'demo', 'examples',
'bug', 'p0', 'p1', 'p2', 'planning', 'tech-debt', 'perf', 'a11y', 'refactor']
// Tiny deterministic PRNG so the seed doesn't drift between renders.
function rng(seed: number) {
let t = seed >>> 0
return () => {
t = (t + 0x6d2b79f5) >>> 0
let x = t
x = Math.imul(x ^ (x >>> 15), x | 1)
x ^= x + Math.imul(x ^ (x >>> 7), x | 61)
return ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296
}
}
function makeRows(count: number): Row[] {
const r = rng(7)
const pick = <T,>(arr: ReadonlyArray<T>) => arr[Math.floor(r() * arr.length)]!
const sample = <T,>(arr: ReadonlyArray<T>, min: number, max: number): T[] => {
const n = min + Math.floor(r() * (max - min + 1))
const copy = arr.slice() as T[]
const out: T[] = []
for (let i = 0; i < n && copy.length; i += 1) {
out.push(copy.splice(Math.floor(r() * copy.length), 1)[0]!)
}
return out
}
return Array.from({ length: count }, (_, i): Row => ({
id: 'r_' + i.toString(36),
name: pick(TASKS) + ' #' + (i + 1),
priority: pick(['low', 'medium', 'high', 'urgent']) as Row['priority'],
teams: sample(TEAMS, 1, 3),
owner: pick(OWNERS),
tags: sample(TAG_POOL, 1, 4),
skills: sample(['TypeScript', 'Svelte', 'CSS', 'Accessibility', 'Performance', 'Testing', 'Docs'], 1, 4),
}))
}
let rows = $state<Row[]>(makeRows(100))
const columns: GridColumns<Row> = [
{ field: 'name', header: 'Task', editorType: 'text', width: 200 },
{
field: 'priority',
header: 'Priority (list, single)',
editorType: 'list',
editorOptions: PRIORITIES,
width: 170,
},
{
field: 'teams',
header: 'Teams (list, multi)',
editorType: 'list',
editorOptions: TEAMS,
editorMultiple: true,
editorSeparator: ' · ',
width: 240,
},
{
field: 'owner',
header: 'Owner (chips, single)',
editorType: 'chips',
editorOptions: OWNERS.map((o) => ({ value: o, label: o })),
width: 220,
},
{
field: 'tags',
header: 'Tags (chips, freeform)',
editorType: 'chips',
editorMultiple: true,
width: 260,
},
{
field: 'skills',
header: 'Skills (chips, multi)',
editorType: 'chips',
editorOptions: SKILLS,
editorMultiple: true,
width: 320,
},
]
// Monotonic counter - Date.now() collides when many writes happen in
// the same tick (e.g. fill-handle drag writes 5 cells synchronously,
// all get the same millisecond). Svelte's keyed {#each} below crashes
// on duplicate keys, so use a per-entry counter instead.
let logSeq = 0
function onCellValueChange(e: {
rowIndex: number
columnId: string
oldValue: unknown
newValue: unknown
}) {
logSeq += 1
log = [
{
ts: logSeq,
row: e.rowIndex + 1,
field: e.columnId,
from: Array.isArray(e.oldValue) ? e.oldValue.join(', ') : String(e.oldValue ?? ''),
to: Array.isArray(e.newValue) ? e.newValue.join(', ') : String(e.newValue ?? ''),
},
...log,
].slice(0, 6)
}
type Edit = { ts: number; row: number; field: string; from: string; to: string }
let log = $state<Edit[]>([])
</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);">
Double-click any cell to edit. The grid ships two new editor types - list & chips -
each supporting single or multiple selection.
</p>
<ul class="mt-1 text-xs list-disc pl-5 space-y-0.5" style="color: var(--sg-fg);">
<li>
<code>editorType: 'list'</code> → native <code><select></code>. Add
<code>editorMultiple: true</code> for <code><select multiple></code>.
</li>
<li>
<code>editorType: 'chips'</code> → removable tokens. With <code>editorOptions</code> it shows a picker;
without options it's free-form (Enter to add).
</li>
<li>
Multi-select cells store an <em>array</em> of values; <code>editorSeparator</code> controls the joined display.
</li>
</ul>
</div>
<div
class="shrink-0 rounded-lg border"
style="border-color: var(--sg-border); background: var(--sg-header-bg);"
>
<div class="flex items-center justify-between px-3 py-2 border-b"
style="border-color: var(--sg-border);">
<p class="text-xs font-semibold uppercase tracking-wider" style="color: var(--sg-muted);">
Recent edits {#if log.length}({log.length}){/if}
</p>
</div>
<div class="max-h-24 overflow-y-auto px-3 py-2">
{#if log.length === 0}
<p class="text-xs italic" style="color: var(--sg-muted);">
No edits yet - try changing a Priority, Teams, or Tags cell.
</p>
{:else}
<ul class="space-y-1">
{#each log as e (e.ts)}
<li class="text-xs flex flex-wrap gap-x-2" style="color: var(--sg-fg);">
<span style="color: var(--site-accent-2, #22d3ee); font-weight: 600;">row {e.row}</span>
<span style="color: var(--sg-muted);">·</span>
<code>{e.field}</code>
<span style="color: var(--sg-muted);">:</span>
<code>"{e.from}"</code>
<span style="color: var(--sg-muted);">→</span>
<code style="color: #22c55e;">"{e.to}"</code>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
filterMode="menu"
showRowNumbers={true}
enableInlineEditing={true}
enableCellSelection={true}
rowHeight={40}
containerHeight="100%"
fitColumns={true}
onCellValueChange={onCellValueChange}
/>
</div>
<footer class="lc-muted text-xs shrink-0">
{rows.length} rows · 6 columns · 4 list/chips editor variations
</footer>
</section>
<style>
.lc-muted { color: var(--sg-muted, #64748b); }
</style>More Editing examples
- Inline editing - Typed editors (text/number/checkbox/date) with dirty tracking + save.
- Editor types + custom slot - Built-in select / rich-select / textarea editors plus a custom `cellEditor` snippet (a range slider) for cases the built-ins do not cover.
- Async editor options - editorOptions can return a Promise - for the whole column, or per row for a cascade. The dropdown shows Loading… while the request is in flight, results are cached (per column, or per column+row) so reopening never refetches, and api.refreshEditorOptions() invalidates when the server list changes.
- Custom cell editors - Three hand-rolled editors: native colour picker bound to a tag swatch, 5-star rating, emoji feedback mood. All write back through api.setCellValue.
- Dependent dropdowns - Cascade editors: Country → State → City. Each level computes its options from the row's upstream value; changing Country resets State + City.