Foundational concept
- Track click positions on a canvas
- Create comment pins at those positions
- Handle comment states (editing/viewing)
- Manage animations for a polished feel
Step 1: Defining Our Types
First, let's establish our type system. This is crucial for maintaining code clarity and preventing bugs:
export enum COMMENT_MODE {
IDLE = 'IDLE',
EDIT = 'EDIT',
}
export type Comment = {
id: string;
x: number;
y: number;
content: string;
mode: COMMENT_MODE;
};
export type MousePosition = {
x: number;
y: number;
};
export interface CommentPinAreaProps {
allowComment: boolean;
}
typescript
These types define our comment structure and possible states. Each comment has coordinates, content, and a mode (either editing or idle).
Step 2: Setting Up the Context
For state management, we'll use React Context. This makes our comment state accessible throughout the component tree:
'use client';
import React, { createContext, MouseEvent, SetStateAction } from 'react';
import { Comment, MousePosition } from './types';
export type CommentPinAreaContextType = {
// state values and respective setters/dispatch actions
comments: Comment[];
setComments: React.Dispatch<SetStateAction<Comment[]>>;
activeComment: string;
setActiveComment: React.Dispatch<SetStateAction<string>>;
mousePosition: MousePosition;
setMousePosition: React.Dispatch<SetStateAction<MousePosition>>;
newCommentInput: string;
setNewCommentInput: React.Dispatch<SetStateAction<string>>;
// list of handlers for comment actions
handleCanvasClick: (e: MouseEvent<HTMLDivElement>) => void;
handleCommentSubmit: (id: string) => void;
handleCommentResolve: (id: string) => void;
};
export const INITIAL_COMMENT_PIN_AREA_CONTEXT_DATA: CommentPinAreaContextType = {
comments: [],
activeComment: null as unknown as string,
mousePosition: { x: 0, y: 0 },
newCommentInput: '',
setComments: function (_comment: React.SetStateAction<Comment[]>): void {
throw new Error('Function not implemented.');
},
setActiveComment: function (_value: React.SetStateAction<string>): void {
throw new Error('Function not implemented.');
},
setMousePosition: function (
_position: React.SetStateAction<MousePosition>,
): void {
throw new Error('Function not implemented.');
},
setNewCommentInput: function (_value: React.SetStateAction<string>): void {
throw new Error('Function not implemented.');
},
handleCanvasClick: function (_e: MouseEvent<HTMLDivElement>): void {
throw new Error('Function not implemented.');
},
handleCommentSubmit: function (_id: string): void {
throw new Error('Function not implemented.');
},
handleCommentResolve: function (_id: string): void {
throw new Error('Function not implemented.');
},
};
export const CommentPinAreaContext = createContext<CommentPinAreaContextType>({
...INITIAL_COMMENT_PIN_AREA_CONTEXT_DATA,
});
typescript
Step 3: Building the Provider
The provider component is where the magic happens. It manages all the state and logic:
'use client';
import { MouseEvent, ReactNode, useState } from 'react';
import { MousePosition, Comment, COMMENT_MODE } from './types';
import {
CommentPinAreaContext,
CommentPinAreaContextType,
INITIAL_COMMENT_PIN_AREA_CONTEXT_DATA,
} from './comment-pin-area-context';
export function CommentPinAreaProvider({ children }: { children: ReactNode }) {
const [comments, setComments] = useState<Comment[]>(
INITIAL_COMMENT_PIN_AREA_CONTEXT_DATA.comments,
);
const [activeComment, setActiveComment] = useState<string>(
INITIAL_COMMENT_PIN_AREA_CONTEXT_DATA.activeComment,
);
const [mousePosition, setMousePosition] = useState<MousePosition>(
INITIAL_COMMENT_PIN_AREA_CONTEXT_DATA.mousePosition,
);
const [newCommentInput, setNewCommentInput] = useState<string>(
INITIAL_COMMENT_PIN_AREA_CONTEXT_DATA.newCommentInput,
);
const handleCanvasClick = (e: MouseEvent<HTMLDivElement>) => {
if (activeComment) {
// If the active comment is empty, remove it
const activeCommentData = comments.find(
(comment) => comment.id === activeComment,
);
if (activeCommentData?.content.trim() === '') {
setComments(comments.filter((comment) => comment.id !== activeComment));
}
setNewCommentInput('');
setActiveComment(null as unknown as string);
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setMousePosition({ x, y });
const newComment: Comment = {
id: Date.now().toString(),
x,
y,
content: '',
mode: COMMENT_MODE.EDIT,
};
// Updating comments list
setComments([...comments, newComment]);
setActiveComment(newComment.id);
};
const handleCommentSubmit = (id: string) => {
if (newCommentInput.trim()) {
setComments(
comments.map((comment) => {
return comment.id === id
? {
...comment,
content: newCommentInput as string,
mode: COMMENT_MODE.IDLE,
}
: comment;
}),
);
setNewCommentInput('' as string);
setActiveComment(null as unknown as string);
} else {
setComments(comments.filter((comment) => comment.id !== id));
setActiveComment(null as unknown as string);
}
};
const handleCommentResolve = (id: string) => {
setComments(comments.filter((comment) => comment.id !== id));
setActiveComment(null as unknown as string);
};
const PROVIDER_DATA: CommentPinAreaContextType = {
comments,
setComments,
activeComment,
setActiveComment,
mousePosition,
setMousePosition,
newCommentInput,
setNewCommentInput,
handleCanvasClick,
handleCommentResolve,
handleCommentSubmit,
};
return (
<CommentPinAreaContext.Provider value={PROVIDER_DATA}>
{children}
</CommentPinAreaContext.Provider>
);
}
typescript
This provider handles:
- Creating new comments at click positions
- Managing comment state transitions
- Handling comment submission and resolution
Step 4: Creating the Comment Pin Area
Now for the visual component that users interact with:
export function CommentPinArea({ allowComment }: CommentPinAreaProps) {
const { comments, handleCanvasClick } = useContext(CommentPinAreaContext);
return (
<section className="comment-accessible-section w-full h-full overflow-hidden">
<div
className="comment-pinable-canvas relative w-full h-full"
onClick={manageHandleCanvasClick}>
{comments.map((comment) => (
<CommentItem key={comment.id} {...comment} />
))}
</div>
</section>
);
}typescript
This component creates a canvas-like area where users can click to add comments. It renders all existing comments using the CommentItem component.
Step 5: Building the Comment Item
The CommentItem component is where we implement our Dynamic Island-style UI:
function CommentItem(comment: Comment) {
const {
activeComment,
handleCommentResolve,
newCommentInput,
setNewCommentInput,
handleCommentSubmit,
setActiveComment,
} = useContext(CommentPinAreaContext);
const handleTextareaKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && !newCommentInput.trim()) {
handleCommentResolve(comment.id);
}
};
const handleNewCommentChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setNewCommentInput(e.target.value as string);
};
const autoResize = (event: ChangeEvent<HTMLTextAreaElement>) => {
const textarea = event.target;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
handleNewCommentChange(event);
};
const handleMarkAsResolved = () => {
handleCommentResolve(comment.id);
};
return (
<motion.div
className="absolute flex items-start gap-2 cursor-auto w-fit h-fit"
style={{
left: comment.x - 12,
top: comment.y - 12,
}}>
<motion.button
onClick={(e) => {
e.stopPropagation();
setActiveComment(comment.id);
}}
initial={{
opacity: 0,
scale: 0.2,
}}
animate={{
opacity: 1,
scale: 1,
}}
className={cn(
'w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center p-1 z-10',
comment.content.trim() &&
comment.mode === COMMENT_MODE.IDLE &&
'hover:bg-gray-300',
)}>
<Image
src={`https://github.com/${TEST_USER}.png`}
alt={TEST_USER}
width={60}
height={60}
className="w-full h-full rounded-full"
/>
</motion.button>
{activeComment === comment.id && (
<motion.div
onClick={(e) => e.stopPropagation()}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: 'tween', duration: 0.2 }}
className={cn(
'w-[240px] rounded-xl bg-gradient-to-b from-black to-neutral-800 shadow-2xl relative z-20',
comment.mode === COMMENT_MODE.EDIT && 'mt-1',
)}>
{comment.mode === COMMENT_MODE.EDIT ? (
<textarea
value={newCommentInput}
onChange={autoResize}
onKeyDown={handleTextareaKeyDown}
autoFocus
className="hide-scroll resize-none p-3 min-h-[40px] max-h-[120px] bg-transparent font-sans leading-snug text-xs focus:outline-none text-white selection:bg-pink-500 selection:text-white w-full"
placeholder="Drop a comment..."
/>
) : (
<>
<p className="text-xs text-white selection:bg-pink-500 selection:text-white font-sans p-2 mb-10">
{comment.content}
</p>
<button
className="text-neutral-400 text-xs font-sans font-medium absolute bottom-2 right-2 bg-white/10 py-1 px-2 rounded-md hover:bg-white/15 active:bg-white/20"
onClick={handleMarkAsResolved}>
Mark as resolved
</button>
</>
)}
{newCommentInput && (
<div className="py-1.5 px-2 border-t border-white/10 flex items-center justify-end">
<motion.button
className="p-1 rounded-full bg-blue-500 text-white"
onClick={() => handleCommentSubmit(comment.id)}>
<IconArrowRight size={14} strokeWidth={2.5} />
</motion.button>
</div>
)}
</motion.div>
)}
</motion.div>
);
}typescript
ending notes
This Dynamic Island-style comment component shows how modern React features, TypeScript, and animation libraries can create a polished, interactive UI. The combination of Context for state management, Framer Motion for animations, and TypeScript for type safety results in a robust, maintainable component.