Ever wondered how to make your planning poker sessions more interactive? Let's dive into implementing a fun emoji-throwing animation system using React hooks and the Web Animation API. I'll show you how I built this feature for Kollabe, our free planning poker tool.
The Challenge
Planning poker sessions can get monotonous, especially in remote settings. While building Kollabe, we wanted to add interactive elements that would:
- Keep team members engaged
- Provide non-verbal feedback options
- Make remote sessions feel more personal
- Add a fun factor without compromising functionality
The Solution: Flying Emojis
We implemented an emoji throwing system that allows team members to react to estimates by throwing emojis across the screen. Here's how we built it:
The Complete Implementation
Here's the full, working implementation you can use in your own projects:
import { useCallback, useRef } from "react";
interface Point {
x: number;
y: number;
}
interface DOMRect {
left: number;
top: number;
width: number;
height: number;
}
/**
* Generates a random number within a specified range.
*/
const getRandomNumber = (
min = 0.1,
max = 0.4,
isPositive = Math.random() < 0.5,
): number => {
const random = Math.random() * (max - min) + min;
return isPositive ? random : -random;
};
/**
* Calculates the center position of a DOM element.
*/
const getCenterPosition = (rect: DOMRect): Point => ({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
/**
* Calculates the target position with a random offset from the center.
*/
const getTargetPosition = (rect: DOMRect): Point => {
const center = getCenterPosition(rect);
return {
x: center.x - getRandomNumber(0, 15, true),
y: center.y - getRandomNumber(0, 25, true),
};
};
/**
* Calculates the midpoint between two points.
*/
const getMidpoint = (start: Point, target: Point): Point => ({
x: (start.x + target.x) / 2,
y: (start.y + target.y) / 2,
});
/**
* Calculates the distance between two points.
*/
const getDistance = (start: Point, target: Point): number => {
return Math.sqrt(
Math.pow(target.x - start.x, 2) + Math.pow(target.y - start.y, 2),
);
};
/**
* Calculates the control point for the quadratic Bezier curve.
*/
const getControlPoint = (start: Point, target: Point): Point => {
const midpoint = getMidpoint(start, target);
const distance = getDistance(start, target);
const angle = Math.atan2(target.y - start.y, target.x - start.x);
const perpAngle = angle + Math.PI / 2;
const controlPointDistance = distance * getRandomNumber(0.2, 0.4, true);
return {
x: midpoint.x + Math.cos(perpAngle) * controlPointDistance,
y: midpoint.y + Math.sin(perpAngle) * controlPointDistance,
};
};
/**
* Creates and styles the emoji element.
*/
const createEmojiElement = (
emoji: string,
startPoint: Point,
): HTMLDivElement => {
const emojiElement = document.createElement("div");
Object.assign(emojiElement.style, {
position: "fixed",
fontSize: "24px",
pointerEvents: "none",
zIndex: "1",
left: "0",
top: "0",
transform: `translate(${startPoint.x}px, ${startPoint.y}px) translate(-50%, -50%)`,
});
emojiElement.textContent = emoji;
document.body.appendChild(emojiElement);
return emojiElement;
};
/**
* Calculates a point on a quadratic Bezier curve.
*/
const calculateBezierPoint = (
start: Point,
control: Point,
target: Point,
progress: number,
): Point => {
const easeProgress = 1 - Math.pow(1 - progress, 2);
return {
x:
Math.pow(1 - easeProgress, 2) * start.x +
2 * (1 - easeProgress) * easeProgress * control.x +
Math.pow(easeProgress, 2) * target.x,
y:
Math.pow(1 - easeProgress, 2) * start.y +
2 * (1 - easeProgress) * easeProgress * control.y +
Math.pow(easeProgress, 2) * target.y,
};
};
/**
* Custom hook for throwing emoji animations.
*/
export const useEmojiThrow = () => {
const animationRef = useRef<number>();
const emojiRef = useRef<HTMLDivElement | null>(null);
const throwEmoji = useCallback(
(emoji: string, sourceId: string, targetId: string) => {
const sourceEl = document.getElementById(sourceId);
const targetEl = document.getElementById(targetId);
if (!sourceEl || !targetEl) return;
const startRect = sourceEl.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const startPoint = getCenterPosition(startRect);
const targetPoint = getTargetPosition(targetRect);
const controlPoint = getControlPoint(startPoint, targetPoint);
const emojiElement = createEmojiElement(emoji, startPoint);
emojiRef.current = emojiElement;
let startTime = performance.now();
const duration = 1000; // Animation duration in milliseconds
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const position = calculateBezierPoint(
startPoint,
controlPoint,
targetPoint,
progress,
);
emojiElement.style.transform =
`translate(${position.x}px, ${position.y}px) translate(-50%, -50%)`;
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
} else {
emojiElement.style.transform =
`translate(${targetPoint.x}px, ${targetPoint.y}px) translate(-50%, -50%)`;
setTimeout(() => {
emojiElement.remove();
}, duration);
}
};
requestAnimationFrame(() => {
startTime = performance.now();
animate(startTime);
});
},
[],
);
const cleanup = useCallback(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
if (emojiRef.current) {
emojiRef.current.remove();
}
}, []);
return { throwEmoji, cleanup };
};
How to Use It
Here's a complete example of how to implement the emoji throwing in your React component:
import React from 'react';
import { useEmojiThrow } from './useEmojiThrow';
const PlanningPokerRoom: React.FC = () => {
const { throwEmoji } = useEmojiThrow();
const handleEmojiClick = (emoji: string) => {
// Assuming you have elements with these IDs in your DOM
throwEmoji(emoji, "source-player-avatar", "target-player-avatar");
};
return (
<div className="poker-room">
<div id="source-player-avatar" className="avatar">
Player 1
</div>
<div className="emoji-controls">
<button onClick={() => handleEmojiClick("🎉")}>Throw Confetti</button>
<button onClick={() => handleEmojiClick("👍")}>Throw Thumbs Up</button>
<button onClick={() => handleEmojiClick("❤️")}>Throw Heart</button>
</div>
<div id="target-player-avatar" className="avatar">
Player 2
</div>
</div>
);
};
export default PlanningPokerRoom;
Key Features
- Natural Motion: Uses quadratic Bezier curves for smooth, arc-like motion
- Randomization: Adds slight variations to target positions and trajectories
-
Performance: Uses
requestAnimationFrame
for smooth animations - Cleanup: Includes proper cleanup of animations and DOM elements
- TypeScript Support: Fully typed for better developer experience
Technical Details
The animation works by:
- Calculating start and end positions from DOM elements
- Creating a temporary emoji element
- Animating it along a Bezier curve
- Cleaning up after the animation completes
The useEmojiThrow
hook handles all the complexity, providing a simple interface for throwing emojis between any two elements on your page.
See It in Action
Want to see this code running in a production environment? Check out our demo room where you can try throwing emojis at bot players, or start your own planning poker session at Kollabe.
Performance Considerations
- Animations use CSS transforms for better performance
- Elements are properly cleaned up to prevent memory leaks
- Calculations are optimized and cached where possible
- Animation frames are canceled on cleanup
Potential Customizations
You can easily modify the code to:
- Adjust animation duration
- Change emoji size
- Modify trajectory randomization
- Add rotation or scaling effects
- Implement different easing functions
That's Everything
Adding interactive elements like emoji throwing can transform standard planning poker sessions into engaging team experiences. Feel free to use this code in your own projects, and if you're looking for a ready-to-use solution, try Kollabe for your team's planning sessions.