Skip to content
FluxGantt

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 painted

The 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:

TokenDark 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-bg should pass background. The library only sees the --fg-bg custom property, not your page's CSS. A --fg-bg value 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 as blak), falls back to white.
  • Author custom dark colors as hex or rgb()/hsl(). The export sanitizer whitelists those and rejects oklch(), lab() and color(), so a palette authored in oklch() renders on screen but silently falls back to the light value in the exported file.