About
A commit graph with lanes, merges, branch and tag refs and expandable commits with their files. Agent commits are marked and can be filtered, and new pushes arrive highlighted as they land.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Git Graph to your project and fits it to your theme.
Code
How Git Graph is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client"
import * as React from "react"
import { GitCommit, GitGraph } from "@/components/ui/git-graph"
/** How long a choice holds a tour before it carries on. */
const RESUME_MS = 1800
/** True until `RESUME_MS` after the last `hold()`, for tours with their own state. */
function useHold() {
const [held, setHeld] = React.useState(false)
const timer = React.useRef(0)
React.useEffect(() => () => window.clearTimeout(timer.current), [])
const hold = React.useCallback(() => {
setHeld(true)
window.clearTimeout(timer.current)
timer.current = window.setTimeout(() => setHeld(false), RESUME_MS)
}, [])
return [held, hold] as const
}
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
/** The commit the agent pushes while the demo plays. */
const PUSHED: GitCommit = {
sha: "b7d9f1a3c5e7b9d2f4a6c8e1b3d5f7a9c2e4b6d8",
parents: ["3f6d8b0a2c4e6f8a1b3d5f7a9c2e4b6d8f1a3c5e"],
subject: "Address review: show failed batches first",
body: "Requested by Tomas in review. Failed batches sort to the top and keep their retry count.",
author: "Agent run 214",
authorKind: "agent",
date: "Just now",
dateTitle: "Oct 28, 4:16 pm",
refs: [{ name: "feat/batch-status", kind: "branch" }],
files: [
{
path: "app/dashboard/payouts/batch-row.tsx",
status: "modified",
additions: 11,
deletions: 4,
},
],
}
/** What an agent pushes to the feature branch after review, then the merge that lands it. */
const SECOND: GitCommit = {
sha: "c2e4b6d8f1a3c5e7b9d2f4a6c8e1b3d5f7a9c2e4",
parents: [PUSHED.sha],
subject: "Add a retry count column to batch rows",
body: "Each failed batch shows how many retries are left before it pages someone.",
author: "Agent run 214",
authorKind: "agent",
date: "Just now",
refs: [{ name: "feat/batch-status", kind: "branch" }],
files: [
{ path: "app/dashboard/payouts/batch-row.tsx", status: "modified", additions: 9, deletions: 2 },
{ path: "app/dashboard/payouts/columns.ts", status: "modified", additions: 6, deletions: 0 },
],
}
const THIRD: GitCommit = {
sha: "d5f7a9c2e4b6d8f1a3c5e7b9d2f4a6c8e1b3d5f7",
parents: [SECOND.sha],
subject: "Update the batch row snapshot",
body: "The snapshot now includes the retry column. No other markup changed.",
author: "Agent run 214",
authorKind: "agent",
date: "Just now",
refs: [{ name: "feat/batch-status", kind: "branch" }],
files: [
{
path: "app/dashboard/payouts/__snapshots__/batch-row.test.tsx.snap",
status: "modified",
additions: 12,
deletions: 3,
},
],
}
/** A payouts service: an agent fixing a flaky test next to a person's feature branch. */
const HISTORY: GitCommit[] = [
{
sha: "e41b9c07d2a35f8816c0b2e94d1a7f3c5b6e0d21",
parents: [
"9d03c1f5ab72e4d6c8e1f0a3b5d7c9e2f4a6b8c0",
"7a2f0e9d1c3b5a7e9f1d3c5b7a9e1f3d5c7b9a1e",
],
subject: "Merge pull request #418 from agent/retry-clock",
body: "Retry tests now run on an injected clock and seeded jitter. 20 consecutive CI runs passed.",
author: "Ines Okafor",
date: "4 min ago",
dateTitle: "Oct 28, 4:12 pm",
refs: [
{ name: "main", kind: "head" },
{ name: "origin/main", kind: "remote" },
],
},
{
sha: "7a2f0e9d1c3b5a7e9f1d3c5b7a9e1f3d5c7b9a1e",
parents: ["5c8e2a4f6b1d3e5a7c9b2d4f6a8c1e3b5d7f9a2c"],
subject: "Seed jitter in retry tests",
body: "The tests pass a fixed random() so the expected waits are exact: 100, 200 and 400 ms.",
author: "Agent run 212",
authorKind: "agent",
date: "18 min ago",
dateTitle: "Oct 28, 3:58 pm",
refs: [{ name: "agent/retry-clock", kind: "branch" }],
files: [{ path: "src/payouts/retry.test.ts", status: "modified", additions: 14, deletions: 6 }],
},
{
sha: "3f6d8b0a2c4e6f8a1b3d5f7a9c2e4b6d8f1a3c5e",
parents: ["1b4d6f8a3c5e7b9d2f4a6c8e1b3d5f7a9c2e4b6d"],
subject: "Show payout batch status on the dashboard",
author: "Tomas Lindqvist",
date: "26 min ago",
dateTitle: "Oct 28, 3:50 pm",
refs: [{ name: "feat/batch-status", kind: "branch" }],
files: [
{ path: "app/dashboard/payouts/batch-row.tsx", status: "added", additions: 82, deletions: 0 },
{ path: "app/dashboard/payouts/page.tsx", status: "modified", additions: 9, deletions: 3 },
],
},
{
sha: "5c8e2a4f6b1d3e5a7c9b2d4f6a8c1e3b5d7f9a2c",
parents: ["2e4a6c8f1b3d5e7a9c2f4b6d8e1a3c5f7b9d2e4a"],
subject: "Inject sleep and random into retryWithBackoff",
body: "Both default to the real implementations, so production behavior is unchanged.",
author: "Agent run 212",
authorKind: "agent",
date: "31 min ago",
dateTitle: "Oct 28, 3:45 pm",
files: [
{ path: "src/payouts/retry.ts", status: "modified", additions: 6, deletions: 3 },
{ path: "src/payouts/types.ts", status: "modified", additions: 2, deletions: 0 },
],
},
{
sha: "9d03c1f5ab72e4d6c8e1f0a3b5d7c9e2f4a6b8c0",
parents: ["8c1e3a5f7b9d2e4a6c8f1b3d5e7a9c2f4b6d8e1a"],
subject: "Bump vitest to 3.2.4",
author: "Ines Okafor",
date: "52 min ago",
dateTitle: "Oct 28, 3:24 pm",
},
{
sha: "1b4d6f8a3c5e7b9d2f4a6c8e1b3d5f7a9c2e4b6d",
parents: ["8c1e3a5f7b9d2e4a6c8f1b3d5e7a9c2f4b6d8e1a"],
subject: "Add batch status column",
author: "Tomas Lindqvist",
date: "1 h ago",
dateTitle: "Oct 28, 3:10 pm",
},
{
sha: "2e4a6c8f1b3d5e7a9c2f4b6d8e1a3c5f7b9d2e4a",
parents: ["8c1e3a5f7b9d2e4a6c8f1b3d5e7a9c2f4b6d8e1a"],
subject: "Add a failing case for the retry timeout",
author: "Agent run 212",
authorKind: "agent",
date: "1 h ago",
dateTitle: "Oct 28, 3:02 pm",
files: [{ path: "src/payouts/retry.test.ts", status: "modified", additions: 18, deletions: 0 }],
},
{
sha: "8c1e3a5f7b9d2e4a6c8f1b3d5e7a9c2f4b6d8e1a",
parents: ["6a8c1e3b5d7f9a2c4e6b8d1f3a5c7e9b2d4f6a8c"],
subject: "Release 2.14.0",
author: "Ines Okafor",
date: "Yesterday",
dateTitle: "Oct 27, 6:40 pm",
refs: [{ name: "v2.14.0", kind: "tag" }],
},
{
sha: "6a8c1e3b5d7f9a2c4e6b8d1f3a5c7e9b2d4f6a8c",
parents: [
"4f6b8d1a3c5e7f9b2d4a6c8e1f3b5d7a9c2e4f6b",
"0d2f4b6a8c1e3d5f7b9a2c4e6d8f1b3a5c7e9d2f",
],
subject: "Merge pull request #409 from feat/ledger-split",
author: "Yara Haddad",
date: "Yesterday",
dateTitle: "Oct 27, 2:15 pm",
},
{
sha: "0d2f4b6a8c1e3d5f7b9a2c4e6d8f1b3a5c7e9d2f",
parents: ["4f6b8d1a3c5e7f9b2d4a6c8e1f3b5d7a9c2e4f6b"],
subject: "Split payouts across ledgers by currency",
author: "Yara Haddad",
date: "2 days ago",
dateTitle: "Oct 26, 11:30 am",
},
{
sha: "4f6b8d1a3c5e7f9b2d4a6c8e1f3b5d7a9c2e4f6b",
parents: ["c3e5a7f9b2d4e6a8c1f3b5d7e9a2c4f6b8d1e3a5"],
subject: "Round payouts once, after allocation",
author: "Yara Haddad",
date: "3 days ago",
dateTitle: "Oct 25, 9:05 am",
},
]
const MAIN = HISTORY[0]!
const MERGE: GitCommit = {
sha: "f9a2c4e6b8d1f3a5c7e9b2d4f6a8c1e3b5d7f9a2",
parents: [MAIN.sha, THIRD.sha],
subject: "Merge pull request #421 from feat/batch-status",
body: "Approved by Tomas Lindqvist. Checks passed on all three commits.",
author: "Tomas Lindqvist",
date: "Just now",
refs: [
{ name: "main", kind: "head" },
{ name: "origin/main", kind: "remote" },
],
}
const STEPS = [PUSHED, SECOND, THIRD, MERGE]
const BEATS = STEPS.length + 2
const STEP_MS = 1100
/** After the third push the view filters to agent commits for a beat, then the merge lands. */
const FILTER_AT = 4
/** Adds `commit` on top, moving the refs it carries off their old commits. */
function push(list: GitCommit[], commit: GitCommit) {
const moved = new Set(commit.refs?.map((r) => r.name))
return [
commit,
...list.map((c) => {
const refs = c.refs?.filter((r) => !moved.has(r.name))
return refs?.length === c.refs?.length ? c : { ...c, refs: refs?.length ? refs : undefined }
}),
]
}
const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"
/**
* 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
* phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
* render white on dark pages. FitFrame fills the frame exactly instead.
*
* - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
* - "crop": the content stays at full size from the top and fades out at the bottom. For text
* that streams in, where a changing scale would make the page breathe.
*
* `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
* to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
* shrinks evenly on both sides.
*
* Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
* shorter than `hideBelow`, so a profile card shows the visual alone.
*/
function FitFrame({
children,
mode = "scale",
hideBelow = 360,
minWidth,
}: {
children: React.ReactNode
mode?: "scale" | "crop"
hideBelow?: number
minWidth?: string
}) {
const content = React.useRef<HTMLDivElement>(null)
const [cropped, setCropped] = React.useState(false)
React.useLayoutEffect(() => {
const el = content.current
if (!el) return
const measure = () => {
const vw = window.innerWidth
const vh = window.innerHeight
el.toggleAttribute("data-fit-small", vh < hideBelow)
// Layout sizes ignore the transform, so measuring never feeds back.
const w = Math.max(el.offsetWidth, el.scrollWidth)
const h = Math.max(el.offsetHeight, el.scrollHeight)
const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
el.style.transform = scale < 1 ? `scale(${scale})` : ""
setCropped(mode === "crop" && h > vh + 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
// Content that changes inside a fixed width box (a headline that cycles words) never resizes
// the box itself, so changes below it measure again on the next frame.
let frame = 0
const mutations = new MutationObserver(() => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(measure)
})
mutations.observe(el, { subtree: true, childList: true, characterData: true })
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
mutations.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener("resize", measure)
}
}, [mode, hideBelow])
const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
return (
<div
className="flex w-full justify-center overflow-hidden"
style={{
height: "100dvh",
alignItems: mode === "crop" ? "flex-start" : "center",
maskImage: cropped ? fade : undefined,
WebkitMaskImage: cropped ? fade : undefined,
}}
>
<style>{FIT_CSS}</style>
<div
ref={content}
className="w-full shrink-0"
style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
>
{children}
</div>
</div>
)
}
/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
return (
<p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
{children}{" "}
<a
href={`https://chatcn.dev/docs/components/${slug}`}
target="_blank"
rel="noopener"
className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
>
chatcn.dev
</a>
</p>
)
}
/**
* A payouts service while an agent answers review: three commits land on the feature branch,
* each opening as it arrives, the view filters to agent commits, then the pull request merges.
* Then it plays again; a click holds it for a moment.
*/
export default function GitGraphPreview() {
const [count, setCount] = React.useState(0)
const [touched, touch] = useHold()
React.useEffect(() => {
if (reducedMotion() || touched) return
const id = window.setInterval(
() => setCount((n) => (n >= BEATS ? 0 : n + 1)),
STEP_MS,
)
return () => window.clearInterval(id)
}, [touched])
// Beats: three pushes, the agent filter, the merge, a hold on the result.
const shown = Math.min(count >= FILTER_AT ? count - 1 : count, STEPS.length)
const filtered = count === FILTER_AT
const commits = STEPS.slice(0, shown).reduce(push, HISTORY)
const newest = shown ? STEPS[shown - 1]!.sha : (HISTORY[1]?.sha ?? "")
const [picked, setPicked] = React.useState<string | null>(null)
const [open, setOpen] = React.useState<string[]>([])
const [agentOnly, setAgentOnly] = React.useState(false)
return (
<FitFrame minWidth="48rem">
<div className="flex w-full flex-col items-center justify-center gap-6 p-6">
<div className="w-full max-w-4xl">
<GitGraph
commits={commits}
fresh={STEPS.slice(0, shown).map((c) => c.sha)}
value={touched ? picked : newest}
onValueChange={(sha) => {
touch()
setPicked(sha)
}}
expanded={touched ? open : [newest]}
onExpandedChange={(next) => {
touch()
setOpen(next)
}}
agentOnly={touched ? agentOnly : filtered}
onAgentOnlyChange={(next) => {
touch()
setAgentOnly(next)
}}
title="harbor-ledger"
/>
</div>
<MoreOn slug="git-graph">Lanes, refs, agent commits and live pushes. On</MoreOn>
</div>
</FitFrame>
)
}







