Granularity and snapping
Two steps decide where a bar can be. The granularity is the step of the data: every date,
duration and lag in the chart is a multiple of it (a day by default; here 15 minutes). The
snap is the step of a drag: a bar jumps from one multiple of it to the next, and it is the
step of the view mode ('1h' in the quarter-day view, '15min' in the hour view, '1d' in the day
view) unless scheduling.snap says another. The data has one value that is not a multiple of the
granularity on purpose, so that the misaligned issue and scheduling.alignInput are in front of you.
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';
// Wall-clock times, in the form `'YYYY-MM-DDTHH:mm'`. 2026-03-09 is a Monday. Every value is a// multiple of 15 minutes (the granularity in `options.ts`), except the start and the end of// Handoff, which are deliberately not: `scheduling.alignInput` decides what happens to them.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-09T09:00', end: '2026-03-09T15:30' }, { id: 'build', name: 'Build', start: '2026-03-09T13:00', end: '2026-03-10T17:15' }, { id: 'review', name: 'Review', start: '2026-03-10T10:30', end: '2026-03-10T15:00' }, { id: 'qa', name: 'QA', start: '2026-03-10T14:00', end: '2026-03-11T11:45' }, { id: 'docs', name: 'Docs', start: '2026-03-11T09:00', end: '2026-03-11T13:00' }, // 13:10 and 16:35 are not multiples of 15 minutes: each is a `misaligned` issue, and is aligned // to 13:00 and 16:45 (`'expand'`), to 13:15 and 16:30 (`'nearest'`), or not accepted (`'reject'`). { id: 'handoff', name: 'Handoff', start: '2026-03-11T13:10', end: '2026-03-11T16:35' },];
export const links: Link[] = [];import type { GanttOptions } from '@ganttlane/dom';
export const options = { scheduling: { // The data step: every date, duration and lag of a task is a multiple of it. A duration that // divides 24 hours: '1min', '15min', '1h', '1d' (the default), … The first select changes it. granularity: '15min', // What to do with a value that is not a multiple of it. It is always reported as a // `misaligned` issue, and then: 'expand' (the default) aligns a start down and an end up, so // the task only grows; 'nearest' aligns to the closest step; 'reject' keeps the task out of // the chart (a row without a bar). The second select changes it. alignInput: 'expand', // `snap`, the step a drag snaps to, is left out: the step of the view mode is used ('15min' // in 'hour', '1h' in 'quarter-day' and 'half-day', '1d' in 'day', 'week' and 'month'). The // third select sets it: a duration, 'unit' (the column) or `false` (no snapping). }, // 6-hour columns, which snap to an hour; the buttons switch to 'hour' and 'day'. viewMode: 'quarter-day', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { ALIGN_INPUTS, describeEdit, describeIssues, GRANULARITIES, SNAPS, stepName, VIEW_MODES,} from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); // The chart fills its container and scrolls inside it, so the container needs a height. const chart = document.createElement('div'); chart.style.height = '370px'; // The first line says what the options do now, the second what the last drag did. const info = document.createElement('p'); const status = document.createElement('p'); status.textContent = 'Drag a bar, then hold Alt and drag it again.'; root.append(toolbar, chart, info, status); container.append(root);
// The handler reads `gantt`, which it is passed to: give it a type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, onTasksChange: (changeSet): void => { const [edited] = changeSet.changes; if (edited) status.textContent = describeEdit(gantt.getTask(edited.id), changeSet.reason); }, });
// The step a drag snaps to is resolved from the view mode, `scheduling.snap` and the granularity; // the snapshot has the result. The data issues are the chart's own. let granularity: string = options.scheduling.granularity; const refresh = (): void => { const { move } = gantt.core.getSnapshot().state.derived.snap.snap; const nameOf = (id: string | number): string => gantt.getTask(id)?.name ?? String(id); const text = `Snap step: ${stepName(move)}; with Alt: ${granularity}. ` + describeIssues(gantt.getIssues().data, nameOf); if (info.textContent !== text) info.textContent = text; }; // Called after every change of the chart's state: a view mode, an option, the data. gantt.core.subscribe(refresh); refresh();
const controller = new AbortController(); const { signal } = controller;
for (const id of VIEW_MODES) { const button = document.createElement('button'); button.type = 'button'; button.textContent = id; // `keepScroll: false` scrolls to the first task again, as when the chart opened; by default the // date at the centre of the chart stays where it is. button.addEventListener('click', () => gantt.setViewMode(id, { keepScroll: false }), { signal, }); toolbar.append(button); }
const addSelect = ( label: string, values: readonly string[], selected: string, onChange: (value: string) => void, ): void => { const field = document.createElement('label'); const select = document.createElement('select'); for (const value of values) { const option = document.createElement('option'); option.value = option.textContent = value; option.selected = value === selected; select.append(option); } select.addEventListener('change', () => onChange(select.value), { signal }); field.append(`${label} `, select); toolbar.append(field); };
// Each of these changes without recreating the chart: the core re-reads the data and the snap. addSelect( 'Snap', SNAPS.map((entry) => entry.label), SNAPS[0].label, (value) => { const entry = SNAPS.find((candidate) => candidate.label === value); if (entry) gantt.setOptions({ scheduling: { snap: entry.snap } }); }, ); addSelect('Granularity', GRANULARITIES, options.scheduling.granularity, (value) => { granularity = value; gantt.setOptions({ scheduling: { granularity: value } }); }); addSelect('Align input', ALIGN_INPUTS, options.scheduling.alignInput, (value) => { const alignInput = ALIGN_INPUTS.find((candidate) => candidate === value); if (alignInput) gantt.setOptions({ scheduling: { alignInput } }); });
const reset = document.createElement('button'); reset.type = 'button'; reset.textContent = 'Reset'; reset.addEventListener( 'click', () => { gantt.setTasks(tasks); status.textContent = 'Back to the start.'; }, { signal }, ); toolbar.append(reset);
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { type Issue, useGantt, useGanttState } from '@ganttlane/react';import { useState } from 'react';
import { links, tasks } from './data';import { ALIGN_INPUTS, describeEdit, describeIssues, GRANULARITIES, SNAPS, stepName, VIEW_MODES,} from './helpers';import { options } from './options';
export default function Example() { // In React the props are the truth, so what the selects change is state, passed below. const [granularity, setGranularity] = useState<string>(options.scheduling.granularity); const [alignInput, setAlignInput] = useState<string>(options.scheduling.alignInput); const [snapLabel, setSnapLabel] = useState<string>(SNAPS[0].label); const [issues, setIssues] = useState<readonly Issue[]>([]); const [status, setStatus] = useState('Drag a bar, then hold Alt and drag it again.'); const nameOf = (id: string | number): string => gantt.getTask(id)?.name ?? String(id);
// `viewMode` is a controlled prop in React, and `defaultViewMode` is its uncontrolled twin: the // chart owns the view mode, so the buttons can call `setViewMode` with `keepScroll: false`. const { viewMode, ...rest } = options;
const gantt = useGantt({ ...rest, // The chart owns the data and the view mode (uncontrolled): these are where it starts. defaultTasks: tasks, defaultLinks: links, defaultViewMode: viewMode, // `scheduling` is passed from state, not changed with `setOptions` on the instance, which the // next render would overwrite. scheduling: { granularity, alignInput: ALIGN_INPUTS.find((value) => value === alignInput), snap: SNAPS.find((entry) => entry.label === snapLabel)?.snap, }, // The data issues change with the granularity and `alignInput`. onIssues: ({ source, issues: found }) => { if (source === 'data') setIssues(found); }, onTasksChange: (changeSet) => { const [edited] = changeSet.changes; if (edited) setStatus(describeEdit(gantt.getTask(edited.id), changeSet.reason)); }, }); // Destructured, which keeps `react-hooks/refs` quiet about the ref callback. const { setContainer, slots } = gantt; // The step a drag snaps to is resolved from the view mode, `scheduling.snap` and the // granularity; the snapshot has the result, and the component re-renders when it changes. const step = useGanttState(gantt, (snapshot) => snapshot.state.derived.snap.snap.move);
return ( <> <div role="toolbar"> {VIEW_MODES.map((id) => ( // `keepScroll: false` scrolls to the first task again, as when the chart opened; by // default the date at the centre of the chart stays where it is. <button key={id} type="button" onClick={() => gantt.setViewMode(id, { keepScroll: false })} > {id} </button> ))} <label> Snap{' '} <select value={snapLabel} onChange={(event) => setSnapLabel(event.target.value)}> {SNAPS.map((entry) => ( <option key={entry.label}>{entry.label}</option> ))} </select> </label> <label> Granularity{' '} <select value={granularity} onChange={(event) => setGranularity(event.target.value)}> {GRANULARITIES.map((value) => ( <option key={value}>{value}</option> ))} </select> </label> <label> Align input{' '} <select value={alignInput} onChange={(event) => setAlignInput(event.target.value)}> {ALIGN_INPUTS.map((value) => ( <option key={value}>{value}</option> ))} </select> </label> <button type="button" onClick={() => { gantt.setTasks(tasks); setStatus('Back to the start.'); }} > Reset </button> </div> {/* `useGantt` is <Gantt> without the `div`: you render the container, and the slots next to it. */} <div ref={setContainer} style={{ height: 370 }} /> {slots} <p> Snap step: {stepName(step)}; with Alt: {granularity}. {describeIssues(issues, nameOf)} </p> <p>{status}</p> </> );}import type { ChangeReason, Issue, SnapRule, TaskId, TaskRecord } from '@ganttlane/core';
/** The view modes the buttons switch to. */export const VIEW_MODES = ['hour', 'quarter-day', 'day'] as const;
/** The data steps the first select offers. */export const GRANULARITIES = ['15min', '30min', '1h'] as const;
/** The values of `scheduling.alignInput`. */export const ALIGN_INPUTS = ['expand', 'nearest', 'reject'] as const;export type AlignInput = (typeof ALIGN_INPUTS)[number];
/** What the snap select offers: its label, and the `scheduling.snap` it sets (`undefined`: the view mode's). */export const SNAPS = [ { label: "view mode's", snap: undefined }, { label: '15min', snap: '15min' }, { label: '30min', snap: '30min' }, { label: '1h', snap: '1h' }, { label: '1d', snap: '1d' }, { label: 'off', snap: false },] as const;
const when = new Intl.DateTimeFormat('en', { weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',});
/** The time of day of a floating ISO string (`'2026-03-09T10:10'`), or `'?'` for anything else. */function timeOf(value: unknown): string { return typeof value === 'string' && value.includes('T') ? value.slice(11, 16) : '?';}
/** * Names a snap step: `15min`, `1h`, `1d`, or `off`. * * @param rule - A resolved step, from the snapshot: `state.derived.snap.snap.move`. * @returns A short name. */export function stepName(rule: SnapRule): string { if (rule === false) return 'off'; if (rule.kind === 'calendar') return `${String(rule.count)} ${rule.unit}`; const minutes = rule.ms / 60_000; if (minutes % 1440 === 0) return `${String(minutes / 1440)}d`; return minutes % 60 === 0 ? `${String(minutes / 60)}h` : `${String(minutes)}min`;}
/** * Says what the issues of the data are: the code, the task, the edge, the value it had and the one * it was aligned to (`suggested`; with `'reject'` it is only a suggestion). * * @param issues - `getIssues().data`, or what the `issues` event reports for `'data'`. * @param nameOf - The name of a task, from the chart. * @returns A sentence, or `'No issues.'`. */export function describeIssues(issues: readonly Issue[], nameOf: (id: TaskId) => string): string { if (issues.length === 0) return 'No issues.'; const items = issues.slice(0, 3).map((issue) => { const edge = issue.path?.split('.').at(-1) ?? ''; const name = issue.taskId === undefined ? '' : `${nameOf(issue.taskId)} `; const text = `${issue.code}, ${name}${edge} ${timeOf(issue.value)} → ${timeOf(issue.suggested)}`; return issue.severity === 'error' ? `${text} (rejected)` : text; }); const more = issues.length > 3 ? `; and ${String(issues.length - 3)} more` : ''; return `${String(issues.length)} issues: ${items.join('; ')}${more}.`;}
/** * Says where a task is after an edit, from its computed record. * * @param record - The edited task, from `getTask`. * @param reason - What the edit was: `'move'`, `'resize-start'`, `'resize-end'`, … * @returns A sentence. */export function describeEdit(record: TaskRecord | undefined, reason: ChangeReason): string { if (!record?.start || !record.endExclusive) return ''; return `${reason}: ${record.name}, ${when.format(record.start)} to ${when.format(record.endExclusive)}.`;}Files
data.ts: six tasks timed to the quarter hour, and one (Handoff) that is not.options.ts:scheduling.granularity,scheduling.alignInputand the view mode.helpers.ts: the choices of the selects, and the sentences of the two status lines.vanilla.tsandreact.tsx: the chart, three buttons and three selects. The React one usesuseGanttanduseGanttStateto show the snap step the chart resolved.
Options
| Option | Value | What it does |
|---|---|---|
scheduling.granularity |
'15min' |
The data step: a duration that divides 24 hours; default '1d' (the first select) |
scheduling.alignInput |
'expand' |
What to do with a value that is not a multiple: 'expand', 'nearest', 'reject' (the second select) |
scheduling.snap |
left out | The step of a drag; left out, the view mode’s step. The third select sets a duration or false |
viewMode |
'quarter-day' |
6-hour columns, which snap to an hour; the buttons switch to 'hour' and 'day' (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan; the default, 'today', is far from these dates |
onTasksChange |
a function (in the example) | One ChangeSet per drag; the example reads the edited task’s record |
onIssues |
a function (React); on('issues') (vanilla) |
Called when the list of issues changes, also at creation |
What to try
- Drag a bar in the quarter-day view. It moves an hour at a time (the first status line says
Snap step: 1h). Switch tohourand it moves by 15 minutes; switch todayand it jumps by whole days: the step belongs to the view mode. - Hold
Altwhile you drag. The bar snaps to the data step instead of the view mode’s: withSnap step: 1din the day view and 15 minutes of granularity, a bar that only moved by days now moves by quarter hours. The step is raised to the granularity if it was finer. - Set Snap to
30min,1doroff. It takes the place of the step of the view mode in every view.offstops snapping to the grid, but the data stays a multiple of the granularity: the dragged date is aligned to it when you let go. - Look at Handoff (13:10 to 16:35) and the first line under the chart. At 15 minutes it is
aligned to 13:00 to 16:45 and the two
misalignedissues say so (a warning: the value was corrected). Change Align input tonearest: 13:15 to 16:30. Toreject: the task has no bar at all (an error), and a suggested value is in the issue. - Change Granularity to
30minor1h. More of the data stops being a multiple of the step, the list of issues grows, and the bars are aligned again.
Notes
- Always a multiple. Every date, duration and lag in a record, a
ChangeSetand an event is a multiple ofscheduling.granularity. A value that is not is reported as amisalignedissue withvalue,suggestedand apath(tasks[5].start), and corrected asalignInputsays. It is the same for tasks you pass later (setTasks) and forupdateTask. 'expand'only ever grows a task: the start goes down and the end goes up, so a task never loses time.'nearest'can shrink it.'reject'leaves the task out and reports an error.- Snap.
scheduling.snapoverrides the view mode’s. It takes a duration ('30min','1d','1w','1mo'),'unit'for the column of the view mode,falsefor none, or an object that sets the step formove,resizeandprogressseparately and amode('grid'or'relative'). A step finer than the granularity is raised to it. Alt. The step is the data step for as long as the key is down: drop on the quarter hour even where the view mode snaps to the day.- Day precision. A task with date-only dates stays on whole days at any granularity
(
scheduling.keepDayPrecision, on by default); these tasks are timed. - View modes.
setViewMode(id)keeps the date at the centre of the chart where it is; the buttons pass{ keepScroll: false }, which scrolls to the first task again, as when the chart opened. - React.
viewModeis a controlled prop anddefaultViewModeits uncontrolled twin: the example uses the second, so that the buttons can callsetViewModeon the instance.schedulingis passed touseGanttfrom state, after the spread ofoptions: in React the props are the truth, so an option changed withsetOptionson the instance would be overwritten by the next render.useGanttStatereads the resolved snap step from the chart’s snapshot; destructuresetContainerandslotsfrom the instance and render them.