A commit history with lane-routed branch and merge lines, branch and tag labels, authors and dates, selection, and listbox keyboard navigation.
Merge branch 'feature/search' into main
maine91c0a7Mergemain
Add fuzzy matching to search results
feature/search5a8e3c9feature/search
Fix off-by-one in pagination
v2.4.1b72d4f1v2.4.1
Index titles and descriptions
c04b11d
Merge branch 'hotfix/login' into main
9f3e2a8Merge
Expire stale login sessions
hotfix/loginae52f84hotfix/login
Release 2.4.0
v2.4.01d7a6b0v2.4.0
Initial commit
03f95de
"use client"
import { GitGraph, type GitCommit } from "@/components/ballmac/git-graph"
const commits: GitCommit[] = [
{ hash: "e91c0a7", parents: ["b72d4f1", "5a8e3c9"], message: "Merge branch 'feature/search' into main", author: "Priya", date: "2026-09-29T16:20:00Z", branches: ["main"] },
{ hash: "5a8e3c9", parents: ["c04b11d"], message: "Add fuzzy matching to search results", author: "Marco", date: "2026-09-29T14:02:00Z", branches: ["feature/search"] },
{ hash: "b72d4f1", parents: ["9f3e2a8"], message: "Fix off-by-one in pagination", author: "Priya", date: "2026-09-28T18:44:00Z", tags: ["v2.4.1"] },
{ hash: "c04b11d", parents: ["9f3e2a8"], message: "Index titles and descriptions", author: "Marco", date: "2026-09-27T11:15:00Z" },
{ hash: "9f3e2a8", parents: ["1d7a6b0", "ae52f84"], message: "Merge branch 'hotfix/login' into main", author: "Sam", date: "2026-09-26T09:30:00Z" },
{ hash: "ae52f84", parents: ["1d7a6b0"], message: "Expire stale login sessions", author: "Sam", date: "2026-09-25T20:05:00Z", branches: ["hotfix/login"] },
{ hash: "1d7a6b0", parents: ["03f95de"], message: "Release 2.4.0", author: "Priya", date: "2026-09-24T12:00:00Z", tags: ["v2.4.0"] },
{ hash: "03f95de", parents: [], message: "Initial commit", author: "Priya", date: "2026-09-10T08:00:00Z" },
]
export default function GitGraphDemo() {
return (
<div className="w-full max-w-2xl">
<GitGraph commits={commits} defaultSelected="e91c0a7" />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/git-graphInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/git-graph.tsx// Ballmac UI: Git Graph. https://ui.ballmac.com/components/git-graph "use client" import * as React from "react" import { GitBranch, GitMerge, Tag } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type GitCommit = { /** Full commit hash (any unique string works). */ hash: string /** Parent hashes. The first is the line this commit continues; more than one makes it a merge. */ parents: string[] /** First line of the message. */ message: string /** Author name. */ author: string /** Commit date as an ISO string. Shown in UTC. */ date: string /** Branch names pointing here. */ branches?: string[] /** Tag names pointing here. */ tags?: string[] } const LANE_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"] const TONES = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"] const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] type Edge = { from: number; to: number; color: number } type Row = { commit: GitCommit col: number color: number /** Lanes drawn straight through this row. */ through: { lane: number; color: number }[] /** Lanes that arrive at this commit from above. */ incoming: Edge[] /** Lines that leave this commit downward. */ outgoing: Edge[] } /** Assigns each commit a lane. Commits must be newest first, parents after children. */ function layout(commits: GitCommit[]) { let lanes: (string | null)[] = [] const laneColor: number[] = [] let nextColor = 0 const rows: Row[] = [] let width = 1 for (const commit of commits) { const before = lanes.slice() let col = before.indexOf(commit.hash) if (col === -1) { col = before.indexOf(null) if (col === -1) col = before.length laneColor[col] = nextColor++ % LANE_COLORS.length } const ending = before.map((h, l) => (h === commit.hash ? l : -1)).filter((l) => l >= 0) const incoming: Edge[] = ending.map((l) => ({ from: l, to: col, color: laneColor[l] ?? 0 })) const after = before.slice() while (after.length <= col) after.push(null) for (const l of ending) after[l] = null const outgoing: Edge[] = [] commit.parents.forEach((parent, index) => { let target = after.indexOf(parent) if (target === -1) { if (index === 0) target = col else { target = after.indexOf(null) if (target === -1) target = after.length laneColor[target] = nextColor++ % LANE_COLORS.length } while (after.length <= target) after.push(null) after[target] = parent if (index === 0) laneColor[target] = laneColor[col] ?? 0 } outgoing.push({ from: col, to: target, color: laneColor[target] ?? 0 }) }) const through = before .map((h, l) => (h !== null && h !== commit.hash && after[l] === h ? { lane: l, color: laneColor[l] ?? 0 } : null)) .filter((x): x is { lane: number; color: number } => x !== null) while (after.length && after[after.length - 1] === null) after.pop() lanes = after width = Math.max(width, before.length, after.length, col + 1) rows.push({ commit, col, color: laneColor[col] ?? 0, through, incoming, outgoing }) } return { rows, width } } const LANE = 18 const ROW = 56 function curve(x1: number, y1: number, x2: number, y2: number) { if (x1 === x2) return `M${x1} ${y1} L${x2} ${y2}` const mid = (y1 + y2) / 2 return `M${x1} ${y1} C${x1} ${mid} ${x2} ${mid} ${x2} ${y2}` } function shortDate(iso: string) { const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}` } function hue(text: string) { let h = 0 for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0 return h % TONES.length } type GitGraphProps = Omit<React.ComponentProps<"div">, "onSelect" | "children"> & { /** Commits, newest first, parents after children. */ commits: GitCommit[] /** Selected commit hash (controlled). */ selected?: string /** Initially selected hash when uncontrolled. */ defaultSelected?: string /** Called with the hash of the chosen commit. */ onSelect?: (hash: string, commit: GitCommit) => void /** Accessible name of the list. */ label?: string /** Hide the author column. */ hideAuthors?: boolean } function GitGraph({ commits, selected: selectedProp, defaultSelected, onSelect, label, hideAuthors = false, className, ...props }: GitGraphProps) { const msg = useMessages() label ??= msg("git-graph.label", "Commit history") const uid = React.useId() const { rows, width } = React.useMemo(() => layout(commits), [commits]) const [internal, setInternal] = React.useState(defaultSelected) const selected = selectedProp ?? internal const [active, setActive] = React.useState(0) const selectedIndex = Math.max(0, rows.findIndex((r) => r.commit.hash === selected)) const optionId = (i: number) => `${uid}-${i}` const gutter = width * LANE + 12 function choose(index: number) { const row = rows[index] if (!row) return setActive(index) if (selectedProp === undefined) setInternal(row.commit.hash) onSelect?.(row.commit.hash, row.commit) } function onKeyDown(event: React.KeyboardEvent) { const last = rows.length - 1 const map: Record<string, number> = { ArrowDown: Math.min(last, active + 1), ArrowUp: Math.max(0, active - 1), Home: 0, End: last, } if (event.key in map) { event.preventDefault() setActive(map[event.key]!) document.getElementById(optionId(map[event.key]!))?.scrollIntoView({ block: "nearest" }) } else if (event.key === "Enter" || event.key === " ") { event.preventDefault() choose(active) } } return ( <div data-slot="git-graph" className={cn("@container w-full overflow-hidden rounded-xl border bg-card text-card-foreground", className)} {...props} > <div role="listbox" aria-label={label} tabIndex={0} aria-activedescendant={optionId(active)} onKeyDown={onKeyDown} onFocus={() => setActive((a) => (a === 0 && selectedIndex ? selectedIndex : a))} className="max-h-[32rem] overflow-auto outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50" > {rows.map((row, i) => { const c = row.commit const isSelected = c.hash === selected const merge = c.parents.length > 1 const x = (lane: number) => lane * LANE + LANE / 2 + 6 const mid = ROW / 2 const refs = [...(c.branches ?? []), ...(c.tags ?? [])] const refChips = refs.map((r) => { const tag = c.tags?.includes(r) && !c.branches?.includes(r) return ( <span key={r} className="inline-flex shrink-0 items-center gap-1 rounded-full border bg-background px-1.5 py-px font-mono text-[11px] text-foreground"> {tag ? <Tag aria-hidden="true" className="size-3" /> : <GitBranch aria-hidden="true" className="size-3" />} {r} </span> ) }) const desc = `${c.message}, by ${c.author}, ${shortDate(c.date)}, ${c.hash.slice(0, 7)}${merge ? ", merge commit" : ""}${c.branches?.length ? `, branch ${c.branches.join(", ")}` : ""}${c.tags?.length ? `, tag ${c.tags.join(", ")}` : ""}` return ( <div key={c.hash} id={optionId(i)} role="option" aria-selected={isSelected} aria-label={desc} data-active={active === i || undefined} onClick={() => choose(i)} className={cn( "relative flex cursor-pointer items-center gap-3 pe-4 transition-colors duration-100 select-none hover:bg-accent/50 motion-reduce:transition-none", isSelected && "bg-accent", active === i && "outline-2 -outline-offset-2 outline-ring/60 [&:not(:focus-within)]:outline-0 group-focus-within:outline-2" )} style={{ height: ROW }} > <svg aria-hidden="true" width={gutter} height={ROW} className="shrink-0 overflow-visible"> {row.through.map((t) => ( <path key={`t${t.lane}`} d={`M${x(t.lane)} 0 L${x(t.lane)} ${ROW}`} stroke={LANE_COLORS[t.color]} strokeWidth="2" fill="none" /> ))} {row.incoming.map((e, k) => ( <path key={`i${k}`} d={curve(x(e.from), 0, x(e.to), mid)} stroke={LANE_COLORS[e.color]} strokeWidth="2" fill="none" /> ))} {row.outgoing.map((e, k) => ( <path key={`o${k}`} d={curve(x(e.from), mid, x(e.to), ROW)} stroke={LANE_COLORS[e.color]} strokeWidth="2" fill="none" /> ))} <circle cx={x(row.col)} cy={mid} r={merge ? 6 : 5} className="fill-card" stroke={LANE_COLORS[row.color]} strokeWidth="2.5" /> {merge && <circle cx={x(row.col)} cy={mid} r="2" fill={LANE_COLORS[row.color]} />} </svg> <div className="min-w-0 flex-1 py-2"> <div className="flex min-w-0 items-center gap-2"> <p className="truncate text-sm font-medium text-foreground">{c.message}</p> <span className="hidden items-center gap-1.5 @md:flex">{refChips}</span> </div> <p className="mt-0.5 flex items-center gap-2 overflow-hidden text-xs whitespace-nowrap text-muted-foreground"> <code className="font-mono text-foreground/80">{c.hash.slice(0, 7)}</code> {merge && ( <span className="inline-flex items-center gap-0.5"> <GitMerge aria-hidden="true" className="size-3" /> {msg("git-graph.merge", "Merge")} </span> )} <span className="flex items-center gap-1.5 @md:hidden">{refChips}</span> </p> </div> {!hideAuthors && ( <span className="hidden shrink-0 items-center gap-2 text-xs text-muted-foreground @md:flex"> <span aria-hidden="true" className={cn("flex size-5 items-center justify-center rounded-full text-[10px] font-semibold text-background uppercase", TONES[hue(c.author)])}> {c.author.charAt(0)} </span> <span className="max-w-24 truncate">{c.author}</span> </span> )} <time dateTime={c.date} className="w-12 shrink-0 text-end text-xs text-muted-foreground tabular-nums"> {shortDate(c.date)} </time> </div> ) })} </div> </div> ) } export { GitGraph, layout as layoutCommits, type GitGraphProps, type GitCommit }Update the import paths to match your project setup.
Usage
import { GitGraph, layoutCommits } from "@/components/ballmac/git-graph"The full example is in the Code tab above.
Examples
Linear history
Bump dependencies
main7c1a90emain
Add dark mode to settings
2b8d3f4
Refactor billing hooks
f60e1c2
Set up the project
a49d7b5
Select a commit with the mouse or the arrow keys and Enter.
"use client"
import * as React from "react"
import { GitGraph, type GitCommit } from "@/components/ballmac/git-graph"
const commits: GitCommit[] = [
{ hash: "7c1a90e", parents: ["2b8d3f4"], message: "Bump dependencies", author: "Sam", date: "2026-09-30T08:10:00Z", branches: ["main"] },
{ hash: "2b8d3f4", parents: ["f60e1c2"], message: "Add dark mode to settings", author: "Marco", date: "2026-09-29T17:25:00Z" },
{ hash: "f60e1c2", parents: ["a49d7b5"], message: "Refactor billing hooks", author: "Priya", date: "2026-09-29T10:02:00Z" },
{ hash: "a49d7b5", parents: [], message: "Set up the project", author: "Priya", date: "2026-09-28T09:00:00Z" },
]
export default function GitGraphLinear() {
const [hash, setHash] = React.useState<string>()
const chosen = commits.find((c) => c.hash === hash)
return (
<div className="grid w-full max-w-xl gap-3">
<GitGraph commits={commits} selected={hash} onSelect={setHash} hideAuthors />
<p className="rounded-lg border border-dashed px-3 py-2 text-[13px] text-muted-foreground" aria-live="polite">
{chosen ? (
<>
Selected <code className="font-mono text-foreground">{chosen.hash}</code> by {chosen.author}
</>
) : (
"Select a commit with the mouse or the arrow keys and Enter."
)}
</p>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
commits*Commits, newest first, parents after children. | GitCommit[] | — |
selectedSelected commit hash (controlled). | string | — |
defaultSelectedInitially selected hash when uncontrolled. | string | — |
onSelectCalled with the hash of the chosen commit. | (hash: string, commit: GitCommit) => void | — |
labelAccessible name of the list. | string | — |
hideAuthorsHide the author column. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| ArrowUp / ArrowDown | Moves the active commit |
| Home / End | First or last commit |
| Enter / Space | Selects the active commit |
| Screen readers | Each option reads message, author, date, short hash, merge and refs |
Use with AI
commits is newest first: [{ hash, parents, message, author, date, branches?, tags? }]. Lanes are computed for you (layoutCommits is exported). selected/onSelect for a detail view. Rows are listbox options. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Git Graph (@ballmac/git-graph) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Repository or release history views
- Explaining a branching strategy in docs
Not for
- Diffs (diff-viewer)
- Huge histories without paging
Registry JSON: https://ui.ballmac.com/r/git-graph.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Diff Viewer
A line-by-line text diff with change counts, line numbers, and accessible change labels.
Code Block
A code panel with filename and language header, line numbers, highlighted lines, a wrap toggle, copy feedback and file tabs. No highlighter bundled; pass Shiki output as children.
Badge
A small pill label in four variants with an optional status dot and success, warning and error tones. Style links with badgeVariants().
API Endpoint
An endpoint reference card: method and path with highlighted parameters, auth, grouped parameter lists, and request and response examples in tabs with copy.