Commit Graph
A git history lane graph: branches as lanes, merge curves, click a commit for its details, and filter to one branch.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/commit-graph.json'use client'
import { useMemo, useState } from 'react'
import './commit-graph.css'
export interface Commit { id: string; msg: string; lane: number; parents: string[]; author: string; when: string; branch?: string }
export interface CommitGraphProps {
commits: Commit[]
dense?: boolean
onSelect?: (id: string) => void
fill?: 'dark' | 'light'
className?: string
}
const LW = 16
/** Lane-based git history graph. */
export function CommitGraph({ commits, dense = false, onSelect, fill = 'dark', className }: CommitGraphProps) {
const [sel, setSel] = useState(commits[0]?.id)
const [br, setBr] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const RH = dense ? 26 : 34
const idx = useMemo(() => Object.fromEntries(commits.map((c, i) => [c.id, i])), [commits])
const lanes = Math.max(...commits.map((c) => c.lane)) + 1
const branches = commits.filter((c) => c.branch).map((c) => c.branch!)
const reach = useMemo(() => {
if (!br) return null
const head = commits.find((c) => c.branch === br); const s = new Set<string>()
const walk = (id: string) => { if (s.has(id) || idx[id] === undefined) return; s.add(id); commits[idx[id]].parents.forEach(walk) }
if (head) walk(head.id)
return s
}, [br, commits, idx])
// lanes that pass straight through a row, and rows a line enters from above
const through = commits.map(() => new Set<number>()), top = commits.map(() => false)
commits.forEach((c, i) => c.parents.forEach((p) => { const j = idx[p]; if (j === undefined) return; const L = commits[j].lane; for (let k = i + 1; k < j; k++) through[k].add(L); top[j] = true }))
const x = (l: number) => 8 + l * LW
const pick = (id: string) => { setSel(id); onSelect?.(id) }
const cur = commits[idx[sel ?? ''] ?? 0]
return (
<div className={['sd-cmg', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-cmg__h" role="toolbar" aria-label="Branch filter">
<button type="button" aria-pressed={!br} onClick={() => setBr(null)}>all</button>
{branches.map((b) => <button key={b} type="button" aria-pressed={br === b} onClick={() => setBr(b)}>{b}</button>)}
</div>
<ul className="sd-cmg__l" role="listbox" aria-label="Commits" tabIndex={0} aria-activedescendant={`sd-cmg-${sel}`}
onKeyDown={(e) => { const d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0; if (d) { e.preventDefault(); const n = commits[Math.max(0, Math.min(commits.length - 1, (idx[sel ?? ''] ?? 0) + d))]; pick(n.id) } }}>
{commits.map((c, i) => {
const W = x(lanes) + 4, cy = RH / 2
return (
<li key={c.id} id={`sd-cmg-${c.id}`} role="option" aria-selected={sel === c.id} className="sd-cmg__r" data-dim={(reach && !reach.has(c.id)) || undefined} style={{ height: RH }} onClick={() => pick(c.id)}
aria-label={`${c.msg}, ${c.author}, ${c.when}${c.parents.length > 1 ? ', merge' : ''}${c.branch ? `, head of ${c.branch}` : ''}`}>
<svg width={W} height={RH} aria-hidden>
{[...through[i]].map((l) => <line key={'t' + l} x1={x(l)} y1={0} x2={x(l)} y2={RH} />)}
{top[i] && <line x1={x(c.lane)} y1={0} x2={x(c.lane)} y2={cy} />}
{c.parents.map((p) => { const j = idx[p]; if (j === undefined) return null; const pl = commits[j].lane; return pl === c.lane ? <line key={p} x1={x(c.lane)} y1={cy} x2={x(c.lane)} y2={RH} /> : <path key={p} d={`M${x(c.lane)} ${cy} C${x(c.lane)} ${RH} ${x(pl)} ${cy} ${x(pl)} ${RH}`} /> })}
<circle cx={x(c.lane)} cy={cy} r={dense ? 4 : 5} data-merge={c.parents.length > 1 || undefined} />
</svg>
<span className="sd-cmg__m">{c.branch && <em>{c.branch}</em>}{c.msg}</span>
<small>{c.id.slice(0, 7)}</small>
</li>
)
})}
</ul>
{cur && (
<div className="sd-cmg__d" key={cur.id} aria-live="polite">
<b>{cur.msg}</b>
<div><span>{cur.author} · {cur.when}</span></div>
<div><code>{cur.id}</code><button type="button" onClick={() => { navigator.clipboard?.writeText(cur.id).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1200) }}>{copied ? 'Copied' : 'Copy hash'}</button></div>
</div>
)}
</div>
)
}.sd-cmg { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-cmg[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-cmg :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cmg :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cmg .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cmg { width: min(100%, 360px); grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; overflow: hidden; }
.sd-cmg__h { display: flex; gap: 4px; padding: 8px; border-bottom: 1px solid var(--line); overflow-x: auto; scrollbar-width: none; }
.sd-cmg__h button { flex: none; padding: 5px 10px; border-radius: 8px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--mute) !important; }
.sd-cmg__h button[aria-pressed='true'] { background: var(--soft) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--line); }
.sd-cmg__l { list-style: none; margin: 0; padding: 4px 0; outline: 0; max-height: 270px; overflow-y: auto; }
.sd-cmg__r { display: flex; align-items: center; gap: 4px; padding-right: 12px; cursor: pointer; transition: opacity 150ms; }
.sd-cmg__r[aria-selected='true'] { background: var(--soft); }
.sd-cmg__r[data-dim] { opacity: 0.32; }
.sd-cmg__r svg { flex: none; display: block; overflow: visible; }
.sd-cmg__r svg path, .sd-cmg__r svg line { fill: none; stroke: var(--mute); stroke-width: 1.6; }
.sd-cmg__r svg circle { fill: var(--bg); stroke: var(--fg); stroke-width: 2; }
.sd-cmg__r[aria-selected='true'] svg circle { fill: var(--fg); }
.sd-cmg__r svg circle[data-merge] { stroke-width: 3.5; }
.sd-cmg__m { flex: 1; min-width: 0; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-cmg__m em { font-style: normal; font-size: 10.5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; padding: 1px 6px; border-radius: 5px; box-shadow: inset 0 0 0 1px var(--line); margin-right: 6px; }
.sd-cmg__r small { flex: none; color: var(--mute); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.sd-cmg__d { padding: 10px 12px; border-top: 1px solid var(--line); display: grid; gap: 4px; font-size: 12.5px; animation: sd-cmg-in 180ms ease-out; }
@keyframes sd-cmg-in { from { opacity: 0; transform: translateY(4px); } }
.sd-cmg__d b { font-size: 13.5px; }
.sd-cmg__d div { display: flex; align-items: center; gap: 8px; color: var(--mute); }
.sd-cmg__d code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--fg); }
.sd-cmg__d button { margin-left: auto; padding: 4px 10px; border-radius: 7px; font-size: 11.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); color: var(--fg) !important; }
@media (prefers-reduced-motion: reduce) { .sd-cmg *, .sd-cmg *::before, .sd-cmg *::after { animation: none !important; transition: none !important; } }Overview
Commits sit on lanes drawn in SVG; merges curve from one lane into another. Selecting a commit opens a short detail with author, time, hash and a copy button.
The branch filter dims commits that are not reachable from the chosen branch rather than hiding them, so the shape stays readable.
When to use
- Repo browsers, deploy history, code review context.
When not to use
- Hundreds of commits: virtualise the list.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/commit-graph.json$
npx shadcn@latest add https://sundaeui.dev/r/commit-graph.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/commit-graph.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/commit-graph.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy commit-graph.tsx and commit-graph.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { CommitGraph } from '@/components/sundae/commit-graph'
<CommitGraph />Props
| Prop | Type | Default |
|---|---|---|
commits*Newest first. | { id: string; msg: string; lane: number; parents: string[]; author: string; when: string; branch?: string }[] | — |
onSelectCalled on selection. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The list is a listbox of commits; arrow keys move the selection.
- Lanes are decorative; branch and merge info is in each option label.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cmg; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Move between commits
FAQ
- Is Commit Graph free?
- Yes. Commit Graph is free for personal and commercial projects, with full source on this page.
- How do I install Commit Graph?
- Run npx shadcn@latest add https://sundaeui.dev/r/commit-graph.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Commit Graph support keyboard and screen readers?
- The list is a listbox of commits; arrow keys move the selection. Lanes are decorative; branch and merge info is in each option label.
- Does Commit Graph respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Invoice List
Data · RefinedDiff Viewer
Data · Refined- Pro
Price History
Data · Refined Stacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined