Inline Comment
Overlays

Inline Comment

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.

Install

npx shadcn@latest add @paragon/inline-comment

Also installs: popover

inline-comment.tsx

"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>
  );
}