Theming
FluxGantt is styled entirely through CSS custom properties (--fg-*) and BEM classes
(fg--prefixed). Every visual property in the SVG renderer is written inline with a token
fallback, so the chart renders correctly with zero required host CSS — you only add CSS to
override the defaults.
Design tokens
Override any of these on a container (or :root) to retheme. Values below are the defaults
(from the design system, spec §8.2).
Right-to-left
None of the tokens below is direction-specific, so a theme works unchanged in a mirrored chart.
withRtl sets dir="rtl" on the mount container itself; your own CSS for the
container should use logical properties (padding-inline-start, not padding-left).
Typography
--fg-font-sans: 'Inter', 'Geist', system-ui, sans-serif;
--fg-font-mono: 'JetBrains Mono', ui-monospace, monospace;
--fg-font-size-xs: 11px;
--fg-font-size-sm: 12px;
--fg-font-size-base: 13px;
--fg-font-size-lg: 14px;Density (row heights)
--fg-row-height-compact: 24px;
--fg-row-height-default: 32px;
--fg-row-height-comfortable: 40px;
--fg-row-height-touch: 48px;touch is the density withResponsive selects under a coarse pointer. It is a
real density level like the others, so you can set density: 'touch' yourself without the mixin.
Surfaces (light)
--fg-bg: #fafafa;
--fg-bg-subtle: #f3f4f6;
--fg-fg: #18181b;
--fg-fg-muted: #6b6b73;
--fg-border: #e5e7eb;
--fg-border-strong: #d4d4d8;Surfaces (dark)
--fg-bg-dark: #0a0a0a;
--fg-bg-subtle-dark: #18181b;
--fg-fg-dark: #fafafa;
--fg-fg-muted-dark: #a1a1aa;
--fg-border-dark: #27272a;These *-dark names are the palette, not a switch: the renderers read the base names
(--fg-bg, --fg-fg, …) and nothing reads a *-dark token. Going dark means redefining the
base names with these values — which is exactly what withTheme does for you.
Task colors
--fg-task-default: #6366f1; /* indigo — the primary accent */
--fg-task-default-hover: #4f46e5;
--fg-task-critical: #ef4444; /* red — critical path */
--fg-task-completed: #10b981; /* emerald */
--fg-task-milestone: #f59e0b; /* amber — diamond marker */--fg-task-critical does double duty: the critical-path bars, and the today marker — a
vertical rule at the current instant (.fg-timeline__today-line), drawn across the full chart
height, header band included. Retheme it by overriding that one token. The marker is always
on, needs no configuration, and is recomputed on each render; it is omitted entirely when
"now" falls outside the chart's range, rather than clamped onto an edge where it would read as
"today is the first day of this project".
The marker and the --fg-grid-today column wash are two different layers, and both are drawn:
the wash is a fill saying this day is today, the line is a stroke saying we are here
within it. At viewMode: 'year' (1 px/day) the wash is a hairline and the line is the only
legible signal, which is why it exists.
--fg-task-completed paints the .fg-task__progress overlay — the completed portion of each
bar, drawn over --fg-task-default at a width of task.progress (0..1) times the bar. It is
the same percentage the bar's aria-label announces, so overriding the color never changes
what a screen reader reports. Milestones have no fill (a partial diamond would read as a
different shape), and a task at progress: 0 emits no overlay element at all.
Grid & dependencies
--fg-grid-line: #e5e7eb;
--fg-grid-line-strong: #d4d4d8;
--fg-grid-weekend: #f9fafb;
--fg-grid-today: #fef3c7;
--fg-grid-holiday: #fee2e2;
--fg-dep-line: #64748b;
--fg-dep-line-critical: #dc2626;
--fg-dep-arrow-size: 6px;Motion
--fg-transition-fast: 100ms ease-out;
--fg-transition-default: 150ms ease-out;Example: retheme to a teal accent
.fg-gantt {
--fg-task-default: #14b8a6;
--fg-task-default-hover: #0d9488;
}The .fg-gantt class is the container the React/Vue wrappers put on their root element. The
vanilla renderer adds no class to your container — it only appends its .fg-timeline SVG —
so with createGantt() directly, target the element you mounted into (or :root) instead:
#my-chart {
--fg-task-default: #14b8a6;
}Either way the mechanism is the same: custom properties inherit, so any ancestor of the chart works as an override point.
BEM class list
The renderer uses BEM classes, all fg--prefixed so they never clash with your app:
.fg-timeline .fg-timeline__header .fg-timeline__row
.fg-task .fg-task__bar .fg-task__progress
.fg-task--critical .fg-task--selected .fg-task--milestone
.fg-dependency .fg-dependency--fs
.fg-timeline__today-line .fg-timeline__layer
.fg-timeline__sticky-header .fg-timeline__sticky-column .fg-timeline__sticky-corner
.fg-timeline__today-line is aria-hidden — it is decoration, and the chart root is a
role="grid"/"treegrid" whose children must be row-structured.
.fg-timeline__sticky-header is the pinned date axis: an aria-hidden <svg> after the chart that
replicates the header (and its .fg-timeline__header-label text) so it stays at the top while the
rows scroll. It reads the same --fg-* tokens as the header, so theming needs nothing extra; host
CSS that targets header labels applies to both copies.
.fg-timeline__sticky-column is the pinned label column: aria-hidden clones of the row labels and
toggles (they keep fg-timeline__row-label / fg-timeline__row-toggle and carry the row's
data-row-index / data-task-id), and .fg-timeline__sticky-corner is the top-left box above both
layers. The real labels stay in the chart for assistive tech and export and are hidden on screen
with opacity: 0. The column's ground must be opaque, and the library paints no page ground, so it
uses --fg-bg (fallback #fff):
.my-gantt-host {
--fg-bg: #0b0b0f; /* match the surface behind the chart */
}withTheme() sets --fg-bg for you. Without one of the two, a non-white page shows a white slab
behind the names.
Critical path without color
Critical bars carry a dashed outline in addition to the red — controlled by
--fg-task-critical-dash (default 4 2). That keeps the critical path distinguishable for
color-blind users and in grayscale, satisfying WCAG's "don't rely on color alone":
.fg-gantt {
--fg-task-critical-dash: 6 3; /* longer dashes */
}Dark mode
Theming is an opt-in mixin on its own subpath, so a chart that never switches themes never downloads the code:
import { createGantt } from '@fluxgantt/core';
import { withRender } from '@fluxgantt/core/render';
import { withTheme } from '@fluxgantt/core/theme';
const gantt = withTheme(withRender(createGantt({ tasks, dependencies })));
gantt.mount(document.getElementById('chart')!);
gantt.setTheme('dark'); // 'light' | 'dark' | 'auto'
gantt.getTheme(); // 'dark' — what you configured
gantt.getResolvedTheme(); // 'dark' — what is actually paintedThe initial value can also come from config: createGantt({ tasks, theme: 'dark' }). It
defaults to 'auto'. The React and Vue wrappers compose withTheme for you, expose theme as a reactive
prop, and default it to 'light' (not 'auto') so existing charts do not flip on a dark-mode OS.
What 'auto' does
'auto' resolves against prefers-color-scheme and keeps following it — an OS-level
light/dark switch repaints the chart with no call from you. The subscription is held only while
the chart is mounted: it is not armed before the first mount(), and is removed on unmount()
and destroy(). In an environment without matchMedia (SSR, a bare test runner), 'auto' resolves
to 'light' and nothing throws.
setTheme throws on an unknown name, and after destroy(); the two getters stay safe to call
after teardown. Calling it with the theme already in effect is a no-op — no repaint.
How it works
withTheme sets the dark palette as inline custom properties on your mount container and
removes them again for light (every token's built-in fallback is already the light value). Since
custom properties inherit, that rethemes the whole chart:
| Token | Dark value |
|---|---|
--fg-bg | #0a0a0a |
--fg-bg-subtle | #18181b |
--fg-fg | #fafafa |
--fg-fg-muted | #a1a1aa |
--fg-border | #27272a |
--fg-border-strong | #3f3f46 |
--fg-grid-line | #27272a |
--fg-grid-line-strong | #3f3f46 |
--fg-grid-weekend | #18181b |
--fg-grid-today | #422006 |
--fg-grid-holiday | #450a0a |
--fg-dep-line | #94a3b8 |
--fg-link-handle-fill | #18181b |
--fg-link-handle-radius (default 4px) is not in either palette table because it is geometry,
not color. withResponsive raises it to 12px under a coarse pointer, and
overriding that back needs the declaration on .fg-timeline itself — see
opting out for why a rule on your mount
container does not win.
The table is exported as DARK_TOKENS from @fluxgantt/core/theme if you want to diff or extend
it. Task accent colors are deliberately untouched — indigo/red/emerald/amber are saturated
mid-tones that read on both grounds, and re-tinting them would change what "critical red" means.
A note on precedence: withTheme writes the dark tokens as inline custom properties on the
mount container, and an inline declaration beats any class rule on that same element. So while a
chart is dark, a rule such as .fg-gantt { --fg-bg: ... } (or #my-chart { ... }) on the container
loses for those 13 tokens, and so does an inline value the host set on the container: withTheme
overwrites it, and restores the pre-dark value when the chart returns to light, is unmounted
(unmount(), or a remount into another element), or is destroyed. That restore also replaces any
value the host wrote to one of those 13 properties on the container while the chart was dark. (In
the Vue wrapper a reactive style binding on <FluxGantt> is re-applied on every re-render, so
there a host inline value can win again after a runtime switch to dark. Do not rely on either
behaviour.) To override a dark
token, declare it on a descendant, which beats the inherited inline value:
.fg-gantt .fg-timeline { --fg-bg: #000; }. Tokens outside the table (task accents, geometry)
are never written inline, so a plain container rule works for them. To restyle only in dark mode, key off the resolved theme yourself:
gantt.on('renderer:selected', () => {
document.documentElement.dataset.theme = gantt.getResolvedTheme();
});Exporting a dark chart
exportSvg() and exportPng() bake computed styles off the live chart and paint the chart's
resolved --fg-bg (#0a0a0a in the dark theme, #ffffff when unset) as the ground, so a dark
chart exports dark with no extra step. Pass background to override it, or
background: 'transparent' to opt out. Two caveats:
- A host whose ground is not
--fg-bgshould passbackground. The library only sees the--fg-bgcustom property, not your page's CSS. A--fg-bgvalue that is not hex,rgb(),hsl()(comma or space syntax) or a plain keyword, or that the browser cannot parse as a color (a typo such asblak), falls back to white. - Author custom dark colors as hex or
rgb()/hsl(). The export sanitizer whitelists those and rejectsoklch(),lab()andcolor(), so a palette authored inoklch()renders on screen but silently falls back to the light value in the exported file.