Circle Menu
Dialog Form
Dominoes List Scroll
Dominoes Scroll Indicator
Eagle Vision
Electric AI Input
File Input
Flip Scroll
Glowing Scroll Indicator
Horizontal Scroll
Icon Wheel
Image Pile
Interactive CTA
Interactive Folder
Interest Picker
Jelly Loader
Leave Rating
Mask Cursor Effect
Magnet Tabs
Masonry Grid
OTP Input
Photo Gallery
Pixelated Carousel
Rolling Ball Scroll Indicator
Rubik Cube
Sidebar
Sine Wave
Skeumorphic Music Card
Social Media Card
Stacked Input Form
Stack Scroll
Trading Card
Leave Rating
A fun and interactive rating component that lets users share their experience with a smiley face that changes based on their rating. Users can hover over star ratings to see the face transform from sad to happy, making feedback collection engaging and visually appealing.
Install dependencies
npm i framer-motion lucide-react
Utility function
Create a file lib/utils.ts and paste this code
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Component
Create a file leave-rating.tsx in your components folder and paste this code
'use client';
import { Star } from 'lucide-react';
import React, { useState } from 'react';
import { AnimatePresence, motion, MotionConfig } from 'framer-motion';
import { cn } from '@/lib/utils';
const MOOD_COLORS = ['#f5f5dc', '#f0f0a0', '#f5e907', '#faf32b', '#FFFF00'];
const LABELS = ['Very Bad', 'Bad', 'Average', 'Good', 'Very Good'];
interface RatingControllerProps {
hovered: number | false;
setHovered: (hovered: number | false) => void;
selected: number | false;
setSelected: (selected: number | false) => void;
}
const RatingController = ({
hovered,
setHovered,
selected,
setSelected
}: RatingControllerProps) => {
return (
<div onMouseLeave={() => setHovered(false)} className="flex items-center justify-center gap-2">
{Array.from({ length: 5 }).map((_, index) => {
const isActive =
(hovered !== false && hovered >= index) ||
(hovered === false && selected !== false && selected >= index);
return (
<div className="group relative" key={`star-${index}`}>
<Star
size={32}
strokeWidth={1}
stroke={isActive ? '#000000' : '#00000042'}
fill={isActive ? '#FFFF00' : 'none'}
onMouseEnter={() => setHovered(index)}
onClick={() => setSelected(index)}
className="cursor-pointer transition-all duration-100 ease-out"
/>
<AnimatePresence mode="popLayout">
{hovered !== false && hovered === index && (
<motion.p
key={`label-${index}`}
initial={{
opacity: 0,
transform: 'translateY(-7px) translateX(-50%) scale(0.7)'
}}
animate={{
opacity: 1,
transform: 'translateY(-7px) translateX(-50%) scale(1)'
}}
exit={{
opacity: 0,
transform: 'translateY(-7px) translateX(-50%) scale(0.7)'
}}
transition={{
duration: 0.1,
ease: 'easeOut'
}}
className={cn(
'absolute bottom-full left-1/2 -translate-x-1/2 px-2 py-0.5 text-sm bg-zinc-900 shadow-md whitespace-nowrap',
'rounded-md'
)}
>
{LABELS[index]}
</motion.p>
)}
</AnimatePresence>
</div>
);
})}
</div>
);
};
interface SmileSvgProps {
hovered: number | false;
selected: number | false;
}
const SmileSvg = ({ hovered, selected }: SmileSvgProps) => {
const active = hovered !== false ? hovered : selected !== false ? selected : 4;
// 🎨 Mouth shape transitions
const smileTransition = [
'M 34 76 Q 48 42 66 76',
'M 35 72 Q 51 55 66 72',
'M 35 68 Q 50 70 66 68',
'M 34 65 Q 50 80 66 64',
'M 33 63 Q 50 95 67 64'
];
const mouthLinePoints = [
{ x1: 34, y1: 76, x2: 66, y2: 76 },
{ x1: 35, y1: 72, x2: 66, y2: 72 },
{ x1: 35, y1: 68, x2: 66, y2: 68 },
{ x1: 34, y1: 65, x2: 66, y2: 64 },
{ x1: 33, y1: 63, x2: 67, y2: 64 }
];
const eyeStates = [
{ cxL: 39, cxR: 61, cy: 42, ry: 14, pupilOffsetY: 6 },
{ cxL: 39.5, cxR: 60.5, cy: 41, ry: 13, pupilOffsetY: 5.5 },
{ cxL: 40, cxR: 60, cy: 40, ry: 12, pupilOffsetY: 5 },
{ cxL: 40.5, cxR: 59.5, cy: 39, ry: 10, pupilOffsetY: 4 },
{ cxL: 41, cxR: 59, cy: 38.5, ry: 8, pupilOffsetY: 3.5 }
];
const faceStates = [
{ scaleX: 1.05, scaleY: 0.95, rotate: -8 },
{ scaleX: 1.02, scaleY: 0.98, rotate: -1 },
{ scaleX: 1, scaleY: 1, rotate: 0 },
{ scaleX: 0.99, scaleY: 1.025, rotate: 1 },
{ scaleX: 0.98, scaleY: 1.05, rotate: 5 }
];
const eye = eyeStates[active];
const face = faceStates[active];
return (
<div className="size-[200px]">
<MotionConfig transition={{ duration: 0.5, type: 'spring', bounce: 0.25 }}>
<svg viewBox="0 0 100 100">
{/* Animated face base */}
<motion.g
animate={{
scaleX: face.scaleX,
scaleY: face.scaleY,
rotate: face.rotate,
transformOrigin: '50% 50%'
}}
>
<motion.circle
cx="50"
cy="50"
r="40"
animate={{ fill: MOOD_COLORS[active] }}
stroke="#000"
strokeWidth={1}
/>
{/* Eyes */}
<motion.ellipse
animate={{ cy: eye.cy, ry: eye.ry, cx: eye.cxL }}
rx="6"
fill="white"
stroke="#000"
strokeWidth={1}
/>
<motion.ellipse
animate={{
cy: eye.cy + eye.pupilOffsetY,
cx: eye.cxL,
ry: eye.ry / 2
}}
rx="4"
fill="black"
stroke="#000"
strokeWidth={1}
/>
<motion.ellipse
animate={{ cy: eye.cy, ry: eye.ry, cx: eye.cxR }}
rx="6"
fill="white"
stroke="#000"
strokeWidth={1}
/>
<motion.ellipse
animate={{
cy: eye.cy + eye.pupilOffsetY,
cx: eye.cxR,
ry: eye.ry / 2
}}
rx="4"
fill="black"
stroke="#000"
strokeWidth={1}
/>
{/* Mouth curve */}
<motion.path
initial={{ d: smileTransition[4] }}
animate={{ d: smileTransition[active] }}
fill="white"
stroke="#000"
strokeWidth={1.5}
strokeLinecap="round"
/>
{/* Mouth base line */}
<motion.line
initial={{
x1: mouthLinePoints[4].x1,
x2: mouthLinePoints[4].x2,
y1: mouthLinePoints[4].y1,
y2: mouthLinePoints[4].y2
}}
animate={{
x1: mouthLinePoints[active].x1,
x2: mouthLinePoints[active].x2,
y1: mouthLinePoints[active].y1,
y2: mouthLinePoints[active].y2
}}
stroke="#000"
strokeWidth={1.5}
strokeLinecap="round"
/>
</motion.g>
</svg>
</MotionConfig>
</div>
);
};
interface SubmitButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: React.ReactNode;
}
const SubmitButton = ({ children, ...props }: SubmitButtonProps) => {
return (
<button
{...props}
className="bg-blue-500 shadow-md text-sm active:scale-[0.97] hover:bg-blue-600 font-medium text-white px-4 py-2 rounded-md"
>
{children}
</button>
);
};
interface LeaveRatingProps {
onSubmit?: (selected: number | false) => void;
question?: string;
buttonText?: string;
}
const LeaveRating = ({
onSubmit,
question = 'How was your experience?',
buttonText = 'Submit'
}: LeaveRatingProps) => {
const [hovered, setHovered] = useState<number | false>(false);
const [selected, setSelected] = useState<number | false>(false);
return (
<div className="w-full max-w-xl p-10 flex flex-col items-center justify-center gap-6 bg-white rounded-3xl shadow-md border-[0.5px]">
<SmileSvg hovered={hovered} selected={selected} />
<div className="flex flex-col items-center justify-center gap-2">
<p className="text-base text-black font-medium">{question}</p>
<RatingController
hovered={hovered}
setHovered={setHovered}
selected={selected}
setSelected={setSelected}
/>
</div>
<SubmitButton onClick={() => onSubmit?.(selected)}>{buttonText}</SubmitButton>
</div>
);
};
export default LeaveRating;
Usage
<div className="w-full flex items-center justify-center bg-neutral-400 h-full">
<LeaveRating
question="How was your experience?"
buttonText="Submit"
onSubmit={(selected) => console.log(selected)}
/>
</div>