Drop-in React feedback layer for collecting contextual user feedback
npm install @kendew-agency/react-feedback-layerA drop-in React feedback layer for collecting contextual user feedback through interactive comments and annotations. Perfect for design review tools, bug reporting systems, and collaborative annotation platforms.
Have a look at the changelog when updating the package.
- 🎯 Contextual Feedback - Click or drag to create comments anywhere on your UI
- 💬 Comment Lifecycle - Draft → Published → Resolving → Resolved states
- 👥 User Attribution - Track who created each comment with timestamps
- 🎨 Fully Customizable - Headless UI with complete styling control
- 📦 TypeScript First - Full type definitions included
- âš¡ Minimal Dependencies - Only React 18+ and Radix UI Slot
- 🔧 Compound Components - Flexible composition patterns
- 🎪 Visual Indicators - Highlight specific UI areas with selection boxes
- 🔄 Real-time Sync - Optional subscription support for live updates
``bash`
npm install @kendew-agency/react-feedback-layer
This package requires React 18 or higher:
`bash`
npm install react react-dom
`tsx
import {
CommentContextProvider,
CommentOverlay,
CommentRenderer,
} from '@kendew-agency/react-feedback-layer';
function App() {
const handleConfirm = async (comments) => {
// Save comments to your database
await saveToDatabase(comments);
};
const handleResolve = async (comments) => {
// Mark comments as resolved in your database
await resolveInDatabase(comments);
};
return (
onConfirm={handleConfirm}
onResolve={handleResolve}
initialComments={[]}
>
DraftComment={YourDraftComponent}
ResolvingComment={YourResolvingComponent}
ResolvedComment={YourResolvedComponent}
/>
);
}
`
Comments flow through four distinct states:
1. Draft - Newly created, not yet saved
2. Published - Confirmed and saved to your database
3. Resolving - Marked for resolution (in progress)
4. Resolved - Completed and archived
This library provides the logic and structure without imposing any styling. You have complete control over how comments look and behave by providing your own component implementations.
The Comment component uses a compound pattern for maximum flexibility:
`tsx`
#### CommentContextProvider
The root provider that manages all comment state and actions.
Props:
`tsx`
interface CommentOverlayProps {
// Initial comments from your database
initialComments?: ConfirmedComment[];
// Current logged-in user
currentUser: User | null;
// Your app content
children: ReactNode;
// Initial overlay state (default: "inactive")
initialOverlayState?: "idle" | "editing" | "saving" | "error" | "resolving" | "inactive";
// Optional real-time subscription
subscription?: {
subscribe: (update: unknown) => void;
unsubscribe: () => void;
};
// Callback when user confirms draft comments
onConfirm: (comments: ConfirmedComment[]) => Promise
// Callback when user resolves comments
onResolve: (comments: ConfirmedComment[]) => Promise
// Callback to handle errors (optional)
onError?: (error: KnownError) => void;
// Optional configuration for the comment layer
config?: {
// Custom prefix for comment IDs
idPrefix?: string;
// Visibility of comment indicators: "always" | "active"
indicatorVisibility?: "always" | "active";
// Comment visibility settings
commentVisibility?: {
hideResolved?: boolean;
hideResolving?: boolean;
};
};
}
Example:
`tsx`
initialComments={existingComments}
onConfirm={async (comments) => {
await api.createComments(comments);
}}
onResolve={async (comments) => {
await api.resolveComments(comments);
}}
onError={(error) => {
console.error('Comment operation failed:', error);
toast.error('Failed to save comments');
}}
initialOverlayState="idle"
config={{
idPrefix: 'proj_',
indicatorVisibility: 'active',
commentVisibility: {
hideResolved: false,
hideResolving: false,
},
}}
>
{children}
#### CommentOverlay
Creates an interactive overlay for adding comments via click or drag selection.
Props:
`tsx`
interface CommentOverlayProps {
children: ReactNode;
}
Behavior:
- Click - Creates a comment at the clicked position
- Drag - Creates a comment with a visual indicator box
- Inactive State - Renders children without overlay when overlayState === "inactive"
Example:
`tsx`
Your annotatable content
#### CommentRenderer
Renders all comments based on their current state.
Props:
`tsx`
interface CommentRendererProps {
// Component for published comments
Comment: ElementType<{ comment: CommentType }>;
// Component for draft comments
DraftComment: ElementType<{ comment: CommentType }>;
// Component for resolving comments
ResolvingComment: ElementType<{ comment: CommentType }>;
// Component for resolved comments
ResolvedComment: ElementType<{ comment: CommentType }>;
}
Example:
`tsx`
DraftComment={DraftComment}
ResolvingComment={ResolvingComment}
ResolvedComment={ResolvedComment}
/>
#### Comment (Compound Component)
A collection of sub-components for building custom comment UIs.
##### Comment.Root
Container for a single comment with positioning and active state management.
Props:
`tsx`
interface CommentRootProps extends HTMLProps
comment: CommentType;
children?: ReactNode;
defaultDraft?: string; // Initial draft content
}
Example:
`tsx`
{/ Sub-components /}
##### Comment.Indicator
Visual indicator showing the area a comment refers to (when created via drag).
Props:
`tsx`
interface CommentIndicatorProps extends HTMLProps
Example:
`tsx`
##### Comment.Content
Displays the comment text content.
Props:
`tsx`
interface CommentContentProps extends HTMLProps
asChild?: boolean; // Use Radix Slot for custom element
}
Example:
`tsx`
##### Comment.Edit
Textarea for editing comment content.
Props:
`tsx`
interface CommentEditProps extends HTMLProps
asChild?: boolean;
}
Example:
`tsx`
placeholder="Write your feedback..."
/>
##### Comment.Confirm
Button to save/confirm comment changes.
Props:
`tsx`
interface CommentConfirmProps extends ButtonHTMLAttributes
asChild?: boolean;
}
Example:
`tsx`
Save Comment
##### Comment.Delete
Button to delete a comment.
Props:
`tsx`
interface CommentDeleteProps extends ButtonHTMLAttributes
asChild?: boolean;
}
Example:
`tsx`
Delete
##### Comment.Resolve
Button to toggle resolving state (only for published/resolving comments).
Props:
`tsx`
interface CommentResolveProps extends ButtonHTMLAttributes
asChild?: boolean;
}
Example:
`tsx`
Mark as Resolved
#### Action Components
##### ToggleOverlayButton
Toggles the comment overlay on/off.
Props:
`tsx`
interface CommentButtonProps extends ButtonHTMLAttributes
asChild?: boolean;
}
Example:
`tsx
import { ToggleOverlayButton } from '@kendew-agency/react-feedback-layer';
Toggle Comments
`
##### ConfirmComments
Confirms all draft comments and triggers the onConfirm callback.
Example:
`tsx
import { ConfirmComments } from '@kendew-agency/react-feedback-layer';
Save All Comments
`
##### ResolveComments
Resolves all resolving comments and triggers the onResolve callback.
Example:
`tsx
import { ResolveComments } from '@kendew-agency/react-feedback-layer';
Resolve All
`
#### useComments()
Access the global comment context and actions.
Returns:
`tsx`
interface CommentContext {
// Filtered comment arrays
draftComments: CommentType[];
comments: CommentType[]; // Published comments
resolvingComments: CommentType[];
resolvedComments: CommentType[];
allComments: CommentType[];
// State
overlayState: CommentOverlayState;
currentUser: User | null;
config: Config | undefined;
// Actions
registerComment: (position: Position, indicatorPosition?: Indicator) => void;
updateComment: (id: string, newComment: Pick
deleteComment: (id: string) => void;
focusOnComment: (id: string | null) => void;
getActiveComment: () => CommentType | undefined;
toggleOverlay: () => void;
toggleResolvingComment: (id: string) => void;
changeOverlayState: (state: CommentOverlayState) => void;
confirmComments: () => Promise<{ error: Error | DOMException | null }>;
resolveComments: () => Promise<{ error: Error | DOMException | null }>;
updateCommentVisibility: (visibility: Partial
}
Example:
`tsx
import { useComments } from '@kendew-agency/react-feedback-layer';
function CommentToolbar() {
const {
draftComments,
confirmComments,
overlayState,
toggleOverlay,
config
} = useComments();
return (
####
useCommentScope()Access scoped data for a specific comment (must be used within
Comment.Root).Returns:
`tsx
interface CommentScope {
comment: CommentType;
isActive: boolean;
draft: string;
setDraft: React.Dispatch>;
}
`Example:
`tsx
import { useCommentScope } from '@kendew-agency/react-feedback-layer';function CustomCommentContent() {
const { comment, isActive, draft } = useCommentScope();
return (
{draft}
By {comment.user?.name}
);
}
`$3
Import types from the
/types subpath:`tsx
import type {
CommentType,
ConfirmedComment,
User,
Position,
Indicator,
CommentOverlayState,
CommentVisibility,
CommentAction,
Config
} from '@kendew-agency/react-feedback-layer/types';
`####
CommentType`tsx
type CommentType = {
id: string;
position: Position;
content: string;
user: User | null;
createdAt: Date;
resolvedAt?: Date;
status: "draft" | "published" | "resolving" | "resolved";
indicator?: Indicator | null;
};
`####
User`tsx
type User = {
id?: string;
name: string;
avatar?: string;
};
`####
Position`tsx
type Position = {
x: number;
y: number;
};
`####
Indicator`tsx
type Indicator = {
width: number;
height: number;
};
`####
CommentVisibility`tsx
type CommentVisibility = {
hideResolved?: boolean;
hideResolving?: boolean;
};
`####
Config`tsx
type Config = {
// Custom prefix for comment IDs
idPrefix?: string;
// Visibility of comment indicators
indicatorVisibility?: "always" | "active";
// Comment visibility settings
commentVisibility?: CommentVisibility;
};
`Usage Examples
$3
`tsx
import {
CommentContextProvider,
CommentOverlay,
CommentRenderer,
Comment,
ToggleOverlayButton,
ConfirmComments,
} from '@kendew-agency/react-feedback-layer';function DraftComment({ comment }) {
return (
Save
Cancel
);
}
function PublishedComment({ comment }) {
return (
{comment.user?.name}
{comment.createdAt.toLocaleDateString()}
Resolve
Delete
);
}
function App() {
return (
currentUser={{ name: 'John Doe', id: '123' }}
onConfirm={async (comments) => {
const response = await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify(comments),
});
if (!response.ok) {
throw new Error('Failed to save comments');
}
}}
onResolve={async (comments) => {
const response = await fetch('/api/comments/resolve', {
method: 'POST',
body: JSON.stringify(comments),
});
if (!response.ok) {
throw new Error('Failed to resolve comments');
}
}}
onError={(error) => {
console.error('Comment operation failed:', error);
// Show user-friendly error message
}}
config={{
commentVisibility: {
hideResolved: false,
hideResolving: false,
},
}}
>
Toggle Feedback Mode
Save All Comments
Comment={PublishedComment}
DraftComment={DraftComment}
ResolvingComment={PublishedComment}
ResolvedComment={PublishedComment}
/>
);
}
`$3
`tsx
import { useEffect, useState } from 'react';function App() {
const [subscription, setSubscription] = useState(null);
useEffect(() => {
// Example with a WebSocket or real-time database
const sub = {
subscribe: (callback) => {
socket.on('comments:update', callback);
},
unsubscribe: () => {
socket.off('comments:update');
},
};
setSubscription(sub);
}, []);
return (
currentUser={currentUser}
subscription={subscription}
onConfirm={saveComments}
onResolve={resolveComments}
>
{/ ... /}
);
}
`$3
`tsx
import { useComments } from '@kendew-agency/react-feedback-layer';function CommentToolbar() {
const {
draftComments,
resolvingComments,
config,
updateCommentVisibility,
confirmComments,
resolveComments,
overlayState,
} = useComments();
return (
{draftComments.length} drafts
{resolvingComments.length} resolving
onClick={confirmComments}
disabled={draftComments.length === 0 || overlayState === 'saving'}
>
{overlayState === 'saving' ? 'Saving...' : 'Save Comments'}
onClick={resolveComments}
disabled={resolvingComments.length === 0}
>
Resolve All
);
}
`$3
The
asChild prop (powered by Radix UI Slot) lets you render components as different elements:`tsx
import { Comment } from '@kendew-agency/react-feedback-layer';function CustomComment({ comment }) {
return (
{/ Render as a custom button /}
Save Comment
{/ Render content as a div /}
);
}
`Configuration Options
The
config prop allows you to customize various aspects of the comment system:$3
Add a custom prefix to all comment IDs:
`tsx
config={{
idPrefix: 'proj_a_', // Results in IDs like "proj_a_uuid"
}}
// ... other props
>
`$3
Control when comment indicators (selection boxes) are shown:
`tsx
config={{
indicatorVisibility: 'active', // Only show when comment is active
// or 'always' to always show indicators
}}
// ... other props
>
`$3
Hide specific types of comments from the UI:
`tsx
config={{
commentVisibility: {
hideResolved: true, // Hide resolved comments
hideResolving: false, // Show resolving comments
},
}}
// ... other props
>
`You can also update visibility dynamically:
`tsx
function VisibilityControls() {
const { config, updateCommentVisibility } = useComments();
return (
);
}
`Advanced Patterns
$3
`tsx
import { useComments } from '@kendew-agency/react-feedback-layer';function CustomTool() {
const { registerComment } = useComments();
const addCommentAtElement = (elementId: string) => {
const element = document.getElementById(elementId);
if (!element) return;
const rect = element.getBoundingClientRect();
registerComment(
{ x: rect.right, y: rect.bottom },
{ width: rect.width, height: rect.height }
);
};
return (
);
}
`$3
You can handle errors in two ways:
1. Using the
onError callback (recommended):`tsx
currentUser={currentUser}
onConfirm={saveComments}
onResolve={resolveComments}
onError={(error) => {
console.error('Comment operation failed:', error);
// Show user-friendly error message
toast.error('Failed to save comments. Please try again.');
}}
>
{children}
`2. Handling errors from action results:
`tsx
import { useComments } from '@kendew-agency/react-feedback-layer';function SaveButton() {
const { confirmComments, overlayState } = useComments();
const handleSave = async () => {
const { error } = await confirmComments();
if (error) {
console.error('Failed to save comments:', error);
alert('Failed to save comments. Please try again.');
} else {
alert('Comments saved successfully!');
}
};
return (
onClick={handleSave}
disabled={overlayState === 'saving'}
>
{overlayState === 'saving' ? 'Saving...' : 'Save Comments'}
);
}
`#### Error codes
To keep track of the origin of a comment an error code is provided. You can use this code to display a more accurate error message. For example:
`ts
switch(e.code){
case "RESOLVE_ERROR":
alert('Failed to resolve your comments')
break
case "CONFIRM_ERROR":
alert('Failed to submit your comments')
break
default:
alert('An unknown error occured')
}
`$3
`tsx
import { useComments } from '@kendew-agency/react-feedback-layer';
import { useState } from 'react';function CommentList() {
const { allComments } = useComments();
const [filter, setFilter] = useState('');
const filteredComments = allComments.filter(comment =>
comment.content.toLowerCase().includes(filter.toLowerCase()) ||
comment.user?.name.toLowerCase().includes(filter.toLowerCase())
);
return (
type="text"
placeholder="Search comments..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{filteredComments.map(comment => (
{comment.user?.name}: {comment.content}
))}
);
}
`Styling
This library is completely unstyled (headless). Here's a basic CSS example to get started:
`css
/ Comment positioning /
[data-comment] {
position: absolute;
z-index: 30;
}[data-comment][data-active="true"] {
z-index: 40;
}
/ Indicator box /
[data-parent] {
border: 2px dashed #3b82f6;
background: rgba(59, 130, 246, 0.1);
pointer-events: none;
}
/ Comment card /
.comment-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
min-width: 250px;
}
.comment-card[data-active="true"] {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/ Overlay /
[data-overlay] {
position: relative;
}
/ Guard layer /
[data-comment-guard] {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.1);
}
`Browser Support
- Modern browsers (Chrome, Firefox, Safari, Edge)
- Requires support for:
- React 18+
- CSS
position: absolute
- Pointer Events API
- crypto.randomUUID()TypeScript
This library is written in TypeScript and includes full type definitions. No additional
@types packages needed.`tsx
import type { CommentType, User } from '@kendew-agency/react-feedback-layer/types';const user: User = {
id: '123',
name: 'John Doe',
avatar: '/avatar.jpg',
};
const comment: CommentType = {
id: crypto.randomUUID(),
position: { x: 100, y: 200 },
content: 'Great work!',
user,
createdAt: new Date(),
status: 'published',
};
`Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (
git checkout -b feature/amazing-feature)
3. Commit your changes (git commit -m 'Add some amazing feature')
4. Push to the branch (git push origin feature/amazing-feature)
5. Open a Pull RequestDevelopment
`bash
Install dependencies
npm installStart development build (watch mode)
npm run devRun playground for testing
npm run playgroundType checking
npm run typecheckLinting and formatting
npm run format-and-lint:fixBuild for production
npm run build
`License
MIT © Kendew Agency
Links
- GitHub Repository
- Issue Tracker
- NPM Package
Changelog
A list if breaking changes that could impact the way you configured the package
$3
- Reworked the subscription system. The system remains in beta and may change in the future. Configurations made with version 0.1.2 or older will need adjustment after updating.
- mode` was defined twice in props. It has been removed as a root prop and is now only part of the config.