A working slice of a code review screen. It shows one file as a unified diff with old and new line numbers, green added rows, red removed rows and a hunk header. Every line has a comment button; pressing it opens a thread directly under that line with an animated height, where you can read replies, add your own, resolve or reopen the thread and close it. A badge on the button counts the comments on that line and turns green when the thread is resolved. It opens with one two-message thread already expanded. On phones the old line number column is dropped, code wraps, and buttons grow to a comfortable tap size.
// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/github-inline-comments.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircle2, FileCode2, MessageSquarePlus, X } from "lucide-react";
type Line =
| { kind: "hunk"; content: string }
| { kind: "context" | "add" | "del"; old: number | null; new: number | null; content: string };
type Comment = { id: number; author: string; initials: string; color: string; body: string; when: string };
const fileName = "src/billing/proration.ts";
const diff: Line[] = [
{ kind: "hunk", content: "@@ -14,9 +14,12 @@ export function prorate(plan: Plan, daysLeft: number)" },
{ kind: "context", old: 14, new: 14, content: " const daysInCycle = plan.interval === \"year\" ? 365 : 30;" },
{ kind: "del", old: 15, new: null, content: " const daily = plan.price / daysInCycle;" },
{ kind: "del", old: 16, new: null, content: " return daily * daysLeft;" },
{ kind: "add", old: null, new: 15, content: " if (daysLeft <= 0) return 0;" },
{ kind: "add", old: null, new: 16, content: " const daily = plan.priceInCents / daysInCycle;" },
{ kind: "add", old: null, new: 17, content: " const credit = Math.round(daily * daysLeft);" },
{ kind: "add", old: null, new: 18, content: " return Math.min(credit, plan.priceInCents);" },
{ kind: "context", old: 17, new: 19, content: "}" },
];
// Threads are keyed by the line's position in `diff`.
const initialThreads: Record<number, Comment[]> = {
6: [
{ id: 1, author: "Renata Solis", initials: "RS", color: "#7c3aed", body: "Rounding per call can drift by a cent over a year of upgrades. Should we round once, at invoice time?", when: "12 min ago" },
{ id: 2, author: "Kofi Mensah", initials: "KM", color: "#0e7490", body: "Fair. I will move the rounding into the invoice builder and keep this in fractional cents.", when: "4 min ago" },
],
};
export default function CodeDiffViewerWithInlineCommentThreads() {
const reduce = useReducedMotion();
const [threads, setThreads] = useState(initialThreads);
const [openAt, setOpenAt] = useState<number | null>(6);
const [resolved, setResolved] = useState<Record<number, boolean>>({});
const [draft, setDraft] = useState("");
const addComment = (idx: number) => {
const body = draft.trim();
if (!body) return;
setThreads((t) => ({ ...t, [idx]: [...(t[idx] ?? []), { id: Date.now(), author: "You", initials: "YO", color: "#be123c", body, when: "just now" }] }));
setDraft("");
};
const toggle = (idx: number) => {
setOpenAt((o) => (o === idx ? null : idx));
setDraft("");
};
const added = diff.filter((l) => l.kind === "add").length;
const removed = diff.filter((l) => l.kind === "del").length;
return (
<section className="bg-slate-950 px-3 py-14 text-slate-200 sm:px-6 sm:py-20">
<div className="mx-auto max-w-4xl">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4 px-1">
<div>
<p className="text-sm font-medium text-sky-400">Pull request 482</p>
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-white sm:text-3xl">Stop proration from over-crediting upgrades</h2>
</div>
<p className="text-sm text-slate-400">Select the comment icon on any line to open a thread.</p>
</div>
<div className="overflow-hidden rounded-xl border border-white/10 bg-slate-900 shadow-2xl shadow-black/50">
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-3 py-2.5 sm:px-4">
<p className="flex min-w-0 items-center gap-2 font-mono text-[13px] text-slate-200">
<FileCode2 className="h-4 w-4 shrink-0 text-slate-400" aria-hidden="true" />
<span className="truncate">{fileName}</span>
</p>
<p className="shrink-0 font-mono text-xs">
<span className="text-emerald-400">+{added}</span> <span className="text-rose-400">-{removed}</span>
</p>
</div>
<ol aria-label={`Changes in ${fileName}`}>
{diff.map((line, idx) => {
if (line.kind === "hunk") {
return (
<li key={idx} className="overflow-hidden text-ellipsis whitespace-nowrap border-b border-white/5 bg-sky-500/10 px-3 py-1.5 font-mono text-xs text-sky-200/80 sm:px-4">
{line.content}
</li>
);
}
const list = threads[idx] ?? [];
const isOpen = openAt === idx;
const isResolved = !!resolved[idx];
const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : " ";
return (
<li key={idx} className="border-b border-white/5 last:border-b-0">
<div
className={`group relative flex items-stretch font-mono text-[13px] leading-6 ${
line.kind === "add" ? "bg-emerald-500/10" : line.kind === "del" ? "bg-rose-500/10" : ""
}`}
>
<span className="hidden w-11 shrink-0 select-none px-2 text-right text-xs leading-6 text-slate-500 sm:block" aria-hidden="true">
{line.old ?? ""}
</span>
<span className="w-9 shrink-0 select-none px-2 text-right text-xs leading-6 text-slate-500 sm:w-11" aria-hidden="true">
<span className="sm:hidden">{line.new ?? line.old ?? ""}</span>
<span className="hidden sm:inline">{line.new ?? ""}</span>
</span>
<span className="flex w-10 shrink-0 items-center justify-center">
<button
type="button"
onClick={() => toggle(idx)}
aria-expanded={isOpen}
aria-label={`${list.length ? `${list.length} comments` : "Add a comment"} on line ${line.new ?? line.old}`}
className={`relative flex h-10 w-10 items-center justify-center rounded-md transition-opacity focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sky-400 sm:h-7 sm:w-7 ${
list.length || isOpen ? "text-sky-300 opacity-100" : "text-slate-300 opacity-40 hover:bg-sky-500 hover:text-white group-hover:opacity-100"
}`}
>
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
{list.length > 0 && (
<span className={`absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 font-sans text-[10px] font-bold text-white sm:-right-1.5 sm:-top-1.5 ${isResolved ? "bg-emerald-600" : "bg-sky-600"}`}>
{list.length}
</span>
)}
</button>
</span>
<span className={`w-4 shrink-0 select-none text-center font-semibold ${line.kind === "add" ? "text-emerald-400" : line.kind === "del" ? "text-rose-400" : ""}`} aria-hidden="true">
{sign}
</span>
<code className="min-w-0 flex-1 whitespace-pre-wrap break-words py-0 pr-3 text-slate-100">
<span className="sr-only">{line.kind === "add" ? "Added: " : line.kind === "del" ? "Removed: " : ""}</span>
{line.content}
</code>
</div>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="thread"
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 320, damping: 34 }}
className="overflow-hidden border-t border-white/10 bg-slate-950/70"
>
<div className="m-3 rounded-lg border border-white/10 bg-slate-900 sm:m-4 sm:ml-[7.5rem]">
<div className="flex items-center justify-between gap-2 border-b border-white/10 px-3 py-2">
<span className={`inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs font-semibold ${isResolved ? "bg-emerald-600 text-white" : "bg-white/10 text-slate-100"}`}>
{isResolved && <CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
{isResolved ? "Resolved" : list.length ? "Open" : "New thread"}
</span>
<span className="flex items-center gap-1">
{list.length > 0 && (
<button
type="button"
onClick={() => setResolved((r) => ({ ...r, [idx]: !r[idx] }))}
aria-pressed={isResolved}
className="inline-flex h-10 items-center rounded-md px-3 text-xs font-semibold text-slate-100 transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 sm:h-8"
>
{isResolved ? "Reopen" : "Resolve"}
</button>
)}
<button
type="button"
onClick={() => setOpenAt(null)}
aria-label="Close thread"
className="flex h-10 w-10 items-center justify-center rounded-md text-slate-300 transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 sm:h-8 sm:w-8"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</span>
</div>
{list.length > 0 && (
<ul className="space-y-4 px-3 py-4">
<AnimatePresence initial={false}>
{list.map((c) => (
<motion.li key={c.id} initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-bold text-white" style={{ backgroundColor: c.color }} aria-hidden="true">
{c.initials}
</span>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="font-semibold text-white">{c.author}</span>
<span className="text-xs text-slate-400">{c.when}</span>
</p>
<p className={`mt-1 text-sm leading-relaxed ${isResolved ? "text-slate-400" : "text-slate-200"}`}>{c.body}</p>
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
<form
className="border-t border-white/10 p-3"
onSubmit={(e) => {
e.preventDefault();
addComment(idx);
}}
>
<label htmlFor={`diff-comment-${idx}`} className="sr-only">
Reply on line {line.new ?? line.old}
</label>
<textarea
id={`diff-comment-${idx}`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpenAt(null);
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) addComment(idx);
}}
rows={2}
placeholder={list.length ? "Reply..." : "Leave a comment on this line"}
className="block w-full resize-none rounded-md border border-white/15 bg-slate-950 px-3 py-2 text-sm text-white placeholder:text-slate-400 focus:border-sky-400 focus:outline-none focus:ring-1 focus:ring-sky-400"
/>
<div className="mt-2 flex justify-end gap-2">
<button type="button" onClick={() => setOpenAt(null)} className="inline-flex h-10 items-center rounded-md px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400">
Cancel
</button>
<button
type="submit"
disabled={!draft.trim()}
className="inline-flex h-10 items-center rounded-md bg-sky-500 px-4 text-sm font-semibold text-slate-950 transition-colors hover:bg-sky-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:cursor-not-allowed disabled:bg-white/10 disabled:text-slate-400"
>
Comment
</button>
</div>
</form>
</div>
</motion.div>
)}
</AnimatePresence>
</li>
);
})}
</ol>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/code-diff-viewer-with-inline-comment-threads.json
No. It displays rows you give it. Produce them on the server or with a diff library from two versions of a file, then map each result to a `context`, `add` or `del` row with its line numbers.
Replace the plain `{line.content}` inside the `code` element with the output of a highlighter that returns React elements or spans per token. Keep `whitespace-pre-wrap` so indentation survives.
It keeps the file readable and lets one draft state serve the whole view. To allow several, change `openAt` from a single index to a set of indexes and keep one draft per thread.
Threads are stored in an object keyed by the row's position in the diff array. In a real product, key them by file path, commit and line number so they survive when the diff changes.
This component is a unified view, which works at any width. A split view needs two columns with paired rows and does not fit on phones, so it is better as a separate layout switched on at wide breakpoints.
Adapted from GitHub Inline Comments in Eldora UI, used under its open-source licence. Licences and credits
Related: Animated Terminal Window with Typed Commands, File Tree with Collapsible Folders, Streaming AI Chat Thread with Typing Indicator.
More app and game ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.