Appearance & Theming
Theme, density, row height, empty/loading states
The appearance group controls how the grid looks: theme, row height, density, striping, and the empty/loading/error states.
A few visual terms used below:
- Theme — a coordinated set of colours.
'light'is dark text on white;'dark'is light text on a dark background.- Density — how tightly packed the rows are vertically.
'compact'fits more rows on screen;'comfortable'adds breathing room.- Striping (a.k.a. "zebra striping") — giving every other row a slightly different background so the eye can follow a row across wide tables.
- Overlay — a panel drawn on top of the grid area to communicate a whole-grid state: "no data", "loading…", or "something failed".
const grid = createGrid<User>({
columns,
data,
appearance: {
theme: 'light',
density: 'normal',
rowHeight: 44,
rowStriping: true,
},
})
Appearance options
| Property | Type | Default | Meaning |
|---|---|---|---|
theme |
'light' | 'dark' | 'auto' |
'light' |
Color theme (auto follows the OS) |
density |
'compact' | 'normal' | 'comfortable' |
'normal' |
Vertical spacing preset |
rowHeight |
number |
40 |
Body row height in pixels |
headerHeight |
number |
44 |
Header row height in pixels |
rowStriping |
boolean |
true |
Alternating row background |
showColumnBorders |
boolean |
false |
Vertical lines between columns |
showHoverHighlight |
boolean |
true |
Highlight the row under the cursor |
className |
string |
— | Extra CSS class on the grid container |
style |
Partial<CSSStyleDeclaration> |
— | Inline styles on the grid container |
Dark mode
appearance: { theme: 'dark' }
// or follow the operating system:
appearance: { theme: 'auto' }
Compact rows for dense data
appearance: { density: 'compact', rowHeight: 32 }
Add your own wrapper class / styles
appearance: {
className: 'my-grid',
style: { borderRadius: '12px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' },
}
Empty states
There are two different "empty" situations, and the grid shows a different message for each so users aren't confused:
- No data at all — you genuinely have zero rows (a brand-new account, say).
- No matching data — you have rows, but the current filter hides all of them. The fix here is "clear your filters", not "add data".
When the grid has no data, it shows an empty-state overlay. Customize the text and icon — icons are typed as VanillaNode (a plain HTMLElement, or just a string):
const icon = document.createElement('span')
icon.style.fontSize = '48px'
icon.textContent = '📋'
appearance: {
emptyState: {
title: 'No employees yet',
description: 'Add your first employee to get started.',
icon,
},
}
When a filter matches nothing, a separate state is shown:
appearance: {
filteredEmptyState: {
title: 'No matching results',
description: 'Try adjusting or clearing your filters.',
},
}
For full control, pass your own renderer — a plain function that receives the overlay's container element and paints into it directly (no component, no virtual DOM):
appearance: {
emptyStateComponent: (container) => {
container.innerHTML = '<div class="empty">Nothing to see here 🤷</div>'
},
}
emptyStateComponent, if provided, overrides the simple emptyState/filteredEmptyState props above.
Loading state
When data is loading (typically with a server-side dataSource), the grid shows a loading overlay:
appearance: {
loadingState: { message: 'Loading employees…' },
}
Or a custom renderer, same shape as the empty-state one:
appearance: {
loadingComponent: (container) => {
container.innerHTML = '<div class="spinner">⏳ Loading…</div>'
},
}
The grid is smart about loading: with static
datait won't flash a loading shimmer on the first render.
Error state
errorComponent is a renderer that additionally receives the thrown Error as its second argument:
appearance: {
errorComponent: (container, error) => {
container.innerHTML = `<div class="error">Failed to load: ${error.message}</div>`
},
}
Theming with CSS variables
A CSS custom property (often called a "CSS variable") is a named value you can define once and reuse throughout your styles — they always start with two dashes, like --eg-accent. EliteGrid builds its entire look out of these, all prefixed with --eg-. Because the grid reads these variables rather than hard-coding colours, you can re-skin it to match your brand simply by giving them new values in your own CSS — no need to fight the library's stylesheet.
.my-grid {
--eg-accent: #6d28d9; /* highlight / focus color */
--eg-border: #e5e7eb;
--eg-header-bg: #f9fafb;
}
(Pair this with appearance.className: 'my-grid' so the variables only apply to this grid. A variable set on .my-grid "cascades" down to every element inside it, which is how one declaration restyles the whole table.)
density and rowHeight are independent — set both
It's easy to assume density picks a row height for you, but the two options do genuinely different jobs and don't talk to each other:
rowHeightis what the grid's virtualization math actually uses to lay out and measure rows (in pixels). It defaults to40if you don't set it.densityonly adds a CSS class (compact/normal/comfortable) that adjusts visual things like cell padding and font size. It does not change the pixel value the virtualizer thinks each row is.
That means switching density alone, without also adjusting rowHeight, can leave you with padding tuned for a different row height than the grid is actually rendering at — rows that look slightly cramped (compact CSS in a 40px row) or slightly loose (comfortable CSS in a 40px row). For a genuinely compact or comfortable grid, set both:
// Genuinely compact: tighter padding AND a shorter row
appearance: { density: 'compact', rowHeight: 32 }
// Genuinely comfortable: roomier padding AND a taller row
appearance: { density: 'comfortable', rowHeight: 52 }
If you only change rowHeight without density, the grid still renders correctly — you just keep the 'normal' padding/font-size at your custom height, which is a perfectly valid look too.
Following the site's own theme toggle
appearance.theme is read once, when the grid is created — there's no method to flip it live afterwards. That's fine, because the grid's whole look is driven by the --eg-* CSS variables described above, and CSS variables are reactive to attribute/class changes. So instead of trying to update appearance.theme at runtime, point your CSS at whatever your own toggle currently has set, and update a data-theme attribute on the wrapper whenever the toggle changes:
const wrapper = document.getElementById('grid-container')!
wrapper.classList.add('my-grid')
function applyTheme(theme: 'light' | 'dark') {
wrapper.dataset.theme = theme
}
applyTheme(localStorage.getItem('theme') === 'dark' ? 'dark' : 'light')
document.getElementById('theme-toggle')!.addEventListener('click', () => {
const next = wrapper.dataset.theme === 'dark' ? 'light' : 'dark'
applyTheme(next)
localStorage.setItem('theme', next)
})
.my-grid[data-theme='dark'] {
--eg-header-bg: #1a1a2a;
--eg-row-bg: #0f0f1a;
--eg-cell-text: #e2e8f0;
/* …override the rest of the --eg-* variables you care about */
}
Set appearance: { theme: 'light' } (or leave the default) in createGrid so the grid's own light/dark class matches your CSS overrides, and let the data-theme attribute — driven by whatever your toggle sets — do the actual switching. There's no reactivity system to route this through in vanilla JS; setting the attribute directly, as above, is the whole mechanism.
theme: 'auto'is a good default if your app doesn't have its own toggle yet — it just follows the OS setting viaprefers-color-scheme, with zero wiring required.
Live example
The theme toggle from above, live: a button flips data-theme on the grid's wrapper element, which re-cascades the --eg-* variables scoped to .my-grid — no grid re-creation.
import { createGrid, mount } from '@elitegrid/vanilla'
import '@elitegrid/vanilla/styles.css'
interface Employee {
id: number
name: string
department: string
role: string
}
const employees: Employee[] = [
{ id: 1, name: 'Ada Lovelace', department: 'Engineering', role: 'Staff Engineer' },
{ id: 2, name: 'Alan Turing', department: 'Research', role: 'Principal Scientist' },
{ id: 3, name: 'Grace Hopper', department: 'Engineering', role: 'Eng Manager' },
{ id: 4, name: 'Margaret Hamilton', department: 'Engineering', role: 'Tech Lead' },
{ id: 5, name: 'Katherine Johnson', department: 'Research', role: 'Senior Analyst' },
{ id: 6, name: 'Linus Torvalds', department: 'Engineering', role: 'Staff Engineer' },
{ id: 7, name: 'Tim Berners-Lee', department: 'Research', role: 'Principal Scientist' },
{ id: 8, name: 'Barbara Liskov', department: 'Engineering', role: 'Eng Manager' },
{ id: 9, name: 'Dennis Ritchie', department: 'Engineering', role: 'Staff Engineer' },
{ id: 10, name: 'Radia Perlman', department: 'Research', role: 'Senior Analyst' },
]
const grid = createGrid<Employee>({
columns: [
{ field: 'name', header: 'Name', size: { flex: 2 } },
{ field: 'department', header: 'Department', size: { flex: 1.5 } },
{ field: 'role', header: 'Role', size: { flex: 1.5 } },
],
data: employees,
appearance: { className: 'my-grid' },
})
const style = document.createElement('style')
style.textContent = `
.my-grid { --eg-header-bg: #f9fafb; --eg-row-bg: #ffffff; --eg-cell-text: #111827; --eg-border: #e5e7eb; --eg-accent: #6d28d9; }
.my-grid[data-theme='dark'] { --eg-header-bg: #1a1a2a; --eg-row-bg: #0f0f1a; --eg-cell-text: #e2e8f0; --eg-border: #2d2d4d; --eg-accent: #a78bfa; }
`
document.head.appendChild(style)
const container = document.getElementById('grid-container')!
container.style.cssText = 'height:440px;display:flex;flex-direction:column;gap:8px;padding:8px;box-sizing:border-box'
const toolbar = document.createElement('div')
toolbar.style.cssText = 'display:flex;gap:8px;flex-shrink:0'
const gridWrapper = document.createElement('div')
gridWrapper.className = 'my-grid'
gridWrapper.dataset.theme = 'light'
gridWrapper.style.cssText = 'flex:1;min-height:0'
const toggleBtn = document.createElement('button')
toggleBtn.textContent = 'Switch to dark theme'
toggleBtn.style.cssText = 'padding:7px 16px;border-radius:7px;border:none;cursor:pointer;' +
'font-size:0.8rem;font-weight:600;font-family:system-ui;background:#7c3aed;color:#ffffff'
toggleBtn.onclick = () => {
const next = gridWrapper.dataset.theme === 'dark' ? 'light' : 'dark'
gridWrapper.dataset.theme = next
toggleBtn.textContent = `Switch to ${next === 'dark' ? 'light' : 'dark'} theme`
}
toolbar.appendChild(toggleBtn)
container.appendChild(toolbar)
container.appendChild(gridWrapper)
mount(grid, gridWrapper)
Common appearance mistakes
| Symptom | Cause | Fix |
|---|---|---|
Custom className/style changes don't seem to apply |
CSS variable overrides need to target the class you passed via appearance.className, not :root |
Scope your overrides to .my-grid { --eg-accent: ...; }, matching the class name |
| Grid flashes light theme before switching to dark | data-theme is set after first paint (e.g. in a DOMContentLoaded listener) |
Read the saved/OS theme and set data-theme synchronously, in an inline <script> before the grid mounts, or default to 'auto' |
Empty state shows even though data has rows |
You're looking at filteredEmptyState, not emptyState — a filter is hiding everything |
Check api.getFilterModel(); this is the "no matching data" case, not "no data at all" |
Custom loadingComponent never appears |
You're using static data, not a dataSource |
The loading overlay is driven by the async dataSource lifecycle — static data has nothing to wait for |
emptyStateComponent renders once and never updates |
The renderer paints into the container once, synchronously — it isn't a live-bound component | Re-run your own painting logic (e.g. re-call container.innerHTML = ...) inside an onFilterChange/onReady handler if the empty state needs to reflect changing app state |
Next: 10 · Exporting to CSV