Comment Pin
Anchored discussion pin rendered at canvas coordinates with author + unread badge. Install Comment Pin from the VLLNT UI registry with the shadcn CLI.
Anchored discussion pin rendered at canvas coordinates with author + unread badge. Part of the utility family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Comment Pin to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.com/r/comment-pin.jsonSource
"use client";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { cn } from "../../lib/utils";
/**
* Resolution state of a pinned comment.
*
* @public
*/
export type CommentPinState = "open" | "resolved";
/**
* Localizable strings.
*
* @public
*/
export type CommentPinLabels = {
/** Aria-label override. Defaults to `"Comment"`. */
region?: string;
/** Suffix appended after the unread count for screen readers. */
unreadSuffix?: string;
};
const DEFAULT_LABELS = {
region: "Comment",
unreadSuffix: "unread",
} as const satisfies Required<CommentPinLabels>;
/**
* Props for {@link CommentPin}.
*
* @public
*/
export type CommentPinProps = {
/** Optional author initial / glyph rendered inside the pin. */
authorInitial?: ReactNode;
/** Optional accent color for the pin. Defaults to the foreground. */
color?: string;
/** Localizable strings. */
labels?: CommentPinLabels;
/** Click handler — when provided, the pin becomes a button. */
onActivate?: () => void;
/** State of the underlying thread. Defaults to `"open"`. */
state?: CommentPinState;
/** Optional unread reply count. Renders as a small numeric badge. */
unread?: number;
/** Anchor X in canvas pixels. */
x: number;
/** Anchor Y in canvas pixels. */
y: number;
} & ComponentPropsWithoutRef<"div">;
const STATE_FILL: Record<CommentPinState, string> = {
open: "bg-foreground text-background",
resolved: "bg-muted text-muted-foreground",
};
type PinBodyInput = {
accent?: string;
authorInitial?: ReactNode;
state: CommentPinState;
unread?: number;
};
const PinBody = (props: PinBodyInput): React.ReactElement => {
const showBadge = typeof props.unread === "number" && props.unread > 0;
const useAccent = props.accent && props.state === "open";
return (
<>
<span
aria-hidden="true"
className={cn(
"flex size-7 items-center justify-center rounded-full border border-background text-[11px] font-semibold shadow-sm",
STATE_FILL[props.state],
)}
data-comment-pin-body
style={
useAccent
? { backgroundColor: props.accent, color: "white" }
: undefined
}
>
{props.authorInitial ?? "•"}
</span>
{showBadge ? (
<span
aria-hidden="true"
className="absolute -right-1 -top-1 inline-flex min-h-[14px] min-w-[14px] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] font-medium text-white"
data-comment-pin-unread
>
{props.unread}
</span>
) : null}
</>
);
};
/**
* Anchored discussion pin rendered at canvas coordinates. Use to mark
* an object-anchored comment thread; click to expand into a
* {@link "../thread-bubble/thread-bubble".ThreadBubble} (or whatever the host wires up).
*
* Pure presentation; the host owns the thread store + supplies the
* unread count and resolution state.
*
* @example
* ```tsx
* <div className="relative h-screen w-screen">
* <Canvas />
* <CommentPin
* x={420} y={180}
* authorInitial="B"
* unread={3}
* onActivate={() => openThread("c-1")}
* />
* </div>
* ```
*
* @public
*/
export const CommentPin = ({
ref,
...props
}: CommentPinProps & { ref?: React.Ref<HTMLDivElement> }) => {
const {
authorInitial,
className,
color,
labels,
onActivate,
state = "open",
unread,
x,
y,
...rest
} = props;
const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
const showBadge = typeof unread === "number" && unread > 0;
const ariaLabel = showBadge
? `${resolvedLabels.region}, ${unread} ${resolvedLabels.unreadSuffix}`
: resolvedLabels.region;
const handleActivateComment = (): void => {
onActivate?.();
};
const body = (
<PinBody
accent={color}
authorInitial={authorInitial}
state={state}
unread={unread}
/>
);
return (
<div
aria-label={ariaLabel}
className={cn(
"absolute z-30 inline-flex -translate-x-1/2 -translate-y-1/2",
className,
)}
data-comment-pin
data-comment-pin-state={state}
ref={ref}
role="img"
style={{ left: x, top: y }}
{...rest}
>
{onActivate ? (
<button
aria-label={ariaLabel}
className="relative inline-flex rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-comment-pin-trigger
onClick={handleActivateComment}
type="button"
>
{body}
</button>
) : (
<span className="relative inline-flex">{body}</span>
)}
</div>
);
};
CommentPin.displayName = "CommentPin";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.