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.