Docs-style comment thread anchored to a highlighted phrase — origin-aware card with replies, a resolve control that morphs to a check, and a marker that fades to a dotted underline once resolved.
npx shadcn@latest add @paragon/inline-commentAlso installs: popover
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUp, Check, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/paragon/ui/popover";
export interface InlineCommentEntry {
id: string;
author: string;
/** Pre-formatted timestamp ("2h ago"). Kept as text for determinism. */
time: string;
body: string;
}
export interface InlineCommentProps {
/** The annotated text span. */
children: React.ReactNode;
comments?: InlineCommentEntry[];
/** Author name used for replies composed here. */
currentUser?: string;
onReply?: (body: string) => void;
onResolveChange?: (resolved: boolean) => void;
defaultResolved?: boolean;
side?: React.ComponentProps<typeof PopoverContent>["side"];
align?: React.ComponentProps<typeof PopoverContent>["align"];
className?: string;
}
const defaultComments: InlineCommentEntry[] = [
{
id: "c1",
author: "Priya Patel",
time: "2h ago",
body: "Legal flagged this number — it should cite the audited Q2 figure, not the forecast.",
},
{
id: "c2",
author: "Marcus Webb",
time: "1h ago",
body: "Good catch. Pulling the audited figure from the finance workspace now.",
},
];
function initials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0] ?? "")
.join("")
.toUpperCase();
}
/**
* Docs-style inline comment thread. The commented phrase carries a marker
* highlight with a hairline underline; clicking it opens an origin-aware
* thread card with the conversation, a reply composer (Enter sends), and a
* resolve control whose icon morphs to a success check. Resolving fades the
* marker to a quiet dotted underline — the anchor stays discoverable, the
* highlight noise goes — and gently closes the card. Fully keyboard
* operable: the marker is a real button, Esc dismisses, focus returns.
*/
export function InlineComment({
children,
comments = defaultComments,
currentUser = "You",
onReply,
onResolveChange,
defaultResolved = false,
side = "bottom",
align = "start",
className,
}: InlineCommentProps) {
const reducedMotion = useReducedMotion();
const [open, setOpen] = React.useState(false);
const [resolved, setResolved] = React.useState(defaultResolved);
const [thread, setThread] = React.useState(comments);
const [draft, setDraft] = React.useState("");
const closeTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
const replySeq = React.useRef(0);
React.useEffect(() => {
return () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
};
}, []);
const send = () => {
const body = draft.trim();
if (!body) return;
replySeq.current += 1;
setThread((current) => [
...current,
{
id: `reply-${replySeq.current}`,
author: currentUser,
time: "Just now",
body,
},
]);
setDraft("");
onReply?.(body);
};
const toggleResolved = () => {
const next = !resolved;
setResolved(next);
onResolveChange?.(next);
if (next) {
// Let the check morph land, then put the thread away.
closeTimer.current = setTimeout(() => setOpen(false), 600);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"relative inline rounded-sm px-0.5 -mx-0.5 text-inherit outline-none",
"transition-[background-color,border-color] duration-200",
"focus-visible:ring-2 focus-visible:ring-ring",
resolved
? "border-b border-dashed border-muted-foreground/40 bg-transparent hover:bg-secondary"
: "border-b-2 border-warning bg-warning/20 hover:bg-warning/30",
className,
)}
>
{children}
<span className="sr-only">
{resolved
? ` (resolved comment thread, ${thread.length} comments)`
: ` (comment thread, ${thread.length} comments)`}
</span>
</button>
</PopoverTrigger>
<PopoverContent side={side} align={align} className="w-72 p-0">
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<MessageSquare aria-hidden className="size-3.5" />
<span className="tabular-nums">
{thread.length} comment{thread.length === 1 ? "" : "s"}
</span>
{resolved && (
<span className="rounded-full bg-success/10 px-1.5 py-0.5 text-[11px] font-medium text-success">
Resolved
</span>
)}
</span>
<button
type="button"
aria-pressed={resolved}
aria-label={resolved ? "Reopen thread" : "Resolve thread"}
onClick={toggleResolved}
className={cn(
"pressable relative flex size-6 items-center justify-center rounded-full outline-none",
"transition-colors duration-150",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
"focus-visible:ring-2 focus-visible:ring-ring",
resolved
? "bg-success text-success-foreground"
: "text-muted-foreground shadow-border hover:text-success",
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={resolved ? "resolved" : "open"}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="flex items-center justify-center"
>
<Check className="size-3.5" strokeWidth={resolved ? 3 : 2} />
</motion.span>
</AnimatePresence>
</button>
</div>
<ul className="max-h-64 space-y-3 overflow-y-auto px-3 py-3">
{thread.map((comment, index) => (
<motion.li
key={comment.id}
initial={
index < comments.length || reducedMotion
? false
: { opacity: 0, y: 6, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="flex gap-2.5"
>
<span
aria-hidden
className="flex size-6 shrink-0 items-center justify-center rounded-full bg-secondary text-[10px] font-semibold text-secondary-foreground"
>
{initials(comment.author)}
</span>
<div className="min-w-0 flex-1">
<p className="flex items-baseline gap-1.5">
<span className="truncate text-[13px] leading-4 font-medium">
{comment.author}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{comment.time}
</span>
</p>
<p className="mt-0.5 text-[13px] leading-5 text-muted-foreground">
{comment.body}
</p>
</div>
</motion.li>
))}
</ul>
<form
className="flex items-center gap-2 border-t px-3 py-2"
onSubmit={(event) => {
event.preventDefault();
send();
}}
>
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Reply…"
aria-label="Reply to thread"
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2.5 text-[13px] transition-[border-color,box-shadow] duration-150 ease-out outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
/>
<button
type="submit"
aria-label="Send reply"
disabled={!draft.trim()}
className="pressable relative flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity duration-150 outline-none after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 disabled:pointer-events-none disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover"
>
<ArrowUp className="size-3.5" />
</button>
</form>
</PopoverContent>
</Popover>
);
}