Theming
The look of the chart is a set of CSS custom properties, the --gantt-* tokens, and a theme is
nothing but a set of values for them. This example has the two things you will want to change:
- the theme,
theme: 'auto' | 'light' | 'dark'. It is thedata-themeattribute of the chart’s root element and switches without a redraw.'auto'followsprefers-color-scheme; - a palette and a density of your own, for one chart: three presets (Brand, Compact, High
contrast) written as CSS in
styles.css, chosen by adata-presetattribute on the chart’s container.
pnpm add @ganttlane/dom # vanillapnpm add @ganttlane/react react react-dom # Reactnpm install @ganttlane/dom # vanillanpm install @ganttlane/react react react-dom # Reactyarn add @ganttlane/dom # vanillayarn add @ganttlane/react react react-dom # Reactbun add @ganttlane/dom # vanillabun add @ganttlane/react react react-dom # Reactimport type { Link, Task } from '@ganttlane/core';
// 2026-03-02 is a Monday. A short plan with a spread of progress, a milestone and weekends, so// that bars, progress, links, rows, days off, the today line and a highlighted day all show the// tokens of a theme.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 100 }, { id: 'backend', name: 'Backend', start: '2026-03-09', end: '2026-03-20', progress: 65 }, { id: 'frontend', name: 'Frontend', start: '2026-03-11', end: '2026-03-24', progress: 40 }, { id: 'docs', name: 'Docs', start: '2026-03-16', end: '2026-03-20', progress: 20 }, { id: 'review', name: 'Review', start: '2026-03-25', end: '2026-03-26', progress: 0 }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-03-27' },];
export const links: Link[] = [ { source: 'design', target: 'backend' }, { source: 'backend', target: 'frontend', type: 'SS' }, // start to start { source: 'frontend', target: 'review' }, { source: 'docs', target: 'review' }, { source: 'review', target: 'launch' },];import type { GanttOptions } from '@ganttlane/dom';
/** * The size of rows and bars is not a token: it is the `layout` option, so a preset that changes * the density sets both halves, the tokens (type, line widths) in `styles.css` and these sizes. */export const layouts = { comfortable: { rowHeight: 40, barHeight: 24, barRadius: 3, headerRowHeight: [45, 30] }, compact: { rowHeight: 26, barHeight: 16, barRadius: 2, headerRowHeight: [26, 26] },} satisfies Record<string, NonNullable<GanttOptions['layout']>>;
export const options = { // 'auto' (the default) follows `prefers-color-scheme`; 'light' and 'dark' force a theme. It is // only the `data-theme` attribute of the chart's root, so it can change at any time for free. theme: 'auto', // A fixed "today", so that the today line is on the plan whenever you read this. An application // leaves `now` out: the chart then uses the clock. now: () => '2026-03-18', // Zebra rows and one highlighted day: two more things the tokens colour. display: { rowStripes: 'row' }, highlights: [{ id: 'freeze', dates: ['2026-03-24'], label: 'Code freeze' }], layout: layouts.comfortable, viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;
/** * The presets of the example. `id` is the value of the container's `data-preset` attribute, which * `styles.css` selects on; `layout` is the density half of the preset. */export const presets = [ { id: 'default', label: 'Default', layout: layouts.comfortable, note: 'The library’s own tokens.', }, { id: 'brand', label: 'Brand', layout: layouts.comfortable, note: 'Teal bars and links, an amber today line, a tinted header, in light and in dark.', }, { id: 'compact', label: 'Compact', layout: layouts.compact, note: 'Smaller type, thinner lines, and shorter rows and bars (the layout option).', }, { id: 'contrast', label: 'High contrast', layout: layouts.comfortable, note: 'Black on white, or white on black, with heavy lines and a bold today line.', },] as const;
export type PresetId = (typeof presets)[number]['id'];import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt, type ThemeOption } from '@ganttlane/dom';
import { links, tasks } from './data';import { options, presets } from './options';
const THEMES: readonly ThemeOption[] = ['auto', 'light', 'dark'];
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); // The container of the chart. The preset is its `data-preset` attribute, which `styles.css` // selects on (`[data-preset='brand'] .gantt`). The chart fills it, so it needs a height. const chart = document.createElement('div'); chart.style.height = '330px'; chart.dataset.preset = 'default'; const status = document.createElement('p'); root.append(toolbar, chart, status); container.append(root);
const gantt = createGantt(chart, { tasks, links, ...options });
const controller = new AbortController(); const addSelect = ( label: string, choices: readonly (readonly [value: string, text: string])[], onChange: (value: string) => void, ): HTMLSelectElement => { const wrapper = document.createElement('label'); wrapper.append(`${label} `); const select = document.createElement('select'); for (const [value, text] of choices) { const option = document.createElement('option'); option.value = value; option.textContent = text; select.append(option); } select.addEventListener('change', () => onChange(select.value), { signal: controller.signal }); wrapper.append(select); toolbar.append(wrapper); return select; };
let note: string = presets[0].note; // The `theme` option is the `data-theme` attribute of the chart's root, `div.gantt`. Read it // back rather than trusting the select: a page may set the attribute itself (this site does, to // keep every chart in its own theme), and then the select should show what the chart has. const chartRoot = chart.querySelector('.gantt'); const themeSelect = addSelect( 'Theme', THEMES.map((id) => [id, id]), (value) => { const theme = THEMES.find((id) => id === value); if (theme) gantt.setOptions({ theme }); }, ); const show = (): void => { const theme = chartRoot?.getAttribute('data-theme') ?? ''; themeSelect.value = theme; status.textContent = `data-theme="${theme}" · ${note}`; };
addSelect( 'Palette and density', presets.map(({ id, label }) => [id, label]), (value) => { const preset = presets.find(({ id }) => id === value); if (!preset) return; // The palette is CSS: an attribute on the container. The density has a second half that is // not a token, the size of rows and bars, which is the `layout` option. chart.dataset.preset = preset.id; gantt.setOptions({ layout: preset.layout }); note = preset.note; show(); }, );
const observer = new MutationObserver(show); if (chartRoot) observer.observe(chartRoot, { attributes: true, attributeFilter: ['data-theme'] }); show();
return () => { controller.abort(); observer.disconnect(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { Gantt } from '@ganttlane/react';import { useEffect, useRef, useState } from 'react';
import { links, tasks } from './data';import { options, type PresetId, presets } from './options';
const THEMES = ['auto', 'light', 'dark'] as const;type Theme = (typeof THEMES)[number];const isTheme = (value: string | null | undefined): value is Theme => THEMES.some((theme) => theme === value);
export default function Example() { const root = useRef<HTMLDivElement>(null); const [theme, setTheme] = useState<Theme>(options.theme); const [presetId, setPresetId] = useState<PresetId>('default'); const preset = presets.find(({ id }) => id === presetId) ?? presets[0];
// The `theme` prop is the `data-theme` attribute of the chart's root, `div.gantt`. Read it back // rather than trusting the select: a page may set the attribute itself (this site does, to keep // every chart in its own theme), and then the select should show what the chart has. useEffect(() => { const host = root.current; if (!host) return undefined; const observer = new MutationObserver(() => { const attribute = host.querySelector('.gantt')?.getAttribute('data-theme'); if (isTheme(attribute)) setTheme(attribute); }); observer.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-theme'], }); return () => { observer.disconnect(); }; }, []);
// In React, `viewMode`, `tasks` and `links` are controlled props, and `hierarchy.expandedKeys` // is a prop of its own; `defaultViewMode`, `defaultTasks` and `defaultLinks` are where an // uncontrolled chart starts, so they are passed explicitly. There is no `dataMode` prop. const { viewMode, ...rest } = options;
return ( <div ref={root}> <div role="toolbar"> <label> Theme{' '} <select value={theme} onChange={(event) => { const next = event.target.value; if (isTheme(next)) setTheme(next); }} > {THEMES.map((id) => ( <option key={id} value={id}> {id} </option> ))} </select> </label> <label> Palette and density{' '} <select value={presetId} onChange={(event) => { const next = presets.find(({ id }) => id === event.target.value); if (next) setPresetId(next.id); }} > {presets.map(({ id, label }) => ( <option key={id} value={id}> {label} </option> ))} </select> </label> </div> <Gantt style={{ height: 330 }} // The preset is an attribute of the container; `styles.css` selects `[data-preset] .gantt`. data-preset={presetId} defaultTasks={tasks} defaultLinks={links} defaultViewMode={viewMode} {...rest} theme={theme} // The density has a second half that is not a token: the size of rows and bars. layout={preset.layout} /> <p> data-theme="{theme}" · {preset.note} </p> </div> );}/* * Presets: custom properties for one chart, chosen by the `data-preset` attribute of its * container (`<div data-preset="brand">` holds the `div.gantt` the chart draws). * * Why `[data-preset] .gantt` and not `[data-preset]` alone: the library declares every * `--gantt-*` token on `.gantt` itself, so a token set on the container (a parent) is never * inherited by it. These rules are not in a cascade layer, and the library's are in `@layer gantt`, * so they win without `!important`, whatever the order of the two stylesheets. * * A theme is a set of token values, so a preset has to say what it looks like in `dark` too: the * library's dark values would otherwise be replaced by the light ones of the preset, because an * unlayered rule beats a layered one. The presets below keep their palette in a few variables of * their own (`--brand-*`, `--hc-*`) and derive the `--gantt-*` tokens from them once, so a theme * only has to give the variables new values. `auto` repeats the dark values under * `prefers-color-scheme`, as the library's own sheet does. */
/* ---- Brand: teal bars and links, an amber today line, a tinted header ---- */
[data-preset='brand'] .gantt { --brand-bar: #0f766e; --brand-accent: #0f766e; --brand-tint: #f0fdfa; --brand-ink: #115e59; --brand-line: #99d6cf; --brand-warm: #b45309;
--gantt-bar-color: var(--brand-bar); --gantt-bar-selected-color: var(--brand-accent); --gantt-link-color: var(--brand-ink); --gantt-link-selected-color: var(--brand-accent); --gantt-header-background-color: var(--brand-tint); --gantt-header-text-color: var(--brand-ink); --gantt-header-current-color: color-mix(in srgb, var(--brand-warm) 16%, transparent); --gantt-header-range-color: color-mix(in srgb, var(--brand-accent) 18%, transparent); --gantt-grid-major-line-color: var(--brand-line); --gantt-today-color: var(--brand-warm); --gantt-highlight-color: var(--brand-warm); --gantt-focus-ring-color: var(--brand-accent); --gantt-popover-border-color: var(--brand-line); --gantt-popover-radius: 10px;}
[data-preset='brand'] .gantt[data-theme='dark'] { --brand-bar: #0e8f81; --brand-accent: #2dd4bf; --brand-tint: #10262a; --brand-ink: #9fe2da; --brand-line: #1f5b57; --brand-warm: #fbbf24;}
@media (prefers-color-scheme: dark) { [data-preset='brand'] .gantt[data-theme='auto'] { --brand-bar: #0e8f81; --brand-accent: #2dd4bf; --brand-tint: #10262a; --brand-ink: #9fe2da; --brand-line: #1f5b57; --brand-warm: #fbbf24; }}
/* ---- Compact: smaller type and thinner lines (the sizes of rows and bars are the `layout` option) ---- */
[data-preset='compact'] .gantt { --gantt-font-size: 11px; --gantt-link-width: 1px; --gantt-link-selected-width: 2px; --gantt-today-width: 1px; --gantt-bar-selected-width: 1.5px; --gantt-popover-radius: 3px; --gantt-popover-max-width: 240px;}
/* ---- High contrast: black on white and white on black, heavy lines ---- */
[data-preset='contrast'] .gantt { --hc-bg: #fff; --hc-fg: #000; --hc-grid: #595959; --hc-bar: #0033a0; --hc-bar-label: #fff; --hc-alert: #c00000;
--gantt-background-color: var(--hc-bg); --gantt-foreground-color: var(--hc-fg); --gantt-border-color: var(--hc-fg); --gantt-border-width: 2px; --gantt-header-background-color: var(--hc-bg); --gantt-header-text-color: var(--hc-fg); --gantt-header-current-color: color-mix(in srgb, var(--hc-alert) 24%, transparent); --gantt-header-range-color: color-mix(in srgb, var(--hc-fg) 18%, transparent); --gantt-grid-line-color: var(--hc-grid); --gantt-grid-major-line-color: var(--hc-fg); --gantt-grid-major-line-width: 2px; --gantt-row-stripe-color: color-mix(in srgb, var(--hc-fg) 10%, transparent); --gantt-nonworking-color: color-mix(in srgb, var(--hc-fg) 18%, transparent); --gantt-highlight-color: var(--hc-alert); --gantt-highlight-opacity: 0.3; --gantt-today-color: var(--hc-alert); --gantt-today-width: 3px; --gantt-bar-color: var(--hc-bar); --gantt-bar-label-color: var(--hc-bar-label); --gantt-bar-label-outside-color: var(--hc-fg); --gantt-bar-selected-color: var(--hc-fg); --gantt-bar-selected-width: 3px; --gantt-link-color: var(--hc-fg); --gantt-link-width: 2px; --gantt-link-selected-color: var(--hc-alert); --gantt-link-selected-width: 3px; --gantt-focus-ring-color: var(--hc-fg); --gantt-popover-background-color: var(--hc-bg); --gantt-popover-foreground-color: var(--hc-fg); --gantt-popover-border-color: var(--hc-fg); --gantt-popover-shadow-color: transparent;}
[data-preset='contrast'] .gantt[data-theme='dark'] { --hc-bg: #000; --hc-fg: #fff; --hc-grid: #a6a6a6; --hc-bar: #ffd60a; --hc-bar-label: #000; --hc-alert: #ff6b6b;}
@media (prefers-color-scheme: dark) { [data-preset='contrast'] .gantt[data-theme='auto'] { --hc-bg: #000; --hc-fg: #fff; --hc-grid: #a6a6a6; --hc-bar: #ffd60a; --hc-bar-label: #000; --hc-alert: #ff6b6b; }}Files
data.ts: six tasks and five links, so that bars, progress, links, rows, days off, the today line and a highlighted day all have something to colour.options.ts:theme, a fixednow,display.rowStripes, a highlight, and thelayoutsizes of the presets (the density half that is not a token).styles.css: the presets, as rules on[data-preset='…'] .gantt.vanilla.tsandreact.tsx: the two selects, the attribute on the container andsetOptions({ theme })(React: thethemeprop).
Options
| Option | Value | What it does |
|---|---|---|
theme |
'auto', then your choice |
data-theme of the chart’s root: 'light', 'dark', or 'auto' (the system) |
layout.rowHeight, barHeight, barRadius, headerRowHeight |
40, 24, 3, [45, 30]; Compact: 26, 16, 2, [26, 26] |
Geometry is an option, not a token; setOptions merges it field by field |
display.rowStripes |
'row' |
Zebra rows: --gantt-row-stripe-color |
highlights |
one day | A highlighted day: --gantt-highlight-color and --gantt-highlight-opacity |
now |
() => '2026-03-18' |
A fixed today, so the today line is on the plan. Leave it out in an application |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan |
The tokens each preset sets (styles.css):
| Preset | Tokens |
|---|---|
| Brand | --gantt-bar-color, --gantt-bar-selected-color, --gantt-link-color, --gantt-link-selected-color, --gantt-header-background-color, --gantt-header-text-color, --gantt-header-current-color, --gantt-header-range-color, --gantt-grid-major-line-color, --gantt-today-color, --gantt-highlight-color, --gantt-focus-ring-color, --gantt-popover-border-color, --gantt-popover-radius |
| Compact | --gantt-font-size, --gantt-link-width, --gantt-link-selected-width, --gantt-today-width, --gantt-bar-selected-width, --gantt-popover-radius, --gantt-popover-max-width |
| High contrast | the colours of the background, text, borders, header, grid lines, rows, days off, highlight, today line, bars, bar labels, links, focus ring and popovers (--gantt-background-color, --gantt-foreground-color, --gantt-nonworking-color, --gantt-popover-*, …), and the widths --gantt-border-width, --gantt-grid-major-line-width, --gantt-today-width, --gantt-bar-selected-width, --gantt-link-width and --gantt-link-selected-width |
The whole table, with the light and dark value of every token, is in the
@ganttlane/dom README.
What to try
Switch the theme: the chart changes at once, and so does the preset you chose, because each preset
says what it looks like in light and in dark. Pick Brand and hover a bar: the tooltip has the
preset’s border and corners, and the highlighted day (Code freeze) is amber. Pick Compact and look
at the rows: type and lines are thinner (tokens) and the rows are shorter (the layout option,
applied by setOptions). Pick High contrast, then the dark theme. Click an arrow to select it, and
hover a bar to see the range it covers in the header: both are tokens.
Notes
- Set tokens on
.gantt, not on the container. The stylesheet declares every token ondiv.gantt, the element the chart adds inside your container, so a custom property set on the container (or on<body>) is never inherited by it. Give the container a class or an attribute and select the descendant,[data-preset='brand'] .gantt { --gantt-bar-color: … }, as the presets do. The library’s CSS is in@layer ganttand yours is not, so yours wins without!importantand in whatever order the two files are loaded. - A theme has two halves. Because your unlayered rule beats the library’s
darkvalues, a preset that changes colours has to give the dark ones too (.gantt[data-theme='dark'], and.gantt[data-theme='auto']under@media (prefers-color-scheme: dark)). The presets keep their palette in a few variables of their own (--brand-*,--hc-*) and derive the tokens from them once, so a theme only gives those variables new values. A preset that changes only sizes (Compact) needs no dark block. - Density is part tokens, part options. Type size and line widths are tokens
(
--gantt-font-size,--gantt-link-width, …). Row, bar and header heights are thelayoutoption, because the renderer needs the numbers to place every bar.setOptions({ layout })merges field by field, and a field set toundefinedgoes back to its default. - This site and the theme switch. The documentation site keeps every chart in its own theme: it
writes
data-themeon the chart’s root when the chart appears and when you change the theme of the site (the select in the header). That is the attribute thethemeoption sets, so the last writer wins. The Theme select here callssetOptions({ theme }), so it works; when the site’s theme changes the attribute is rewritten, and the example reads the attribute back (aMutationObserver) so that its select shows what the chart has. In your own page nothing else writesdata-theme, and you can set the option and forget it.'auto'follows the system, not the site. - Per-element colours are a separate thing:
task.colorandtask.progressColorset--gantt-bar-colorand--gantt-bar-progress-coloron a single bar; see Task colours and classes. - To replace the stylesheet instead of adjusting it, see Unstyled.