Feedback Widget in React: Thumbs, Star Rating, Free Text
Build a production-ready React feedback widget with thumbs up/down, star ratings, and free text - all in under 120 lines, no extra dependencies required.
Why Most Feedback Widgets Stink
You've seen them. That tiny "Was this helpful? π π" floating in the corner of a docs page. You click thumbs-down, nothing happens, and you're left wondering if anyone's reading the data anyway. The problem isn't the concept - it's that most implementations stop at the binary click and throw away everything valuable.
A good feedback widget collects three layers of signal: a quick sentiment (thumbs or star), an optional categorical reason, and a free-text comment for when the user actually has something to say. None of those are hard to build. What's hard is making the UX not feel like a chore. Nobody's filling out a 12-field form after reading a blog post.
In practice, you want the whole thing to feel like it takes under 10 seconds when the user has something simple to say, and under 30 when they're venting. That's the design constraint. Everything else - state shape, network calls, animations - follows from that.
This article walks you through building each mode from scratch in React 18, with no external rating library. By the end you'll have a composable <FeedbackWidget /> you can drop into any project.
Component Architecture First
Before writing a single line of JSX, figure out the state machine. A feedback widget has roughly four states: idle (showing the prompt), rating (user is picking stars or thumbs), comment (optional text box after rating), and done (thank-you screen). That's it.
Resist the urge to make this a context-driven beast with reducers. It's a single self-contained UI unit. A useState with a union type is totally fine here:
type Step = 'idle' | 'rating' | 'comment' | 'done';
const [step, setStep] = useState<Step>('idle');
const [score, setScore] = useState<number | null>(null);
const [text, setText] = useState('');Worth noting: keep the score as a number even for thumbs mode - map π to 5 and π to 1. That way your backend gets a consistent 1β5 scale no matter which UI variant you ship, and you can switch between thumbs and star modes without a schema migration.
One more thing - don't skip the done state. Disappearing the widget immediately after submit feels broken. Show a 2-second thank-you, then fade out. Users need that closure.
Building the Thumbs Mode
Thumbs is the lowest-friction sentiment capture you can offer. Two buttons, one click, optional follow-up. Here's the whole thing:
function ThumbsStep({ onRate }: { onRate: (score: number) => void }) {
return (
<div className="flex items-center gap-3">
<p className="text-sm text-gray-500">Was this helpful?</p>
<button
onClick={() => onRate(5)}
className="text-xl hover:scale-125 transition-transform"
aria-label="Yes, helpful"
>
π
</button>
<button
onClick={() => onRate(1)}
className="text-xl hover:scale-125 transition-transform"
aria-label="No, not helpful"
>
π
</button>
</div>
);
}Honestly, the aria-label attributes matter more than they look. Screen reader users will hear "button" twice if you skip them. Takes 5 seconds to add, saves you a WCAG violation.
After the user clicks, transition to the comment step if the score is low (1 or 2) - don't bother prompting for a comment when they said yes. That's the UX pattern used by Notion, Linear, and most B2B SaaS tools since around 2021. High scores get a quick "Glad to hear it!" and skip straight to done.
That conditional routing is as simple as score <= 2 ? setStep('comment') : submitAndDone(). No library needed.
Building the Star Rating Mode
Star ratings give you more granularity but require a bit more interaction work - specifically, hover state. You want stars to fill up as the user moves their cursor left-to-right before they click.
function StarStep({ onRate }: { onRate: (score: number) => void }) {
const [hovered, setHovered] = useState(0);
return (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onMouseEnter={() => setHovered(star)}
onMouseLeave={() => setHovered(0)}
onClick={() => onRate(star)}
aria-label={`${star} star${star > 1 ? 's' : ''}`}
className="text-2xl transition-transform hover:scale-110"
>
{star <= hovered ? 'β
' : 'β'}
</button>
))}
</div>
);
}Quick aside: use the actual Unicode characters β
(U+2605) and β (U+2606) instead of an SVG library. They scale with font-size, inherit color from CSS, and cost you zero bytes. At 24px they look perfectly sharp on any display density.
The hovered state resets to 0 on mouse-leave so it snaps back cleanly. You could also track a selected state separately so the filled stars persist after click but before submit - that feedback loop ("I clicked 3 stars") reduces accidental re-ratings.
Look, mobile touch events don't fire mouseEnter and mouseLeave - so on touch devices, the hover fill never shows. That's acceptable. The tap-to-select still works fine, and adding onTouchStart to fake the hover gets janky fast. Just let touch be touch.
The Free Text Comment Step
This is where you capture the actual signal. A <textarea> with a 500-character limit and a submit button. Nothing wild:
function CommentStep({
onSubmit,
onSkip,
}: {
onSubmit: (text: string) => void;
onSkip: () => void;
}) {
const [value, setValue] = useState('');
const max = 500;
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
What could be improved?
</label>
<textarea
value={value}
onChange={(e) => setValue(e.target.value.slice(0, max))}
rows={3}
className="w-full rounded-lg border border-gray-200 p-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Tell us more (optional)..."
/>
<p className="text-xs text-gray-400 text-right">
{value.length}/{max}
</p>
<div className="flex gap-2 justify-end">
<button onClick={onSkip} className="text-sm text-gray-400 hover:text-gray-600">
Skip
</button>
<button
onClick={() => onSubmit(value)}
className="px-3 py-1.5 rounded-md bg-indigo-600 text-white text-sm hover:bg-indigo-700"
>
Submit
</button>
</div>
</div>
);
}Always include a Skip button. Making the comment mandatory kills your conversion rate - you'll get 8% of users completing versus 60%+ when it's optional. That's not a guess; that's what A/B tests across multiple SaaS products have consistently shown since 2019.
The .slice(0, max) pattern is cleaner than maxLength on the input because you control the character counter display without fighting browser native UI. Worth the extra line.
For the visual layer, if you want the widget to match a more polished style, check out the glassmorphism components in Empire UI - a frosted-glass card wrapping this textarea looks genuinely good in dark-mode dashboards, especially with a subtle backdrop-filter: blur(12px) on the container.
Wiring It All Together
Now compose the three steps into one widget component with a submission handler:
export function FeedbackWidget({
onSubmit,
mode = 'stars',
}: {
onSubmit: (score: number, text: string) => Promise<void>;
mode?: 'thumbs' | 'stars';
}) {
const [step, setStep] = useState<'idle' | 'rating' | 'comment' | 'done'>('idle');
const [score, setScore] = useState<number | null>(null);
const handleRate = (s: number) => {
setScore(s);
setStep(s <= 2 ? 'comment' : 'done');
if (s > 2) submitFeedback(s, '');
};
const submitFeedback = async (s: number, text: string) => {
await onSubmit(s, text);
setStep('done');
setTimeout(() => setStep('idle'), 3000);
};
if (step === 'idle')
return <button onClick={() => setStep('rating')}>Leave feedback</button>;
if (step === 'rating')
return mode === 'thumbs'
? <ThumbsStep onRate={handleRate} />
: <StarStep onRate={handleRate} />;
if (step === 'comment')
return (
<CommentStep
onSubmit={(text) => submitFeedback(score!, text)}
onSkip={() => submitFeedback(score!, '')}
/>
);
return <p className="text-sm text-green-600">Thanks for your feedback!</p>;
}That setTimeout resetting to idle after 3 seconds is the closure that completes the loop. Users who click Leave Feedback again will get the fresh widget - good for testing, good for users who change their mind.
The onSubmit prop is async intentionally. You'd typically fire a fetch to /api/feedback here, and if the promise rejects you can catch it and show an inline error without breaking the step machine. Don't swallow errors silently.
Need to style this more aggressively? The box shadow generator is handy for getting the card elevation right - try 0 4px 24px rgba(0,0,0,0.08) as a starting point for a floating widget feel. And for gradient accent buttons, the gradient generator saves you ten minutes of tweaking.
Positioning, Animation, and Production Gotchas
Where does this widget live in your layout? Three common patterns: inline below content (docs pages), floating bottom-right fixed position (dashboards), or inside a modal triggered by a button. The floating variant needs position: fixed; bottom: 24px; right: 24px; z-index: 50; - and z-index: 50 is usually fine unless your app has a nav at z-index 100+.
For enter/exit animation, a simple CSS transition is enough. Don't reach for Framer Motion for something this small:
.feedback-widget {
opacity: 0;
transform: translateY(8px);
transition: opacity 200ms ease, transform 200ms ease;
}
.feedback-widget.visible {
opacity: 1;
transform: translateY(0);
}That 200ms ease feels snappy without being abrupt. Anything under 150ms starts feeling instant (which can feel broken), anything over 300ms starts feeling slow on a widget this small.
That said, don't forget de-duplication on the backend. Users often click thumbs-down, type a comment, then come back 10 minutes later and do it again. Store a sessionId or userId + pageId combo and upsert rather than insert. Otherwise your analytics show 3x the negative feedback you actually got.
One more thing - test on iOS Safari. The focus ring on the textarea behaves differently, and position: fixed with a soft keyboard open causes the widget to ride up with the keyboard on older iOS versions (pre-16). Adding interactive-widget=resizes-content to your viewport meta tag in Next.js 14+ fixes most of that.
FAQ
No. Unicode characters plus a single hovered state handle the interactive star fill without any dependency. Libraries add weight you don't need for something this simple.
Set a cookie or localStorage key keyed to the page slug after a successful submit. Check it on mount and skip rendering the widget entirely if it exists.
5 stars for content quality and product features, NPS (0-10) for overall satisfaction surveys. Don't mix them - the benchmarks are different and your team will read the data wrong.
No - it uses useState so it needs to be a Client Component. Add 'use client' at the top of the file and import it into any Server Component page you like.