Bar labels
Every bar has a label, by default the name of the task. Two options decide what you see:
display.barLabelsays where it goes: inside the bar, outside it,'auto'(inside when it fits, outside when it does not), or'none'.display.renderBarLabelsays what it is. It is called with the task (id,name,task,progress) and returns a string, a DOM node, a{ node, dispose }, or nothing, which is the name. With@ganttlane/reactthe same is therenderBarLabelprop, which returns aReactNode.
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 # ReactrenderBarLabel returns |
The label is |
|---|---|
a string |
text, never parsed as HTML |
a DOM Node |
the node, as it is |
{ node, dispose } |
a node that is told (dispose()) when the chart is done with it |
null or undefined |
the name of the task |
import type { Link, Task } from '@ganttlane/core';
/** What `task.data` holds: who owns the task. A label can read it from `task.data`. */export interface Meta { owner: 'Ann' | 'Bo' | 'Cy' | 'Di';}
// 2026-03-02 is a Monday. In the week view a day is 20 px wide, so a bar of five days is 100 px:// "Research" fits in its bar, "Accessibility audit" does not, and `display.barLabel: 'auto'`// puts each label where it fits.export const tasks: Task<Meta>[] = [ { id: 'research', name: 'Research', start: '2026-03-02', end: '2026-03-11', progress: 100, data: { owner: 'Ann' }, }, { id: 'a11y', name: 'Accessibility audit', start: '2026-03-09', end: '2026-03-13', progress: 60, data: { owner: 'Di' }, }, { id: 'design', name: 'Design system', start: '2026-03-12', end: '2026-03-20', progress: 45, data: { owner: 'Bo' }, }, { id: 'icons', name: 'Icons', start: '2026-03-12', end: '2026-03-13', progress: 20, data: { owner: 'Bo' }, }, { id: 'migrations', name: 'Database migrations', start: '2026-03-16', end: '2026-03-20', progress: 80, data: { owner: 'Cy' }, }, { id: 'frontend', name: 'Frontend', start: '2026-03-16', end: '2026-03-23', progress: 30, data: { owner: 'Ann' }, }, { id: 'docs', name: 'Docs', start: '2026-03-18', end: '2026-03-20', progress: 0, data: { owner: 'Di' }, }, { id: 'security', name: 'Security review', start: '2026-03-24', end: '2026-03-25', progress: 0, data: { owner: 'Cy' }, }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-03-26', data: { owner: 'Ann' } },];
export const links: Link[] = [ { source: 'research', target: 'design' }, { source: 'design', target: 'frontend', type: 'SS' }, // start to start { source: 'frontend', target: 'security' }, { source: 'docs', target: 'security', type: 'FF' }, // finish to finish { source: 'security', target: 'launch' },];import type { GanttOptions } from '@ganttlane/dom';
import type { Meta } from './data';
export const options = { display: { // Where a bar's label goes: 'inside', 'outside', 'none', or 'auto' (the default), which puts // it inside when it fits and outside when it does not. The select in the example changes it. barLabel: 'auto', // What the label says is `display.renderBarLabel` (vanilla.ts) or the `renderBarLabel` prop // (react.tsx). Left out, a label is the name of the task. }, // A little shorter than the defaults (48 and 30 px), room enough for an avatar of 16 px. layout: { rowHeight: 34, barHeight: 22 }, viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions<Meta>>;import '@ganttlane/dom/styles.css';import './styles.css';
import { type BarLabelOption, type BarLabelRenderer, createGantt } from '@ganttlane/dom';
import { links, type Meta, tasks } from './data';import { options } from './options';
type Renderer = BarLabelRenderer<Meta>;type Content = 'name' | 'text' | 'node' | 'disposable';
const PLACEMENTS: readonly BarLabelOption[] = ['auto', 'inside', 'outside', 'none'];const CONTENTS: readonly (readonly [Content, string])[] = [ ['name', 'name (default)'], ['text', 'string'], ['node', 'DOM node'], ['disposable', 'DisposableNode'],];
/** An avatar and a name, built from DOM APIs: the name is data, so it goes in as text. */function labelNode(name: string, owner: string): HTMLElement { const label = document.createElement('span'); label.className = 'bar-label'; const avatar = document.createElement('span'); avatar.className = 'bar-label__avatar'; avatar.dataset.owner = owner; avatar.dataset.initial = owner.slice(0, 1); const text = document.createElement('span'); text.className = 'bar-label__name'; text.textContent = name; label.append(avatar, text); return label;}
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 = '390px'; const status = document.createElement('p'); root.append(toolbar, chart, status); container.append(root);
let content: Content = 'name'; let made = 0; let disposed = 0; const describe = (): string => { switch (content) { case 'name': return 'The label is the name of the task: the default.'; case 'text': return 'A string, inserted as text: the name and the progress.'; case 'node': return 'A DOM node: an avatar and the name. "auto" counts the text only, so pick inside or outside.'; case 'disposable': return `A DisposableNode: ${made} made, ${disposed} disposed, ${made - disposed} alive. Hide the labels, drag a bar, scroll.`; } };
// 1. A string: inserted as text, never parsed as HTML. const text: Renderer = ({ name, progress }) => `${name} · ${Math.round(progress)}%`;
// 2. A DOM node: appended as it is. Its width is estimated from its text, so for 'auto' the // avatar (which has no text) is not counted. const node: Renderer = ({ name, task }) => labelNode(name, task.data?.owner ?? '');
// 3. A DisposableNode, `{ node, dispose }`: the chart calls `dispose()` once when it is done // with the node (the label is hidden, the bar leaves the window, the label is made again from // new inputs, the chart is destroyed). The counters show that it happens. const disposable: Renderer = ({ name, task }) => { made += 1; status.textContent = describe(); return { node: labelNode(name, task.data?.owner ?? ''), dispose: () => { disposed += 1; status.textContent = describe(); }, }; }; const renderers: Record<Content, Renderer | undefined> = { name: undefined, // `undefined` puts the default back: the name of the task text, node, disposable, };
status.textContent = describe(); const gantt = createGantt<Meta>(chart, { tasks, links, ...options });
const controller = new AbortController(); const addSelect = <T extends string>( label: string, choices: readonly (readonly [value: T, text: string])[], onChange: (value: T) => void, ): void => { const wrapper = document.createElement('label'); wrapper.append(`${label} `); const select = document.createElement('select'); for (const [value, caption] of choices) { const option = document.createElement('option'); option.value = value; option.textContent = caption; select.append(option); } select.addEventListener( 'change', () => { const chosen = choices.find(([value]) => value === select.value); if (chosen) onChange(chosen[0]); }, { signal: controller.signal }, ); wrapper.append(select); toolbar.append(wrapper); };
addSelect( 'Placement', PLACEMENTS.map((id) => [id, id] as const), // `display` is merged field by field: this changes the placement and nothing else. (barLabel) => gantt.setOptions({ display: { barLabel } }), ); addSelect('Content', CONTENTS, (value) => { content = value; gantt.setOptions({ display: { renderBarLabel: renderers[value] } }); status.textContent = describe(); });
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { type BarLabelContext, Gantt } from '@ganttlane/react';import { useState } from 'react';
import { links, type Meta, tasks } from './data';import { options } from './options';
const PLACEMENTS = ['auto', 'inside', 'outside', 'none'] as const;const CONTENTS = [ ['name', 'name (default)'], ['text', 'string'], ['avatar', 'React element'],] as const;type Placement = (typeof PLACEMENTS)[number];type Content = (typeof CONTENTS)[number][0];
// The label of a bar is a React element, drawn through a portal into the bar: it sees your context// providers and your state, and may use hooks. Keep the logic in a component.function Label({ name, owner }: { name: string; owner: string }) { return ( <span className="bar-label"> <span className="bar-label__avatar" data-owner={owner} data-initial={owner.slice(0, 1)} /> <span className="bar-label__name">{name}</span> </span> );}
// A string is a ReactNode too.const renderText = ({ name, progress }: BarLabelContext<Meta>) => `${name} · ${Math.round(progress)}%`;const renderAvatar = ({ name, task }: BarLabelContext<Meta>) => ( <Label name={name} owner={task.data?.owner ?? ''} />);
const NOTES: Record<Content, string> = { name: 'The label is the name of the task: the default.', text: 'A string, inserted as text: the name and the progress.', avatar: 'A React element in a portal. The chart cannot measure it, so "auto" puts it inside: pick outside to see it beside the bar.',};
export default function Example() { const [placement, setPlacement] = useState<Placement>(options.display.barLabel); const [content, setContent] = useState<Content>('name');
// In React `tasks`, `links` and `viewMode` are controlled props, and `defaultTasks`, // `defaultLinks` and `defaultViewMode` are where an uncontrolled chart starts (there is no // `dataMode` prop). const { viewMode, ...rest } = options;
return ( <> <div role="toolbar"> <label> Placement{' '} <select value={placement} onChange={(event) => { const next = PLACEMENTS.find((id) => id === event.target.value); if (next) setPlacement(next); }} > {PLACEMENTS.map((id) => ( <option key={id} value={id}> {id} </option> ))} </select> </label> <label> Content{' '} <select value={content} onChange={(event) => { const next = CONTENTS.find(([id]) => id === event.target.value); if (next) setContent(next[0]); }} > {CONTENTS.map(([id, caption]) => ( <option key={id} value={id}> {caption} </option> ))} </select> </label> </div> <Gantt<Meta> style={{ height: 390 }} defaultTasks={tasks} defaultLinks={links} defaultViewMode={viewMode} {...rest} display={{ barLabel: placement }} // The same as `display.renderBarLabel`, returning a ReactNode. Not given: the name. renderBarLabel={ content === 'text' ? renderText : content === 'avatar' ? renderAvatar : undefined } /> <p>{NOTES[content]}</p> </> );}/* * The content of a custom label: an avatar and a name. A label that is a DOM node (or a React * element) lives in `div.gantt__bar-label-content`, a flex box inside the bar that centres it and * clips what does not fit. The colour of the text is the chart's (`color` of `.gantt__bar-label`: * white inside the bar, dark outside), so the node only sets what is its own. */.bar-label { display: inline-flex; align-items: center; gap: 5px; min-width: 0;}
.bar-label__name { overflow: hidden; text-overflow: ellipsis;}
/* * Inside, the box is the bar's, so a name that does not fit is cut with an ellipsis. Outside, the * box is only as wide as the chart's estimate of the text and the label may overflow it * (`overflow: visible` on the foreignObject): the root of the label says which one it is, with * `data-placement`. */.gantt__bar-label[data-placement='outside'] .bar-label { min-width: auto;}
.gantt__bar-label[data-placement='outside'] .bar-label__name { overflow: visible;}
/* The initial is generated from `data-initial`, so the node has no text of its own for it. */.bar-label__avatar { display: inline-grid; flex: none; place-items: center; width: 16px; height: 16px; border-radius: 50%; background: var(--avatar, #cbd5e1); color: #1b2230; font-size: 9px; font-weight: 700; line-height: 1;}
.bar-label__avatar::before { content: attr(data-initial);}
.bar-label__avatar[data-owner='Ann'] { --avatar: #fbbf24;}
.bar-label__avatar[data-owner='Bo'] { --avatar: #f9a8d4;}
.bar-label__avatar[data-owner='Cy'] { --avatar: #86efac;}
.bar-label__avatar[data-owner='Di'] { --avatar: #c4b5fd;}Files
data.ts: nine tasks with anownerin theirdata, and bars of different widths, so that'auto'has something to decide.options.ts:display.barLabel, row and bar heights, the first scroll.styles.css: the avatar and the name inside a label.vanilla.ts: a string, a DOM node and aDisposableNodewith counters, swapped withsetOptions({ display }).react.tsx: therenderBarLabelprop with a string and a<Label>component.
Options
| Option | Value | What it does |
|---|---|---|
display.barLabel |
'auto'; the select: all four values |
Where the label goes: 'auto', 'inside', 'outside' or 'none' |
display.renderBarLabel |
functions (vanilla) | What the label is; undefined is the name |
renderBarLabel |
functions (React) | The same, returning a ReactNode; replaces display.renderBarLabel |
layout.rowHeight, layout.barHeight |
34, 22 |
Room enough for a 16 px avatar |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan |
What to try
Leave the placement on auto and look at the chart: “Research” is inside its bar, “Accessibility
audit” and “Database migrations” are beside theirs, because the text does not fit in five days.
Choose inside: they are clipped to the bar. Choose outside and none. Then change the
content: a string (name and progress), a DOM node with an avatar, and, in the vanilla example, a
DisposableNode: the status line counts the nodes it made and the nodes the chart has let go of.
Hide the labels (none), drag a bar, or scroll, and watch the counters: a node is disposed when its
label is hidden, made again from new inputs, or when its bar leaves the window.
Notes
'auto'needs a width, and it only has an estimate. The chart never measures text while it draws (that forces a layout). For a string or a name it estimates the width from the characters; for a plain node, from the node’stextContent, so an avatar made of CSS is not counted and the label may be a little wider than it looks. For aDisposableNodeor a React element the width is not known at all (the node is kept by its owner and holds whatever was drawn last), and'auto'puts it inside the bar. When the width matters, choose'inside'or'outside'.- Inside, a label is clipped to its bar; outside, it is not. The node is in a
foreignObject, indiv.gantt__bar-label-content: a flex box the size of the bar (inside) or as wide as the estimate, starting at the bar’s edge (outside). A label takes no pointer events. - Colour. The text colour is the chart’s:
--gantt-bar-label-colorinside a bar (white),--gantt-bar-label-outside-colorbeside it. A node inherits it; set your owncoloron the part that needs another. - Nodes and text. Build a node with DOM APIs and
textContent, neverinnerHTML: a task name is data. A string is inserted as text for you. - A
DisposableNodeis for a node that has a life of its own (a widget, a framework’s portal host).dispose()is called once, and the new label is asked for before the old one is disposed, so the two may share a node.@ganttlane/reactis built on this: itsReactNodeslots are portals into such nodes. - React. The function is called as the body of a component of its own, once for each visible bar, so it may use hooks and sees your context providers. For hundreds of bars that is hundreds of portals: keep the label light. A function that is new on every render costs the chart nothing.
- A renderer should not throw. If it does, that bar shows its name and the error is rethrown from the frame, as the errors of every render function are.