tap-react
v1.1.1
Published
A highly customizable animated reaction system for React (like Facebook, LinkedIn, and X reactions). Built with React, TypeScript, and Framer Motion.
Downloads
444
Maintainers
Readme
Tap React
Tap, React, Engage. A beautifully animated reaction system that transforms static interactions into delightful moments.
📦 Reactions Source:
Seesrc/samples/reactions.tsxandsrc/samples/sounds/for full reaction and sound examples used for reference and testing.
Table of Contents
- Overview
- Features
- Quick Start
- Styling
- Real-World Example: Social Feed with JSON Server
- Sound Effects
- Advanced Patterns
- API Reference
- Contributing
- License
- Acknowledgments
Overview
Tap React brings the polish of Facebook, LinkedIn, and Medium reactions to your application — built with React, TypeScript, and Framer Motion. Whether it's a heartfelt ❤️ on a friend's photo, a thoughtful 💡 on an article, or a celebratory 🎉 on a milestone, every interaction feels alive.
Features
| Feature | Description | |---------|-------------| | 🎯 Smart State Management | Controlled and uncontrolled modes with optimistic updates | | ⚡ Buttery Animations | Spring-powered micro-interactions that feel natural | | 🎨 Pixel-Perfect Customization | Style every element — button, menu, icons, tooltips | | 📍 Smart Positioning | Menu appears exactly where users expect (top/bottom, start/center/end) | | 🎭 Per-Reaction Personality | Individual styles, colors, and tooltips for each reaction | | 🔊 Sound Feedback | Optional audio cues for hover and click interactions | | 📱 Responsive by Design | Works beautifully on any screen size | | 🔄 Revert on Failure | Built-in optimistic UI with automatic rollback | | 💪 Type-Safe | Full TypeScript support with intelligent autocomplete | | 🎨 Zero Runtime CSS | Bring your own styles or use the defaults |
Quick Start
npm install framer-motion tap-reactimport { ReactionButton } from 'tap-react'
import { ThumbsUp, Heart } from "lucide-react";
const reactions = [
{ id: "like", label: "Like", icon: <ThumbsUp /> },
{ id: "love", label: "Love", icon: <Heart /> },
];
function App() {
return (
<ReactionButton
reactions={reactions}
displayMode="both"
onReactionSelect={(id, { revert }) => {
console.log("User reacted with:", id);
// Call revert() if your API call fails
}}
/>
);
}Styling
⚠️ Important: className Override Behavior
When you provide any className prop, it completely replaces the default styling — it does not merge or extend. You must provide all necessary styles yourself.
// ❌ This will lose all default button styling
<ReactionButton
classNames={{
button: "my-custom-class"
}}
/>
// ✅ Provide all necessary styles for each element
classNames={{
button: "flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg",
text: "text-sm font-medium text-gray-700",
icon: "text-xl",
menu: "flex gap-4 bg-white rounded-xl shadow-lg border p-2 min-w-[200px] z-50",
menuWrapper: "relative bg-white",
menuItem: "flex items-center gap-2 rounded-lg hover:bg-gray-50",
menuIcon: "flex items-center justify-center w-10 h-10 rounded-lg hover:bg-gray-100",
tooltip: "absolute -top-10 -translate-x-1/2 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap"
}}Style Priority
Styles cascade from most specific to most general:
afterReactionClassNames → Per-Reaction classNames → Main Config classNames → Defaults
(highest priority) (lowest priority)const mainClassNames = {
button: "px-4 py-2 rounded-lg bg-gray-100",
menuIcon: "w-10 h-10 rounded-lg bg-gray-100",
menuItem: "p-2 rounded-md"
};
const reactions = [
{
id: "like",
label: "Like",
icon: <Heart />,
classNames: {
menuIcon: "bg-red-100 text-red-500", // Overrides main config for this reaction's menu icon
},
afterReactionClassNames: {
button: "px-4 py-2 rounded-lg bg-blue-100 text-blue-600 font-semibold", // Active state for button
icon: "text-blue-500", // Active state for icon
text: "text-blue-600", // Active state for text
}
},
];
<ReactionButton reactions={reactions} classNames={mainClassNames} />Style Notes
Ensure the menuWrapper always has a background if overidden or equivalent background, as this improves animation stability and prevents visual glitches during transitions.
⚠️ Critical: Syncing classNames and afterReactionClassNames
afterReactionClassNames completely replaces the corresponding classNames entry when a reaction is active — it does not extend or merge. This means any style you set in classNames but not in afterReactionClassNames will be lost when the reaction is selected, and vice versa.
You must deliberately carry over all shared base styles into both.
// ❌ BROKEN — button loses its shape/padding when selected
const reactions = [
{
id: "like",
label: "Like",
icon: <Heart />,
afterReactionClassNames: {
button: "text-blue-600 font-semibold", // Missing layout styles!
}
}
];
<ReactionButton
reactions={reactions}
classNames={{
button: "flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-100",
}}
/>
// When "like" is selected, the button becomes "text-blue-600 font-semibold"
// — all the padding, rounding, and layout disappear.// ✅ CORRECT — base styles are repeated in afterReactionClassNames, only color differs
const reactions = [
{
id: "like",
label: "Like",
icon: <Heart />,
afterReactionClassNames: {
button: " items-center gap-2 px-4 py-2 rounded-lg bg-blue-50 text-blue-600 font-semibold",
// ^^^ same layout as classNames.button ^^^ ^^^ only this part changed ^^^
icon: "text-xl text-blue-500",
text: "text-sm font-semibold text-blue-600",
}
}
];
<ReactionButton
reactions={reactions}
classNames={{
button: "flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-100",
icon: "text-xl text-gray-500",
text: "text-sm font-medium text-gray-700",
}}
/>Rule of thumb: Start by copying your
classNamesvalues intoafterReactionClassNames, then only change what should look different in the active/selected state.
Real-World Example: Social Feed with JSON Server
Step 1: Set Up JSON Server
npm install -g json-serverCreate db.json:
{
"posts": [
{ "id": "1", "author": "Sarah Johnson", "content": "Just launched my new portfolio! 🚀", "reactionId": "love" },
{ "id": "2", "author": "Mike Chen", "content": "React 19 is out! 🔥", "reactionId": "" },
{ "id": "3", "author": "Emma Watson", "content": "Beautiful sunset today in Barcelona 🌅", "reactionId": "like" }
]
}json-server --watch db.json --port 3000Step 2: Build the Feed Component
import { useEffect, useState } from "react";
import { ReactionButton } from 'tap-react';
import { ThumbsUp, Heart, Gift, Sparkles, Flame } from "lucide-react";
const reactions = [
{ id: "like", label: "Like", icon: <ThumbsUp /> },
{ id: "love", label: "Love", icon: <Heart /> },
{ id: "celebrate", label: "Celebrate", icon: <Gift /> },
{ id: "insightful", label: "Insightful", icon: <Sparkles /> },
{ id: "fire", label: "Fire", icon: <Flame /> },
];
type Post = {
id: string;
author: string;
content: string;
reactionId: string;
};
const PostCard = ({ post, onUpdate }: {
post: Post;
onUpdate: (id: string, reactionId: string) => void;
}) => {
const [loading, setLoading] = useState(false);
const handleReaction = async (id: string, { revert }: { revert: () => void }) => {
setLoading(true);
try {
onUpdate(post.id, id); // Optimistic update
await fetch(`http://localhost:3000/posts/${post.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reactionId: id }),
});
} catch (err) {
revert(); // Rollback on failure
console.error("Failed to save reaction:", err);
} finally {
setLoading(false);
}
};
return (
<div className="bg-white rounded-xl shadow-sm border p-4 hover:shadow-md transition">
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-gray-800">{post.author}</h3>
<span className="text-xs text-gray-400">Just now</span>
</div>
<p className="text-gray-600 mb-3">{post.content}</p>
<ReactionButton
displayMode="both"
currentReactionId={post.reactionId}
disabled={loading}
reactions={reactions}
onReactionSelect={handleReaction}
menuPosition={{ side: "top", align: "start" }}
scaleConfig={{ hoverScale: 1.6, shrinkFactor: 0.7, shouldShrink: true, scaleType: "center" }}
/>
</div>
);
};
const PostList = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { fetchPosts(); }, []);
const fetchPosts = async () => {
try {
const res = await fetch("http://localhost:3000/posts");
setPosts(await res.json());
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
setLoading(false);
}
};
const handleUpdate = (id: string, reactionId: string) => {
setPosts(prev => prev.map(p => p.id === id ? { ...p, reactionId } : p));
};
if (loading) return <div>Loading posts...</div>;
return (
<div className="space-y-4 max-w-xl mx-auto">
{posts.map(post => (
<PostCard key={post.id} post={post} onUpdate={handleUpdate} />
))}
</div>
);
};
export default PostList;This pattern gives you optimistic updates (instant feedback), automatic rollback on failure, loading state protection against double-clicks, and persistent reactions across page refreshes.
Sound Effects
Tap React supports three sound trigger modes:
| Mode | Description |
|------|-------------|
| "click" | Sound plays automatically when a reaction is selected |
| "hover" | Sound plays when hovering over reaction options |
| "manual" | You control exactly when sound plays via a callback |
Click Mode
import { ThumbsUp, Heart } from "lucide-react";
const reactions = [
{ id: "like", label: "Like", icon: <ThumbsUp />, sound: likeSound },
{ id: "love", label: "Love", icon: <Heart />, sound: loveSound },
];
<ReactionButton
reactions={reactions}
soundConfig={{ enabled: true, playOn: "click" }}
onReactionSelect={(id, { revert }) => {
updateReaction(id).catch(() => revert());
}}
/>Hover Mode
<ReactionButton
reactions={reactions}
soundConfig={{ enabled: true, playOn: "hover" }}
/>Manual Mode
Manual mode gives you full control — play sound only after a successful API call, or conditionally based on user preferences.
import { useRef } from "react";
import { ThumbsUp, Heart } from "lucide-react";
const reactions = [
{ id: "like", label: "Like", icon: <ThumbsUp /> },
{ id: "love", label: "Love", icon: <Heart /> },
];
const PostCard = ({ post, onUpdate }) => {
const [loading, setLoading] = useState(false);
const playSoundRef = useRef<(() => void) | null>(null);
const handleReaction = async (id: string, { revert }: { revert: () => void }) => {
setLoading(true);
try {
onUpdate(post.id, id);
await fetch(`http://localhost:3000/posts/${post.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reactionId: id }),
});
playSoundRef.current?.(); // ✅ Only plays on success
} catch (err) {
console.error("Failed to update reaction:", err);
revert(); // ❌ No sound on failure
} finally {
setLoading(false);
}
};
return (
<ReactionButton
reactions={reactions}
soundConfig={{
enabled: true,
playOn: "manual",
onManualTrigger: (playSound) => {
playSoundRef.current = playSound;
}
}}
onReactionSelect={handleReaction}
/>
);
};Advanced Patterns
Analytics Tracking
import { ThumbsUp, Heart } from "lucide-react";
const reactions = [
{ id: "like", label: "Like", icon: <ThumbsUp /> },
{ id: "love", label: "Love", icon: <Heart /> },
];
<ReactionButton
reactions={reactionGroups.socialReactions}
onReactionSelect={(id, { revert }) => {
analytics.track('reaction', {
reactionId: id,
timestamp: Date.now(),
context: 'post_feed'
});
updateReaction(id).catch(() => revert());
}}
/>Conditional Display Modes
<ReactionButton displayMode="icon" /> // Mobile: compact
<ReactionButton displayMode="both" /> // Desktop: icon + label
<ReactionButton displayMode="text" /> // Accessibility: text onlyCustom Animation Timing
<ReactionButton
scaleConfig={{
hoverScale: 1.8,
shrinkFactor: 0.5,
shouldShrink: true,
scaleType: "center"
}}
animationConfig={{
button: true,
menu: true,
items: true
}}
/>⚠️ Animation and CSS Transition Conflicts
When Framer Motion animations are enabled, do not add CSS transitions to the same elements. Mixing both causes jitter, doubled animations, and layout shifts.
// ❌ Will conflict
<ReactionButton
animationConfig={{ button: true, menu: true, items: true }}
classNames={{
button: "transition-all duration-300",
menuIcon: "transition-transform"
}}
/>
// ✅ Let Framer Motion handle everything
<ReactionButton
animationConfig={{ button: true, menu: true, items: true }}
classNames={{
button: "",
menuIcon: ""
}}
/>
// ✅ Or disable Framer Motion and use CSS transitions
<ReactionButton
animationConfig={{ button: false, menu: false, items: false }}
classNames={{
button: "transition-all duration-300",
menuIcon: "transition-transform duration-200"
}}
/>API Reference
ReactionButton Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| reactions | Reaction[] | Required | Array of reaction options |
| currentReactionId | string | "" | Currently selected reaction ID |
| disabled | boolean | false | Disables all interactions |
| displayMode | "icon" \| "text" \| "both" | "icon" | Button display style |
| onReactionSelect | (id: string, { revert }) => void | Required | Callback when reaction changes |
| enableTooltip | boolean | true | Show tooltips on hover |
| menuPosition | { side, align } | { side: "top", align: "start" } | Menu positioning |
| scaleConfig | ScaleConfig | See below | Animation scale behavior |
| animationConfig | AnimationConfig | All true | Toggle specific animations |
| soundConfig | SoundConfig | undefined | Sound effect configuration |
| classNames | ClassNames | — | Custom styling overrides |
ScaleConfig
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| hoverScale | number | 1.25 | Scale factor when hovering |
| shrinkFactor | number | 0.7 | Scale factor for non-hovered items |
| shouldShrink | boolean | true | Whether to shrink non-hovered items |
| scaleType | "up" \| "down" \| "center" | "center" | Animation direction |
AnimationConfig
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| button | boolean | true | Enable button tap/hover animations |
| menu | boolean | true | Enable menu entrance animation |
| items | boolean | true | Enable individual reaction animations |
SoundConfig
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| enabled | boolean | false | Enable sound effects |
| playOn | "click" \| "hover" \| "manual" | undefined | When to trigger sounds |
| onManualTrigger | (playSound) => void | undefined | Required when playOn="manual" — receives the play function |
ClassNames
| Property | Description |
|----------|-------------|
| button | Main button container |
| text | Main button text |
| icon | Main button icon |
| menu | Menu container |
| menuWrapper | Wrapper element for positioning context |
| menuItem | Individual reaction row in the menu |
| menuIcon | Icon inside each menu item |
| tooltip | Tooltip element |
Reaction Type
type Reaction = {
id: string; // Unique identifier
label: string; // Display text
icon: React.ReactNode; // React icon component (e.g., from lucide-react)
sound?: string; // Optional sound file URL
classNames?: { // Per-reaction style overrides
menuItem?: string;
menuIcon?: string;
tooltip?: string;
};
afterReactionClassNames?: { // Styles applied when this reaction is active (selected state)
button?: string; // ⚠️ Replaces classNames.button entirely — carry over shared base styles
icon?: string; // ⚠️ Replaces classNames.icon entirely — carry over shared base styles
text?: string; // ⚠️ Replaces classNames.text entirely — carry over shared base styles
};
};See the Style Syncing section above for the full breakdown of how
afterReactionClassNamesinteracts withclassNamesand how to avoid losing styles on selection.
Contributing
- Fork the repo and create your feature branch
- Write code and add tests for new features
- Update documentation where needed
- Submit a pull request with a clear description
License
Tap React is licensed under the MIT License.
See the LICENSE file for full details.
Acknowledgments
Inspired by the reaction systems on Facebook, LinkedIn, and Medium. Powered by Framer Motion. Icons by Lucide React.
Issues & Discussions: GitHub
