Locale and strings
Two options make a chart speak your language, and they are separate on purpose:
locale, a BCP 47 tag ('en'by default, not the browser’s language, so that a server and a browser draw the same text), says how dates and numbers are written, withIntl: the labels of the header (months, weekdays, the range of a week), and the dates and numbers of the tooltip.stringssays which words the chart uses for the text it writes itself: the lines of the tooltip, the title and the action of the link menu, and the accessible name of the chart. They are English until you override them, whatever the locale is. The words and the grammar are yours.
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. The tasks last 1, 3, 12, 5 and 2 working days, so that the tooltip's// "working days" line meets the plural forms of a language: Ukrainian says "1 робочий день",// "3 робочі дні" and "5 робочих днів" (and "12 робочих днів").export const tasks: Task[] = [ { id: 'brief', name: 'Brief', start: '2026-03-02', end: '2026-03-02', progress: 100 }, { id: 'design', name: 'Design', start: '2026-03-03', end: '2026-03-05', progress: 100 }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-24', progress: 35 }, { id: 'docs', name: 'Docs', start: '2026-03-16', end: '2026-03-20', progress: 60 }, { 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: 'brief', target: 'design' }, { source: 'design', target: 'build' }, { source: 'build', target: 'docs', type: 'SS' }, { source: 'build', target: 'review' }, { source: 'review', target: 'launch' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // A BCP 47 tag: how dates and numbers are written, in the header and in the tooltip. The default // is 'en' (not the browser's language, so that a server and a browser draw the same text). The // `strings` option is separate, see `strings.ts`: it is what the chart says, in whose words. locale: 'en', // The header rows are month and week range: both come from the locale. viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;
/** The locales of the select: a tag and the name of the language, in that language. */export const locales = [ ['en', 'English'], ['uk', 'Українська'], ['de', 'Deutsch'], ['ja', '日本語'], ['th-TH-u-ca-buddhist', 'ไทย (Buddhist calendar)'],] as const;import '@ganttlane/dom/styles.css';
import { createGantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { locales, options } from './options';import { wordings } from './strings';
const VIEW_MODES = ['day', 'week', 'month'] as const;
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const chart = document.createElement('div'); chart.style.height = '360px'; const status = document.createElement('p'); root.append(toolbar, chart, status); container.append(root);
const gantt = createGantt(chart, { tasks, links, ...options });
// The accessible name of the chart is one of the strings (`chartLabel`): read it from the DOM, as // a screen reader does. It changes in the frame after `setOptions`. let locale: string = options.locale; let wording = wordings[0].label; const scroller = chart.querySelector('.gantt__scroller'); const show = (): void => { const label = scroller?.getAttribute('aria-label') ?? ''; status.textContent = `locale: ${locale} · strings: ${wording} · aria-label: “${label}” · weeks start on Monday in every locale`; }; const observer = new MutationObserver(show); if (scroller) observer.observe(scroller, { attributes: true, attributeFilter: ['aria-label'] }); show();
const controller = new AbortController(); const addSelect = ( label: string, choices: readonly (readonly [value: string, text: string])[], onChange: (value: string) => void, initial: string, ): void => { 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.value = initial; select.addEventListener('change', () => onChange(select.value), { signal: controller.signal, }); wrapper.append(select); toolbar.append(wrapper); };
addSelect( 'Locale', locales, (tag) => { // Dates and numbers: the header, and the dates and numbers of the tooltip. The words stay. locale = tag; gantt.setOptions({ locale: tag }); show(); }, options.locale, ); addSelect( 'Strings', wordings.map(({ id, label }) => [id, label]), (id) => { const chosen = wordings.find((candidate) => candidate.id === id) ?? wordings[0]; // `strings` is merged key by key, so go back to English first (`undefined`), then apply the // set: otherwise the keys a smaller set leaves out would keep the previous language. gantt.setOptions({ strings: undefined }); gantt.setOptions({ strings: chosen.strings }); wording = chosen.label; show(); }, wordings[0].id, ); addSelect( 'View', VIEW_MODES.map((id) => [id, id]), (id) => { gantt.setViewMode(id); }, options.viewMode, );
return () => { controller.abort(); observer.disconnect(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt } from '@ganttlane/react';import { useEffect, useRef, useState } from 'react';
import { links, tasks } from './data';import { locales, options } from './options';import { wordings } from './strings';
const VIEW_MODES = ['day', 'week', 'month'];
export default function Example() { const root = useRef<HTMLDivElement>(null); const [locale, setLocale] = useState<string>(options.locale); const [wordingId, setWordingId] = useState(wordings[0].id); // `viewMode` is controlled by its prop: the chart shows the one your state holds. const [viewMode, setViewMode] = useState<string>(options.viewMode); const [label, setLabel] = useState('Gantt chart'); // English until the observer hears of another const wording = wordings.find(({ id }) => id === wordingId) ?? wordings[0];
// The accessible name of the chart is one of the strings (`chartLabel`): read it from the DOM, as // a screen reader does. It changes in the frame after the prop does. useEffect(() => { const host = root.current; if (!host) return undefined; const observer = new MutationObserver(() => { setLabel(host.querySelector('.gantt__scroller')?.getAttribute('aria-label') ?? ''); }); observer.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label'], }); return () => { observer.disconnect(); }; }, []);
return ( <div ref={root}> <div role="toolbar"> <label> Locale{' '} <select value={locale} onChange={(event) => setLocale(event.target.value)}> {locales.map(([tag, name]) => ( <option key={tag} value={tag}> {name} </option> ))} </select> </label> <label> Strings{' '} <select value={wordingId} onChange={(event) => setWordingId(event.target.value)}> {wordings.map(({ id, label: text }) => ( <option key={id} value={id}> {text} </option> ))} </select> </label> <label> View{' '} <select value={viewMode} onChange={(event) => setViewMode(event.target.value)}> {VIEW_MODES.map((id) => ( <option key={id} value={id}> {id} </option> ))} </select> </label> </div> <Gantt style={{ height: 360 }} // In React `tasks`, `links` and `viewMode` are controlled props, and `defaultTasks` and // `defaultLinks` are where an uncontrolled chart starts (there is no `dataMode` prop). defaultTasks={tasks} defaultLinks={links} {...options} viewMode={viewMode} // Dates and numbers: the header, and the dates and numbers of the tooltip. locale={locale} // What the chart says. A prop is merged key by key and one that goes away is reset, so // switching to a smaller set puts English back for the keys it leaves out. strings={wording.strings} /> <p> locale: {locale} · strings: {wording.label} · aria-label: “{label}” · weeks start on Monday in every locale </p> </div> );}import type { GanttStringsInput } from '@ganttlane/dom';
// The grammar belongs to the strings, so the plural rules are Ukrainian whatever the chart's// `locale` is. The locale is only used for the numbers (`toLocaleString(locale)`).const ukPlural = new Intl.PluralRules('uk');
const WORKING_DAYS: Record<Intl.LDMLPluralRule, string> = { zero: 'робочих днів', one: 'робочий день', // 1, 21, 31, … two: 'робочі дні', few: 'робочі дні', // 2–4, 22–24, … many: 'робочих днів', // 0, 5–20, 25–30, … other: 'робочого дня', // fractions: 2.5};
/** * Every string the chart writes itself, in Ukrainian. A string that has a number or a name in it is * a function of the value and the chart's locale; the others are plain text. */export const uk: GanttStringsInput = { // The `aria-label` of the chart's focusable scroll region. chartLabel: 'Діаграма Ганта',
// The default tooltip of a task. progress: (percent, locale) => `${percent.toLocaleString(locale)}% виконано`, workingDays: (days, locale) => `${days.toLocaleString(locale)} ${WORKING_DAYS[ukPlural.select(days)]}`, dateRange: (from, to) => `${from} — ${to}`,
// The default menu of a link. linkTitle: (source, target) => `${source} → ${target}`, removeLink: 'Видалити залежність',
// The toolbar and the view mode names are reserved: nothing draws them yet (the toolbar is // planned), but a translation can already have them. today: 'Сьогодні', zoomIn: 'Збільшити', zoomOut: 'Зменшити', viewMode: 'Режим перегляду', hour: 'Година', quarterDay: 'Чверть доби', halfDay: 'Півдоби', day: 'День', week: 'Тиждень', month: 'Місяць', quarter: 'Квартал', year: 'Рік',};
/** Any subset works: two keys reword the link menu, and every other text stays as it was. */export const linkMenuOnly: GanttStringsInput = { removeLink: 'Unlink these tasks', linkTitle: (source, target) => `${source}, then ${target}`,};
/** One entry of the select: a set of strings, or none (`undefined`: English). */export interface Wording { readonly id: string; readonly label: string; readonly strings: GanttStringsInput | undefined;}
/** The sets of the example's select: the default, every string, and two. */export const wordings: readonly [Wording, ...Wording[]] = [ { id: 'en', label: 'English (the default)', strings: undefined }, { id: 'uk', label: 'Ukrainian (every string)', strings: uk }, { id: 'menu', label: 'Two strings (the link menu)', strings: linkMenuOnly },];Files
data.ts: six tasks that last 1, 3, 12, 5 and 2 working days (and a milestone), so the tooltip meets the plural forms of a language.options.ts:locale, the first scroll, and the list of locales in the select.strings.ts:uk, a full Ukrainianstringsobject, with the plural rules of Ukrainian,linkMenuOnly, a set of two strings, andwordings, the list the select shows.vanilla.tsandreact.tsx: the three selects and the status line, which reads thearia-labelof the chart from the DOM.
Options
| Option | Value | What it does |
|---|---|---|
locale |
'en'; the select: 'uk', 'de', 'ja', 'th-TH-u-ca-buddhist' |
The tag for Intl: header labels, dates and numbers in the tooltip |
strings |
none; the select: uk, linkMenuOnly |
The words of the chart. Any subset works; undefined puts English back |
viewMode |
'week'; the select: 'day', 'month' |
The scale, so you can see the header rows of each (React: a controlled prop) |
scroll.initial |
'first-task' |
Opens on the plan |
The keys of strings, with their English default and what they are used for:
| Key | English | Used for |
|---|---|---|
chartLabel |
Gantt chart |
The aria-label of the chart’s focusable region |
progress(percent, locale) |
60% done |
The progress line of the task tooltip |
workingDays(days, locale) |
1 working day, 2.5 working days |
The length line of the task tooltip |
dateRange(from, to, locale) |
Mar 2, 2026 – Mar 6, 2026 |
The dates line of the task tooltip |
linkTitle(source, target, locale) |
Design → Build |
The title of the link menu |
removeLink |
Remove dependency |
The action of the link menu |
today, zoomIn, zoomOut, viewMode |
Today, Zoom in, Zoom out, View mode |
The toolbar: reserved, nothing draws it yet |
hour, quarterDay, halfDay, day, week, month, quarter, year |
Hour, Quarter day, … Year |
The names of the view modes: reserved, nothing draws them yet |
What to try
Choose a locale and look at the header: the month names, the order of day and month, the range of a
week and the era of the year (the Thai locale writes Buddhist years) all change, and the dates in the
tooltip with them. Hover a bar: the words of the tooltip are still English. Choose the Ukrainian
strings: the tooltip, the link menu (click an arrow), and the aria-label in the status line speak
Ukrainian. Hover Brief (“1 робочий день”), Design (“3 робочі дні”) and Docs (“5 робочих днів”): the
word for “day” changes with the number, which is what Intl.PluralRules is for. Choose the third set: only two strings
change, and the rest stay English, whatever you chose before. Then mix them: locale de with
Ukrainian words writes the numbers the German way.
Notes
- A string with a number or a name in it is a function of the value and the chart’s locale
(
workingDays: (days, locale) => …); the others are text. The function does the grammar, plurals included: other languages have more forms than “one” and “other”. Instrings.tsthe plural rules are Ukrainian (new Intl.PluralRules('uk')) whatever the locale is, because the grammar belongs to the words, and the locale only formats the numbers. stringsis merged key by key with what was set before, insetOptionsand in the React prop alike. A key you leave out keeps its value, so switching from a full set to a small one means going back to English first:setOptions({ strings: undefined }), then the new set (a React prop that changes is reset field by field for you). A value of the wrong kind (a function where a string goes, an empty string) puts that key’s default back.- The week starts on Monday in every locale in this version. The locale decides how a week is
written in the header (
2–8,Mar 30 – Apr 5), not where it begins; week starts and weekend defaults from the locale are planned. Which days are days off is thecalendar.weekendsoption, not the locale. - Not available yet: right-to-left.
direction: 'rtl'is accepted and reported as anunsupported-option; the chart is always drawn left to right. - What is not a string: task names and the labels of highlighted days and holidays (yours), the
output of
tooltip.render,linkMenu.renderanddisplay.renderBarLabel(also yours: they receivelocaleand can useIntlwith it), and the messages of issues, which are for developers and English. - Locales the runtime does not know. A tag that is well formed but that the runtime has no data
for (
'xx') draws English, never the runtime’s own language. A tag that is not a tag is an issue (invalid-option).context.localein a render function is the tag you gave, so format with[locale, 'en']as the library does if you want the same fallback. - A calendar of another kind (
th-TH-u-ca-buddhist) writes its years; the cells themselves stay Gregorian.