Gantt 1: project timeline
A project timeline: bars placed by real dates with a progress fill, milestones, dependency arrows, a today line, three zoom levels, a sticky task column, a details panel and a table of the same data for screen readers.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Gantt 1. https://ui.ballmac.com/blocks/gantt-1
"use client"
import * as React from "react"
import { useReducedMotion } from "motion/react"
import { CalendarRange, CircleAlert, CircleCheck, CircleDashed, CirclePlay, LocateFixed } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
type Gantt1Status = "done" | "active" | "late" | "planned"
type Gantt1Task = {
id: string
name: string
/** First day, as YYYY-MM-DD. */
start: string
/** Last day, as YYYY-MM-DD, included. A milestone has the same start and end. */
end: string
/** From 0 to 1. Decides whether it is done, and whether it is late. */
progress?: number
/** The section it is listed under. */
group?: string
/** Initials of who owns it. */
owner?: string
/** Ids of tasks that must finish first. Drawn as arrows. */
dependsOn?: string[]
/** A single day that marks a decision or a date, drawn as a diamond. */
milestone?: boolean
}
type Gantt1Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
tasks?: Gantt1Task[]
title?: string
description?: string
/** Today as YYYY-MM-DD. Pass it in so the server and browser agree; it decides what is late and where the today line goes. */
today?: string
/** How much of the timeline fits on screen at first. */
defaultScale?: "days" | "weeks" | "months"
/** The task whose details are open at first. */
defaultSelectedId?: string
/** BCP 47 locale for dates. */
locale?: string
/** Called when a task is chosen. */
onSelect?: (task: Gantt1Task) => void
}
const DAY = 86_400_000
const toT = (iso: string) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10))
const PX = { days: 44, weeks: 18, months: 6 } as const
const ROW = 40
const GROUP = 32
const sample: Gantt1Task[] = [
{ id: "t1", group: "Discovery", name: "User interviews", start: "2026-10-05", end: "2026-10-16", progress: 1, owner: "MA" },
{ id: "t2", group: "Discovery", name: "Legal review", start: "2026-10-05", end: "2026-10-15", progress: 0.6, owner: "JL" },
{ id: "t3", group: "Discovery", name: "Requirements", start: "2026-10-12", end: "2026-10-20", progress: 0.8, owner: "JL", dependsOn: ["t1"] },
{ id: "t4", group: "Design", name: "Wireframes", start: "2026-10-14", end: "2026-10-23", progress: 0.55, owner: "MA", dependsOn: ["t1"] },
{ id: "t5", group: "Design", name: "Visual design", start: "2026-10-26", end: "2026-11-06", progress: 0.1, owner: "MA", dependsOn: ["t4"] },
{ id: "m1", group: "Design", name: "Design review", start: "2026-11-09", end: "2026-11-09", milestone: true, dependsOn: ["t5"] },
{ id: "t6", group: "Build", name: "API and data model", start: "2026-10-19", end: "2026-11-13", progress: 0.2, owner: "PS", dependsOn: ["t3"] },
{ id: "t7", group: "Build", name: "Web app", start: "2026-11-09", end: "2026-12-04", progress: 0, owner: "TN", dependsOn: ["m1", "t6"] },
{ id: "t8", group: "Build", name: "Desktop app", start: "2026-11-16", end: "2026-12-11", progress: 0, owner: "TN", dependsOn: ["t6"] },
{ id: "t9", group: "Launch", name: "Accessibility audit", start: "2026-11-30", end: "2026-12-11", progress: 0, owner: "AK", dependsOn: ["t7"] },
{ id: "t10", group: "Launch", name: "Private beta", start: "2026-12-14", end: "2026-12-23", progress: 0, owner: "SO", dependsOn: ["t9", "t8"] },
{ id: "m2", group: "Launch", name: "Public launch", start: "2027-01-12", end: "2027-01-12", milestone: true, dependsOn: ["t10"] },
]
const statusMeta: Record<Gantt1Status, { label: string; icon: typeof CircleCheck; bar: string; fill: string; chip: string }> = {
done: { label: "Done", icon: CircleCheck, bar: "bg-chart-2/25 border-chart-2", fill: "bg-chart-2", chip: "bg-chart-2/15 border-chart-2/30" },
active: { label: "In progress", icon: CirclePlay, bar: "bg-chart-1/20 border-chart-1", fill: "bg-chart-1", chip: "bg-chart-1/15 border-chart-1/30" },
late: { label: "Late", icon: CircleAlert, bar: "bg-destructive/15 border-destructive", fill: "bg-destructive", chip: "bg-destructive/10 border-destructive/30" },
planned: { label: "Planned", icon: CircleDashed, bar: "bg-muted border-border", fill: "bg-foreground/30", chip: "bg-muted border-border" },
}
function GanttSkeleton() {
return (
<div className="bg-card overflow-hidden rounded-2xl border" aria-hidden="true">
<Skeleton className="h-14 rounded-none" />
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="flex h-10 items-center gap-4 border-t px-3">
<Skeleton className="h-3.5 w-36" />
<Skeleton className="h-5 rounded-md" style={{ width: `${18 + ((i * 7) % 5) * 9}%`, marginInlineStart: `${10 + ((i * 11) % 6) * 8}%` }} />
</div>
))}
</div>
)
}
/**
* A project timeline (Gantt chart): tasks as bars placed by their real dates with a fill for progress, milestones as diamonds, arrows for
* what depends on what, a line for today, three zoom levels, a task column that stays put while you scroll sideways, a details panel, and
* a table of the same data for screen readers. Late work is marked in words as well as color.
*/
function Gantt1({
tasks: tasksProp = sample,
title = "Product launch plan",
description = "From the first interviews to public launch. Select a bar to see its dates and what it waits for.",
today = "2026-10-18",
defaultScale = "weeks",
defaultSelectedId,
locale = "en-US",
onSelect,
state = "ready",
onRetry,
emptyState,
errorState,
className,
...props
}: Gantt1Props) {
const id = React.useId()
const reduce = useReducedMotion()
const scroller = React.useRef<HTMLDivElement>(null)
const [scale, setScale] = React.useState<"days" | "weeks" | "months">(defaultScale)
const [selectedId, setSelectedId] = React.useState<string | undefined>(defaultSelectedId)
const ready = state === "ready" || state === "empty"
const tasks = state === "empty" ? [] : tasksProp
const px = PX[scale]
const dayFmt = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", timeZone: "UTC" })
const monthFmt = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" })
const weekdayFmt = new Intl.DateTimeFormat(locale, { weekday: "narrow", timeZone: "UTC" })
const model = React.useMemo(() => {
if (!tasks.length) return null
const todayT = toT(today)
let min = Math.min(...tasks.map((t) => toT(t.start)), todayT) - 3 * DAY
min -= ((new Date(min).getUTCDay() + 6) % 7) * DAY // start on a Monday
const max = Math.max(...tasks.map((t) => toT(t.end)), todayT) + 7 * DAY
const days = Math.round((max - min) / DAY) + 1
const statusOf = (t: Gantt1Task): Gantt1Status => ((t.progress ?? 0) >= 1 ? "done" : toT(t.end) < todayT ? "late" : toT(t.start) <= todayT ? "active" : "planned")
const rows: ({ kind: "group"; name: string } | { kind: "task"; task: Gantt1Task; status: Gantt1Status })[] = []
const groups = [...new Set(tasks.map((t) => t.group ?? ""))]
for (const g of groups) {
if (g) rows.push({ kind: "group", name: g })
for (const t of tasks.filter((x) => (x.group ?? "") === g)) rows.push({ kind: "task", task: t, status: statusOf(t) })
}
let y = 0
const tops: Record<string, number> = {}
const layout = rows.map((r) => {
const top = y
const h = r.kind === "group" ? GROUP : ROW
y += h
if (r.kind === "task") tops[r.task.id] = top
return { ...r, top, h }
})
return { min, days, rows: layout, tops, height: y, todayT }
}, [tasks, today])
const x = (iso: string) => (model ? ((toT(iso) - model.min) / DAY) * px : 0)
const jump = React.useCallback(
(smooth = true) => {
const el = scroller.current
if (!el || !model) return
const side = el.querySelector<HTMLElement>("[data-gantt-left]")?.offsetWidth ?? 224
el.scrollTo?.({ left: Math.max(0, ((model.todayT - model.min) / DAY) * px - (el.clientWidth - side) / 3), behavior: smooth && !reduce ? "smooth" : "auto" })
},
[model, px, reduce]
)
// open around today, and again after a zoom change
React.useEffect(() => {
jump(false)
}, [jump])
const selected = tasks.find((t) => t.id === selectedId)
const statusOf = (t: Gantt1Task) => (model?.rows.find((r) => r.kind === "task" && r.task.id === t.id) as { status: Gantt1Status } | undefined)?.status ?? "planned"
const lengthOf = (t: Gantt1Task) => Math.round((toT(t.end) - toT(t.start)) / DAY) + 1
const pick = (t: Gantt1Task) => {
setSelectedId(t.id)
onSelect?.(t)
}
function onKeyDown(e: React.KeyboardEvent) {
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) return
const bars = [...(scroller.current?.querySelectorAll<HTMLElement>("[data-bar]") ?? [])]
const at = bars.indexOf(e.target as HTMLElement)
if (at === -1) return
e.preventDefault()
const next = e.key === "Home" ? 0 : e.key === "End" ? bars.length - 1 : Math.max(0, Math.min(bars.length - 1, at + (e.key === "ArrowDown" ? 1 : -1)))
bars[next]?.focus()
}
const months: { label: string; w: number }[] = []
if (model) {
let i = 0
while (i < model.days) {
const d = new Date(model.min + i * DAY)
const left = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)).getTime()
const span = Math.min(model.days - i, Math.round((left - (model.min + i * DAY)) / DAY))
months.push({ label: monthFmt.format(model.min + i * DAY), w: span * px })
i += span
}
}
const chart =
model === null ? (
<div className="bg-card rounded-2xl border px-6 py-16 text-center">
<span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
<CalendarRange className="size-6" />
</span>
<h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No tasks scheduled"}</h2>
<p className="text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty">{emptyState?.description ?? "Add tasks with start and end dates and the plan draws itself."}</p>
{emptyState?.action}
</div>
) : (
<>
<div
ref={scroller}
dir="ltr"
tabIndex={0}
role="region"
aria-label="Project timeline. Scroll sideways for other dates."
onKeyDown={onKeyDown}
className="bg-card focus-visible:ring-ring/50 relative max-h-[42rem] overflow-auto rounded-2xl border [--gantt-left:8.5rem] outline-none focus-visible:ring-[3px] sm:[--gantt-left:14rem]"
>
<div className="relative" style={{ width: `calc(var(--gantt-left) + ${model.days * px}px)` }}>
<div className="bg-card sticky top-0 z-30 flex h-14 border-b">
<div data-gantt-left className="bg-card sticky start-0 z-40 flex shrink-0 items-end border-e px-3 pb-2 text-xs font-medium" style={{ width: "var(--gantt-left)" }}>
<span className="text-muted-foreground">Task</span>
</div>
<div className="relative" style={{ width: model.days * px }}>
<div className="flex h-7 border-b">
{months.map((m, i) => (
<div key={i} className="text-foreground/80 border-e py-1 text-xs font-medium whitespace-nowrap" style={{ width: m.w }}>
{/* sticky, so the name stays readable beside the task column while the month is on screen */}
{m.w > 70 ? (
<span className="sticky inline-block px-2" style={{ left: "var(--gantt-left)" }}>
{m.label}
</span>
) : null}
</div>
))}
</div>
<div className="relative h-7">
{Array.from({ length: model.days }, (_, i) => {
const t = model.min + i * DAY
const dow = new Date(t).getUTCDay()
const monday = dow === 1
if (scale === "days")
return (
<div key={i} className={cn("text-muted-foreground absolute top-0 flex h-7 flex-col items-center justify-center text-[10px] leading-tight tabular-nums", (dow === 0 || dow === 6) && "bg-muted/50")} style={{ left: i * px, width: px }}>
<span className="text-foreground/80 font-medium">{new Date(t).getUTCDate()}</span>
<span>{weekdayFmt.format(t)}</span>
</div>
)
if (scale === "weeks" && monday)
return (
<div key={i} className="text-muted-foreground absolute top-0 flex h-7 items-center border-s ps-1.5 text-[10px] tabular-nums" style={{ left: i * px }}>
{dayFmt.format(t)}
</div>
)
return null
})}
</div>
</div>
</div>
<div className="flex">
<div className="bg-card sticky start-0 z-20 shrink-0 border-e" style={{ width: "var(--gantt-left)" }}>
{model.rows.map((r, i) =>
r.kind === "group" ? (
<div key={`g-${i}`} className="bg-muted/60 text-muted-foreground flex items-center px-3 text-[11px] font-semibold tracking-wide uppercase" style={{ height: r.h }}>
{r.name}
</div>
) : (
<div key={r.task.id} className={cn("flex items-center gap-2 border-t px-3 text-sm", selectedId === r.task.id && "bg-accent/60")} style={{ height: r.h }}>
<span className="min-w-0 flex-1 truncate" dir="auto">
{r.task.name}
</span>
{r.status === "late" ? <CircleAlert aria-hidden="true" className="text-destructive size-3.5 shrink-0" /> : null}
{r.task.owner ? (
<span aria-hidden="true" className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold">
{r.task.owner}
</span>
) : null}
</div>
)
)}
</div>
<div className="relative" style={{ width: model.days * px, height: model.height }}>
{Array.from({ length: model.days }, (_, i) => {
const t = model.min + i * DAY
const d = new Date(t)
const dow = d.getUTCDay()
if (scale === "days" && (dow === 0 || dow === 6)) return <div key={`w-${i}`} aria-hidden="true" className="bg-muted/40 absolute inset-y-0" style={{ left: i * px, width: px }} />
if (scale !== "days" && (scale === "weeks" ? dow === 1 : d.getUTCDate() === 1)) return <div key={`l-${i}`} aria-hidden="true" className="border-border/70 absolute inset-y-0 border-s" style={{ left: i * px }} />
return null
})}
{model.rows.map((r) => (r.kind === "group" ? <div key={`gb-${r.top}`} aria-hidden="true" className="bg-muted/60 absolute inset-x-0" style={{ top: r.top, height: r.h }} /> : <div key={`rb-${r.task.id}`} aria-hidden="true" className="absolute inset-x-0 border-t" style={{ top: r.top }} />))}
<svg aria-hidden="true" className="pointer-events-none absolute inset-0 z-[1] overflow-visible" width={model.days * px} height={model.height}>
{model.rows.flatMap((r) =>
r.kind === "task"
? (r.task.dependsOn ?? []).flatMap((dep) => {
const from = tasks.find((t) => t.id === dep)
if (!from || model.tops[dep] === undefined) return []
const x1 = x(from.end) + px
const y1 = model.tops[dep]! + ROW / 2
const x2 = x(r.task.start)
const y2 = model.tops[r.task.id]! + ROW / 2
return [
<path
key={`${dep}-${r.task.id}`}
d={`M${x1} ${y1} H${x1 + 8} V${y2} H${Math.max(x2 - 5, x1 + 8)}`}
className={cn("fill-none stroke-[1.25]", selectedId === r.task.id || selectedId === dep ? "stroke-foreground" : "stroke-muted-foreground/50")}
/>,
<path key={`a-${dep}-${r.task.id}`} d={`M${x2 - 6} ${y2 - 3.5} L${x2 - 1} ${y2} L${x2 - 6} ${y2 + 3.5} Z`} className={selectedId === r.task.id || selectedId === dep ? "fill-foreground" : "fill-muted-foreground/60"} />,
]
})
: []
)}
</svg>
<div aria-hidden="true" className="bg-foreground absolute inset-y-0 z-[2] w-px" style={{ left: ((model.todayT - model.min) / DAY) * px + px / 2 }} />
<div aria-hidden="true" className="bg-foreground text-background absolute top-1 z-[4] -translate-x-1/2 rounded px-1.5 py-px text-[10px] font-semibold" style={{ left: ((model.todayT - model.min) / DAY) * px + px / 2 }}>
Today
</div>
{model.rows.map((r) => {
if (r.kind !== "task") return null
const t = r.task
const S = statusMeta[r.status]
const pct = Math.round((t.progress ?? 0) * 100)
const label = `${t.name}: ${dayFmt.format(toT(t.start))}${t.milestone ? "" : ` to ${dayFmt.format(toT(t.end))}`}, ${t.milestone ? "milestone" : `${pct}% done`}, ${S.label}`
if (t.milestone)
return (
<button
key={t.id}
type="button"
data-bar
aria-label={label}
aria-pressed={selectedId === t.id}
onClick={() => pick(t)}
className="focus-visible:ring-ring/60 group/bar absolute z-[3] flex items-center gap-2 outline-none"
style={{ top: r.top + ROW / 2 - 10, left: x(t.start) + px / 2 - 10, height: 20 }}
>
<span aria-hidden="true" className={cn("border-background size-5 rotate-45 rounded-[4px] border-2 transition-transform duration-150 group-hover/bar:scale-110 group-focus-visible/bar:ring-[3px] group-focus-visible/bar:ring-ring/60 motion-reduce:transition-none", S.fill, selectedId === t.id && "ring-foreground ring-2")} />
<span aria-hidden="true" className="text-foreground bg-card/80 absolute start-7 rounded px-1 text-xs font-medium whitespace-nowrap">
{t.name}
</span>
</button>
)
const w = (lengthOf(t)) * px
return (
<button
key={t.id}
type="button"
data-bar
aria-label={label}
aria-pressed={selectedId === t.id}
onClick={() => pick(t)}
className={cn("focus-visible:ring-ring/60 group/bar absolute z-[3] overflow-hidden rounded-md border text-start outline-none transition-[box-shadow,transform] duration-150 hover:shadow-md focus-visible:ring-[3px] motion-reduce:transition-none", S.bar, selectedId === t.id && "ring-foreground ring-2")}
style={{ top: r.top + 8, left: x(t.start), width: w, height: ROW - 16 }}
>
<span aria-hidden="true" className={cn("absolute inset-y-0 start-0 opacity-90", S.fill)} style={{ width: `${pct}%` }} />
{w > 74 ? (
<span aria-hidden="true" className="relative flex h-full items-center ps-2 text-[11px] font-semibold whitespace-nowrap mix-blend-normal">
<span className="bg-card/85 text-foreground rounded px-1 tabular-nums">{pct}%</span>
</span>
) : null}
</button>
)
})}
</div>
</div>
</div>
</div>
<div className="sr-only">
<table>
<caption>Project schedule</caption>
<thead>
<tr>
<th scope="col">Task</th>
<th scope="col">Section</th>
<th scope="col">Start</th>
<th scope="col">End</th>
<th scope="col">Progress</th>
<th scope="col">Status</th>
<th scope="col">Waits for</th>
</tr>
</thead>
<tbody>
{tasks.map((t) => (
<tr key={t.id}>
<th scope="row">{t.name}</th>
<td>{t.group ?? ""}</td>
<td>{dayFmt.format(toT(t.start))}</td>
<td>{dayFmt.format(toT(t.end))}</td>
<td>{t.milestone ? "Milestone" : `${Math.round((t.progress ?? 0) * 100)}%`}</td>
<td>{statusMeta[statusOf(t)].label}</td>
<td>{(t.dependsOn ?? []).map((d) => tasks.find((x) => x.id === d)?.name).filter(Boolean).join(", ")}</td>
</tr>
))}
</tbody>
</table>
</div>
<div aria-live="polite" className="bg-card mt-4 rounded-2xl border p-5">
{selected ? (
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="text-base font-semibold tracking-tight">{selected.name}</span>
<span className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium", statusMeta[statusOf(selected)].chip)}>
{React.createElement(statusMeta[statusOf(selected)].icon, { "aria-hidden": true, className: "size-3.5" })}
{statusMeta[statusOf(selected)].label}
</span>
</p>
<p className="text-muted-foreground mt-1.5 text-sm tabular-nums">
{selected.milestone ? dayFmt.format(toT(selected.start)) : `${dayFmt.format(toT(selected.start))} to ${dayFmt.format(toT(selected.end))} · ${lengthOf(selected)} ${lengthOf(selected) === 1 ? "day" : "days"}`}
{selected.owner ? ` · ${selected.owner}` : ""}
</p>
{selected.dependsOn?.length ? (
<p className="mt-2 text-sm text-pretty">
<span className="text-muted-foreground">Waits for </span>
{selected.dependsOn.map((d) => tasks.find((t) => t.id === d)?.name).filter(Boolean).join(", ")}
</p>
) : null}
</div>
{!selected.milestone ? (
<div className="w-full sm:w-56">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium tabular-nums">{Math.round((selected.progress ?? 0) * 100)}%</span>
</div>
<div role="progressbar" aria-label={`${selected.name} progress`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round((selected.progress ?? 0) * 100)} className="bg-muted mt-1.5 h-2 overflow-hidden rounded-full">
<div className={cn("h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none", statusMeta[statusOf(selected)].fill)} style={{ width: `${Math.round((selected.progress ?? 0) * 100)}%` }} />
</div>
</div>
) : null}
</div>
) : (
<p className="text-muted-foreground text-sm">Select a bar to see its dates, who owns it and what it waits for.</p>
)}
</div>
</>
)
return (
<section data-slot="gantt-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-6xl px-4 py-8 sm:px-6", className)} {...frameProps(props)}>
<header className="mb-6 flex flex-wrap items-end gap-x-6 gap-y-4">
<div className="min-w-0 flex-1 basis-80">
<h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em]">
{title}
</h1>
<p className="text-muted-foreground mt-2 max-w-xl text-sm text-pretty">{description}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<SegmentedControl value={scale} onValueChange={(v) => setScale(v as typeof scale)} aria-label="Zoom" size="sm" disabled={!ready || !model}>
<SegmentedControlItem value="days">Days</SegmentedControlItem>
<SegmentedControlItem value="weeks">Weeks</SegmentedControlItem>
<SegmentedControlItem value="months">Months</SegmentedControlItem>
</SegmentedControl>
<Button variant="outline" size="sm" disabled={!ready || !model} onClick={() => jump()}>
<LocateFixed aria-hidden="true" />
Today
</Button>
</div>
</header>
{ready && model ? (
<ul aria-label="Legend" className="text-muted-foreground mb-4 flex flex-wrap gap-x-5 gap-y-1.5 text-xs">
{(Object.keys(statusMeta) as Gantt1Status[]).map((k) => {
const S = statusMeta[k]
return (
<li key={k} className="flex items-center gap-1.5">
<S.icon aria-hidden="true" className="size-3.5" />
{S.label}
</li>
)
})}
<li className="flex items-center gap-1.5">
<span aria-hidden="true" className="bg-foreground size-2.5 rotate-45 rounded-[2px]" />
Milestone
</li>
</ul>
) : null}
<StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the timeline" loading={<GanttSkeleton />} error={{ title: errorState?.title ?? "The timeline could not be loaded", description: errorState?.description ?? "Nothing was changed. Check your connection and try again.", ...errorState }}>
{chart}
</StateView>
</section>
)
}
export { Gantt1, type Gantt1Props, type Gantt1Task, type Gantt1Status }Installation
$ pnpm dlx shadcn@latest add @ballmac/gantt-1Install the dependencies.
$ pnpm add lucide-react motionAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/segmented-control @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/gantt-1/gantt-1.tsx// Ballmac UI: Gantt 1. https://ui.ballmac.com/blocks/gantt-1 "use client" import * as React from "react" import { useReducedMotion } from "motion/react" import { CalendarRange, CircleAlert, CircleCheck, CircleDashed, CirclePlay, LocateFixed } from "lucide-react" import { Button } from "@/components/ballmac/button" import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" type Gantt1Status = "done" | "active" | "late" | "planned" type Gantt1Task = { id: string name: string /** First day, as YYYY-MM-DD. */ start: string /** Last day, as YYYY-MM-DD, included. A milestone has the same start and end. */ end: string /** From 0 to 1. Decides whether it is done, and whether it is late. */ progress?: number /** The section it is listed under. */ group?: string /** Initials of who owns it. */ owner?: string /** Ids of tasks that must finish first. Drawn as arrows. */ dependsOn?: string[] /** A single day that marks a decision or a date, drawn as a diamond. */ milestone?: boolean } type Gantt1Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { tasks?: Gantt1Task[] title?: string description?: string /** Today as YYYY-MM-DD. Pass it in so the server and browser agree; it decides what is late and where the today line goes. */ today?: string /** How much of the timeline fits on screen at first. */ defaultScale?: "days" | "weeks" | "months" /** The task whose details are open at first. */ defaultSelectedId?: string /** BCP 47 locale for dates. */ locale?: string /** Called when a task is chosen. */ onSelect?: (task: Gantt1Task) => void } const DAY = 86_400_000 const toT = (iso: string) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)) const PX = { days: 44, weeks: 18, months: 6 } as const const ROW = 40 const GROUP = 32 const sample: Gantt1Task[] = [ { id: "t1", group: "Discovery", name: "User interviews", start: "2026-10-05", end: "2026-10-16", progress: 1, owner: "MA" }, { id: "t2", group: "Discovery", name: "Legal review", start: "2026-10-05", end: "2026-10-15", progress: 0.6, owner: "JL" }, { id: "t3", group: "Discovery", name: "Requirements", start: "2026-10-12", end: "2026-10-20", progress: 0.8, owner: "JL", dependsOn: ["t1"] }, { id: "t4", group: "Design", name: "Wireframes", start: "2026-10-14", end: "2026-10-23", progress: 0.55, owner: "MA", dependsOn: ["t1"] }, { id: "t5", group: "Design", name: "Visual design", start: "2026-10-26", end: "2026-11-06", progress: 0.1, owner: "MA", dependsOn: ["t4"] }, { id: "m1", group: "Design", name: "Design review", start: "2026-11-09", end: "2026-11-09", milestone: true, dependsOn: ["t5"] }, { id: "t6", group: "Build", name: "API and data model", start: "2026-10-19", end: "2026-11-13", progress: 0.2, owner: "PS", dependsOn: ["t3"] }, { id: "t7", group: "Build", name: "Web app", start: "2026-11-09", end: "2026-12-04", progress: 0, owner: "TN", dependsOn: ["m1", "t6"] }, { id: "t8", group: "Build", name: "Desktop app", start: "2026-11-16", end: "2026-12-11", progress: 0, owner: "TN", dependsOn: ["t6"] }, { id: "t9", group: "Launch", name: "Accessibility audit", start: "2026-11-30", end: "2026-12-11", progress: 0, owner: "AK", dependsOn: ["t7"] }, { id: "t10", group: "Launch", name: "Private beta", start: "2026-12-14", end: "2026-12-23", progress: 0, owner: "SO", dependsOn: ["t9", "t8"] }, { id: "m2", group: "Launch", name: "Public launch", start: "2027-01-12", end: "2027-01-12", milestone: true, dependsOn: ["t10"] }, ] const statusMeta: Record<Gantt1Status, { label: string; icon: typeof CircleCheck; bar: string; fill: string; chip: string }> = { done: { label: "Done", icon: CircleCheck, bar: "bg-chart-2/25 border-chart-2", fill: "bg-chart-2", chip: "bg-chart-2/15 border-chart-2/30" }, active: { label: "In progress", icon: CirclePlay, bar: "bg-chart-1/20 border-chart-1", fill: "bg-chart-1", chip: "bg-chart-1/15 border-chart-1/30" }, late: { label: "Late", icon: CircleAlert, bar: "bg-destructive/15 border-destructive", fill: "bg-destructive", chip: "bg-destructive/10 border-destructive/30" }, planned: { label: "Planned", icon: CircleDashed, bar: "bg-muted border-border", fill: "bg-foreground/30", chip: "bg-muted border-border" }, } function GanttSkeleton() { return ( <div className="bg-card overflow-hidden rounded-2xl border" aria-hidden="true"> <Skeleton className="h-14 rounded-none" /> {[0, 1, 2, 3, 4, 5, 6].map((i) => ( <div key={i} className="flex h-10 items-center gap-4 border-t px-3"> <Skeleton className="h-3.5 w-36" /> <Skeleton className="h-5 rounded-md" style={{ width: `${18 + ((i * 7) % 5) * 9}%`, marginInlineStart: `${10 + ((i * 11) % 6) * 8}%` }} /> </div> ))} </div> ) } /** * A project timeline (Gantt chart): tasks as bars placed by their real dates with a fill for progress, milestones as diamonds, arrows for * what depends on what, a line for today, three zoom levels, a task column that stays put while you scroll sideways, a details panel, and * a table of the same data for screen readers. Late work is marked in words as well as color. */ function Gantt1({ tasks: tasksProp = sample, title = "Product launch plan", description = "From the first interviews to public launch. Select a bar to see its dates and what it waits for.", today = "2026-10-18", defaultScale = "weeks", defaultSelectedId, locale = "en-US", onSelect, state = "ready", onRetry, emptyState, errorState, className, ...props }: Gantt1Props) { const id = React.useId() const reduce = useReducedMotion() const scroller = React.useRef<HTMLDivElement>(null) const [scale, setScale] = React.useState<"days" | "weeks" | "months">(defaultScale) const [selectedId, setSelectedId] = React.useState<string | undefined>(defaultSelectedId) const ready = state === "ready" || state === "empty" const tasks = state === "empty" ? [] : tasksProp const px = PX[scale] const dayFmt = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", timeZone: "UTC" }) const monthFmt = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }) const weekdayFmt = new Intl.DateTimeFormat(locale, { weekday: "narrow", timeZone: "UTC" }) const model = React.useMemo(() => { if (!tasks.length) return null const todayT = toT(today) let min = Math.min(...tasks.map((t) => toT(t.start)), todayT) - 3 * DAY min -= ((new Date(min).getUTCDay() + 6) % 7) * DAY // start on a Monday const max = Math.max(...tasks.map((t) => toT(t.end)), todayT) + 7 * DAY const days = Math.round((max - min) / DAY) + 1 const statusOf = (t: Gantt1Task): Gantt1Status => ((t.progress ?? 0) >= 1 ? "done" : toT(t.end) < todayT ? "late" : toT(t.start) <= todayT ? "active" : "planned") const rows: ({ kind: "group"; name: string } | { kind: "task"; task: Gantt1Task; status: Gantt1Status })[] = [] const groups = [...new Set(tasks.map((t) => t.group ?? ""))] for (const g of groups) { if (g) rows.push({ kind: "group", name: g }) for (const t of tasks.filter((x) => (x.group ?? "") === g)) rows.push({ kind: "task", task: t, status: statusOf(t) }) } let y = 0 const tops: Record<string, number> = {} const layout = rows.map((r) => { const top = y const h = r.kind === "group" ? GROUP : ROW y += h if (r.kind === "task") tops[r.task.id] = top return { ...r, top, h } }) return { min, days, rows: layout, tops, height: y, todayT } }, [tasks, today]) const x = (iso: string) => (model ? ((toT(iso) - model.min) / DAY) * px : 0) const jump = React.useCallback( (smooth = true) => { const el = scroller.current if (!el || !model) return const side = el.querySelector<HTMLElement>("[data-gantt-left]")?.offsetWidth ?? 224 el.scrollTo?.({ left: Math.max(0, ((model.todayT - model.min) / DAY) * px - (el.clientWidth - side) / 3), behavior: smooth && !reduce ? "smooth" : "auto" }) }, [model, px, reduce] ) // open around today, and again after a zoom change React.useEffect(() => { jump(false) }, [jump]) const selected = tasks.find((t) => t.id === selectedId) const statusOf = (t: Gantt1Task) => (model?.rows.find((r) => r.kind === "task" && r.task.id === t.id) as { status: Gantt1Status } | undefined)?.status ?? "planned" const lengthOf = (t: Gantt1Task) => Math.round((toT(t.end) - toT(t.start)) / DAY) + 1 const pick = (t: Gantt1Task) => { setSelectedId(t.id) onSelect?.(t) } function onKeyDown(e: React.KeyboardEvent) { if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) return const bars = [...(scroller.current?.querySelectorAll<HTMLElement>("[data-bar]") ?? [])] const at = bars.indexOf(e.target as HTMLElement) if (at === -1) return e.preventDefault() const next = e.key === "Home" ? 0 : e.key === "End" ? bars.length - 1 : Math.max(0, Math.min(bars.length - 1, at + (e.key === "ArrowDown" ? 1 : -1))) bars[next]?.focus() } const months: { label: string; w: number }[] = [] if (model) { let i = 0 while (i < model.days) { const d = new Date(model.min + i * DAY) const left = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)).getTime() const span = Math.min(model.days - i, Math.round((left - (model.min + i * DAY)) / DAY)) months.push({ label: monthFmt.format(model.min + i * DAY), w: span * px }) i += span } } const chart = model === null ? ( <div className="bg-card rounded-2xl border px-6 py-16 text-center"> <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full"> <CalendarRange className="size-6" /> </span> <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No tasks scheduled"}</h2> <p className="text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty">{emptyState?.description ?? "Add tasks with start and end dates and the plan draws itself."}</p> {emptyState?.action} </div> ) : ( <> <div ref={scroller} dir="ltr" tabIndex={0} role="region" aria-label="Project timeline. Scroll sideways for other dates." onKeyDown={onKeyDown} className="bg-card focus-visible:ring-ring/50 relative max-h-[42rem] overflow-auto rounded-2xl border [--gantt-left:8.5rem] outline-none focus-visible:ring-[3px] sm:[--gantt-left:14rem]" > <div className="relative" style={{ width: `calc(var(--gantt-left) + ${model.days * px}px)` }}> <div className="bg-card sticky top-0 z-30 flex h-14 border-b"> <div data-gantt-left className="bg-card sticky start-0 z-40 flex shrink-0 items-end border-e px-3 pb-2 text-xs font-medium" style={{ width: "var(--gantt-left)" }}> <span className="text-muted-foreground">Task</span> </div> <div className="relative" style={{ width: model.days * px }}> <div className="flex h-7 border-b"> {months.map((m, i) => ( <div key={i} className="text-foreground/80 border-e py-1 text-xs font-medium whitespace-nowrap" style={{ width: m.w }}> {/* sticky, so the name stays readable beside the task column while the month is on screen */} {m.w > 70 ? ( <span className="sticky inline-block px-2" style={{ left: "var(--gantt-left)" }}> {m.label} </span> ) : null} </div> ))} </div> <div className="relative h-7"> {Array.from({ length: model.days }, (_, i) => { const t = model.min + i * DAY const dow = new Date(t).getUTCDay() const monday = dow === 1 if (scale === "days") return ( <div key={i} className={cn("text-muted-foreground absolute top-0 flex h-7 flex-col items-center justify-center text-[10px] leading-tight tabular-nums", (dow === 0 || dow === 6) && "bg-muted/50")} style={{ left: i * px, width: px }}> <span className="text-foreground/80 font-medium">{new Date(t).getUTCDate()}</span> <span>{weekdayFmt.format(t)}</span> </div> ) if (scale === "weeks" && monday) return ( <div key={i} className="text-muted-foreground absolute top-0 flex h-7 items-center border-s ps-1.5 text-[10px] tabular-nums" style={{ left: i * px }}> {dayFmt.format(t)} </div> ) return null })} </div> </div> </div> <div className="flex"> <div className="bg-card sticky start-0 z-20 shrink-0 border-e" style={{ width: "var(--gantt-left)" }}> {model.rows.map((r, i) => r.kind === "group" ? ( <div key={`g-${i}`} className="bg-muted/60 text-muted-foreground flex items-center px-3 text-[11px] font-semibold tracking-wide uppercase" style={{ height: r.h }}> {r.name} </div> ) : ( <div key={r.task.id} className={cn("flex items-center gap-2 border-t px-3 text-sm", selectedId === r.task.id && "bg-accent/60")} style={{ height: r.h }}> <span className="min-w-0 flex-1 truncate" dir="auto"> {r.task.name} </span> {r.status === "late" ? <CircleAlert aria-hidden="true" className="text-destructive size-3.5 shrink-0" /> : null} {r.task.owner ? ( <span aria-hidden="true" className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"> {r.task.owner} </span> ) : null} </div> ) )} </div> <div className="relative" style={{ width: model.days * px, height: model.height }}> {Array.from({ length: model.days }, (_, i) => { const t = model.min + i * DAY const d = new Date(t) const dow = d.getUTCDay() if (scale === "days" && (dow === 0 || dow === 6)) return <div key={`w-${i}`} aria-hidden="true" className="bg-muted/40 absolute inset-y-0" style={{ left: i * px, width: px }} /> if (scale !== "days" && (scale === "weeks" ? dow === 1 : d.getUTCDate() === 1)) return <div key={`l-${i}`} aria-hidden="true" className="border-border/70 absolute inset-y-0 border-s" style={{ left: i * px }} /> return null })} {model.rows.map((r) => (r.kind === "group" ? <div key={`gb-${r.top}`} aria-hidden="true" className="bg-muted/60 absolute inset-x-0" style={{ top: r.top, height: r.h }} /> : <div key={`rb-${r.task.id}`} aria-hidden="true" className="absolute inset-x-0 border-t" style={{ top: r.top }} />))} <svg aria-hidden="true" className="pointer-events-none absolute inset-0 z-[1] overflow-visible" width={model.days * px} height={model.height}> {model.rows.flatMap((r) => r.kind === "task" ? (r.task.dependsOn ?? []).flatMap((dep) => { const from = tasks.find((t) => t.id === dep) if (!from || model.tops[dep] === undefined) return [] const x1 = x(from.end) + px const y1 = model.tops[dep]! + ROW / 2 const x2 = x(r.task.start) const y2 = model.tops[r.task.id]! + ROW / 2 return [ <path key={`${dep}-${r.task.id}`} d={`M${x1} ${y1} H${x1 + 8} V${y2} H${Math.max(x2 - 5, x1 + 8)}`} className={cn("fill-none stroke-[1.25]", selectedId === r.task.id || selectedId === dep ? "stroke-foreground" : "stroke-muted-foreground/50")} />, <path key={`a-${dep}-${r.task.id}`} d={`M${x2 - 6} ${y2 - 3.5} L${x2 - 1} ${y2} L${x2 - 6} ${y2 + 3.5} Z`} className={selectedId === r.task.id || selectedId === dep ? "fill-foreground" : "fill-muted-foreground/60"} />, ] }) : [] )} </svg> <div aria-hidden="true" className="bg-foreground absolute inset-y-0 z-[2] w-px" style={{ left: ((model.todayT - model.min) / DAY) * px + px / 2 }} /> <div aria-hidden="true" className="bg-foreground text-background absolute top-1 z-[4] -translate-x-1/2 rounded px-1.5 py-px text-[10px] font-semibold" style={{ left: ((model.todayT - model.min) / DAY) * px + px / 2 }}> Today </div> {model.rows.map((r) => { if (r.kind !== "task") return null const t = r.task const S = statusMeta[r.status] const pct = Math.round((t.progress ?? 0) * 100) const label = `${t.name}: ${dayFmt.format(toT(t.start))}${t.milestone ? "" : ` to ${dayFmt.format(toT(t.end))}`}, ${t.milestone ? "milestone" : `${pct}% done`}, ${S.label}` if (t.milestone) return ( <button key={t.id} type="button" data-bar aria-label={label} aria-pressed={selectedId === t.id} onClick={() => pick(t)} className="focus-visible:ring-ring/60 group/bar absolute z-[3] flex items-center gap-2 outline-none" style={{ top: r.top + ROW / 2 - 10, left: x(t.start) + px / 2 - 10, height: 20 }} > <span aria-hidden="true" className={cn("border-background size-5 rotate-45 rounded-[4px] border-2 transition-transform duration-150 group-hover/bar:scale-110 group-focus-visible/bar:ring-[3px] group-focus-visible/bar:ring-ring/60 motion-reduce:transition-none", S.fill, selectedId === t.id && "ring-foreground ring-2")} /> <span aria-hidden="true" className="text-foreground bg-card/80 absolute start-7 rounded px-1 text-xs font-medium whitespace-nowrap"> {t.name} </span> </button> ) const w = (lengthOf(t)) * px return ( <button key={t.id} type="button" data-bar aria-label={label} aria-pressed={selectedId === t.id} onClick={() => pick(t)} className={cn("focus-visible:ring-ring/60 group/bar absolute z-[3] overflow-hidden rounded-md border text-start outline-none transition-[box-shadow,transform] duration-150 hover:shadow-md focus-visible:ring-[3px] motion-reduce:transition-none", S.bar, selectedId === t.id && "ring-foreground ring-2")} style={{ top: r.top + 8, left: x(t.start), width: w, height: ROW - 16 }} > <span aria-hidden="true" className={cn("absolute inset-y-0 start-0 opacity-90", S.fill)} style={{ width: `${pct}%` }} /> {w > 74 ? ( <span aria-hidden="true" className="relative flex h-full items-center ps-2 text-[11px] font-semibold whitespace-nowrap mix-blend-normal"> <span className="bg-card/85 text-foreground rounded px-1 tabular-nums">{pct}%</span> </span> ) : null} </button> ) })} </div> </div> </div> </div> <div className="sr-only"> <table> <caption>Project schedule</caption> <thead> <tr> <th scope="col">Task</th> <th scope="col">Section</th> <th scope="col">Start</th> <th scope="col">End</th> <th scope="col">Progress</th> <th scope="col">Status</th> <th scope="col">Waits for</th> </tr> </thead> <tbody> {tasks.map((t) => ( <tr key={t.id}> <th scope="row">{t.name}</th> <td>{t.group ?? ""}</td> <td>{dayFmt.format(toT(t.start))}</td> <td>{dayFmt.format(toT(t.end))}</td> <td>{t.milestone ? "Milestone" : `${Math.round((t.progress ?? 0) * 100)}%`}</td> <td>{statusMeta[statusOf(t)].label}</td> <td>{(t.dependsOn ?? []).map((d) => tasks.find((x) => x.id === d)?.name).filter(Boolean).join(", ")}</td> </tr> ))} </tbody> </table> </div> <div aria-live="polite" className="bg-card mt-4 rounded-2xl border p-5"> {selected ? ( <div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"> <div className="min-w-0"> <p className="flex flex-wrap items-center gap-x-3 gap-y-1.5"> <span className="text-base font-semibold tracking-tight">{selected.name}</span> <span className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium", statusMeta[statusOf(selected)].chip)}> {React.createElement(statusMeta[statusOf(selected)].icon, { "aria-hidden": true, className: "size-3.5" })} {statusMeta[statusOf(selected)].label} </span> </p> <p className="text-muted-foreground mt-1.5 text-sm tabular-nums"> {selected.milestone ? dayFmt.format(toT(selected.start)) : `${dayFmt.format(toT(selected.start))} to ${dayFmt.format(toT(selected.end))} · ${lengthOf(selected)} ${lengthOf(selected) === 1 ? "day" : "days"}`} {selected.owner ? ` · ${selected.owner}` : ""} </p> {selected.dependsOn?.length ? ( <p className="mt-2 text-sm text-pretty"> <span className="text-muted-foreground">Waits for </span> {selected.dependsOn.map((d) => tasks.find((t) => t.id === d)?.name).filter(Boolean).join(", ")} </p> ) : null} </div> {!selected.milestone ? ( <div className="w-full sm:w-56"> <div className="flex justify-between text-xs"> <span className="text-muted-foreground">Progress</span> <span className="font-medium tabular-nums">{Math.round((selected.progress ?? 0) * 100)}%</span> </div> <div role="progressbar" aria-label={`${selected.name} progress`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round((selected.progress ?? 0) * 100)} className="bg-muted mt-1.5 h-2 overflow-hidden rounded-full"> <div className={cn("h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none", statusMeta[statusOf(selected)].fill)} style={{ width: `${Math.round((selected.progress ?? 0) * 100)}%` }} /> </div> </div> ) : null} </div> ) : ( <p className="text-muted-foreground text-sm">Select a bar to see its dates, who owns it and what it waits for.</p> )} </div> </> ) return ( <section data-slot="gantt-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-6xl px-4 py-8 sm:px-6", className)} {...frameProps(props)}> <header className="mb-6 flex flex-wrap items-end gap-x-6 gap-y-4"> <div className="min-w-0 flex-1 basis-80"> <h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em]"> {title} </h1> <p className="text-muted-foreground mt-2 max-w-xl text-sm text-pretty">{description}</p> </div> <div className="flex flex-wrap items-center gap-2"> <SegmentedControl value={scale} onValueChange={(v) => setScale(v as typeof scale)} aria-label="Zoom" size="sm" disabled={!ready || !model}> <SegmentedControlItem value="days">Days</SegmentedControlItem> <SegmentedControlItem value="weeks">Weeks</SegmentedControlItem> <SegmentedControlItem value="months">Months</SegmentedControlItem> </SegmentedControl> <Button variant="outline" size="sm" disabled={!ready || !model} onClick={() => jump()}> <LocateFixed aria-hidden="true" /> Today </Button> </div> </header> {ready && model ? ( <ul aria-label="Legend" className="text-muted-foreground mb-4 flex flex-wrap gap-x-5 gap-y-1.5 text-xs"> {(Object.keys(statusMeta) as Gantt1Status[]).map((k) => { const S = statusMeta[k] return ( <li key={k} className="flex items-center gap-1.5"> <S.icon aria-hidden="true" className="size-3.5" /> {S.label} </li> ) })} <li className="flex items-center gap-1.5"> <span aria-hidden="true" className="bg-foreground size-2.5 rotate-45 rounded-[2px]" /> Milestone </li> </ul> ) : null} <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the timeline" loading={<GanttSkeleton />} error={{ title: errorState?.title ?? "The timeline could not be loaded", description: errorState?.description ?? "Nothing was changed. Check your connection and try again.", ...errorState }}> {chart} </StateView> </section> ) } export { Gantt1, type Gantt1Props, type Gantt1Task, type Gantt1Status }Update the import paths to match your project setup.
Usage
"use client"
import { Gantt1 } from "@/components/ballmac/blocks/gantt-1/gantt-1"
export default function Gantt1Demo() {
return <Gantt1 defaultSelectedId="t4" />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
A skeleton chart under the real heading, announced as loading; zoom and Today are disabled.
state="loading"No tasks yet
Says the plan draws itself once tasks have dates; emptyState sets the copy and can add a button.
state="empty" (or tasks={[]})Could not load
An alert saying nothing was changed, with Try again (onRetry).
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Not applicable: Right to left
Not mirrored by design: A timeline runs left to right in every language, like a chart: dates only make sense in one direction.
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
46 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by gantt-1.test.tsx.
For information: Footprint
7.5 KB of source (gzip) in 1 file. Installs lucide-react, motion.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
Use with AI
A Gantt chart. <Gantt1 tasks={[{ id, name, start: '2026-10-05', end: '2026-10-16', progress: 0..1, group, owner, dependsOn: ['id'], milestone }]} today="2026-10-18" defaultScale='days' | 'weeks' | 'months' defaultSelectedId locale onSelect />. Status is worked out for you: done at progress 1, late when the end date has passed, in progress when it has started, planned otherwise. Pass today yourself (an ISO date); the block never reads the clock. A milestone has the same start and end. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Gantt 1: project timeline (@ballmac/gantt-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing a project plan with dates, progress and dependencies
- A read-only schedule overview in a project tool or a client report
Not for
- A board of cards by status (use kanban-1)
- Booking rooms or people by the hour (use calendar-pro-1)
- A simple list of dated items (use timeline or tasks-1)
Registry JSON: https://ui.ballmac.com/r/gantt-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.