Overview
SentimentGauge is a visual indicator that displays sentiment polarity on a -100 to +100 scale using an animated needle gauge. It calculates a composite score from positive, negative, and neutral percentages and renders a spring-animated SVG gauge.
Props
Percentage of positive sentiment (0-100). Used in score calculation.
Percentage of negative sentiment (0-100). Used in score calculation.
Percentage of neutral sentiment (0-100). Not factored into polarity but contributes to total.
Score Calculation
The sentiment score is computed using this algorithm:
const total = positive + negative + neutral ;
const score = total > 0 ? Math . round ((( positive - negative ) / total ) * 100 ) : 0 ;
Example:
Positive: 60, Negative: 20, Neutral: 20
Total: 100
Score: ((60 - 20) / 100) * 100 = 40
Label Classification
const label = score > 20 ? 'Positive' : score < - 20 ? 'Negative' : 'Neutral' ;
const labelColor = score > 20 ? 'text-joy' : score < - 20 ? 'text-destructive' : 'text-muted-foreground' ;
Thresholds:
Positive: Score > 20 (green)
Negative: Score < -20 (red)
Neutral: -20 ≤ Score ≤ 20 (gray)
Usage
Basic
Positive Sentiment
Negative Sentiment
Live Data
import SentimentGauge from '@/components/SentimentGauge' ;
function Dashboard () {
return (
< SentimentGauge
positive = { 45 }
negative = { 35 }
neutral = { 20 }
/>
);
}
Score: ((45 - 35) / 100) * 100 = 10 → Neutral < SentimentGauge
positive = { 65 }
negative = { 20 }
neutral = { 15 }
/>
Score: ((65 - 20) / 100) * 100 = 45 → Positive < SentimentGauge
positive = { 15 }
negative = { 70 }
neutral = { 15 }
/>
Score: ((15 - 70) / 100) * 100 = -55 → Negative import { useState , useEffect } from 'react' ;
import SentimentGauge from '@/components/SentimentGauge' ;
function LiveGauge () {
const [ sentiment , setSentiment ] = useState ({ positive: 50 , negative: 30 , neutral: 20 });
useEffect (() => {
const interval = setInterval ( async () => {
const data = await fetchLiveSentiment ();
setSentiment ( data );
}, 5000 );
return () => clearInterval ( interval );
}, []);
return < SentimentGauge { ... sentiment } /> ;
}
SVG Architecture
Gauge Dimensions
const r = 60 ; // Arc radius
const cx = 80 ; // Center X
const cy = 75 ; // Center Y
const viewBox = "0 0 160 95" ; // Constrains visible area
Arc Segments
Three color-coded arcs represent sentiment zones:
const segments = {
negative: createArc ( 180 , 230 ), // Left red arc
neutral: createArc ( 233 , 307 ), // Center gray arc
positive: createArc ( 310 , 360 ), // Right green arc
};
Arc Generation:
const createArc = ( startAngle : number , endAngle : number ) => {
const s = ( startAngle * Math . PI ) / 180 ;
const e = ( endAngle * Math . PI ) / 180 ;
const x1 = cx + r * Math . cos ( s );
const y1 = cy + r * Math . sin ( s );
const x2 = cx + r * Math . cos ( e );
const y2 = cy + r * Math . sin ( e );
const largeArc = endAngle - startAngle > 180 ? 1 : 0 ;
return `M ${ x1 } ${ y1 } A ${ r } ${ r } 0 ${ largeArc } 1 ${ x2 } ${ y2 } ` ;
};
Needle Animation
The needle rotates based on the sentiment score:
const angle = ( score / 100 ) * 90 ; // Maps -100…100 to -90°…90°
Framer Motion Configuration:
< motion.line
x1 = "80" y1 = "75" // Pivot point (center)
x2 = "80" y2 = "25" // Needle tip
stroke = "currentColor"
strokeWidth = "2"
initial = { { rotate: 0 } }
animate = { { rotate: angle } }
transition = { {
type: 'spring' ,
stiffness: 60 ,
damping: 15
} }
style = { { transformOrigin: '80px 75px' } }
/>
< circle cx = "80" cy = "75" r = "4" className = "fill-foreground" />
Spring Physics:
Stiffness: 60 (moderate bounce)
Damping: 15 (smooth deceleration)
Transform Origin: Fixed at gauge center (80, 75)
Styling
Color System
{ /* Background arcs with opacity */ }
< path d = { segments . negative }
stroke = "hsl(0,65%,48%)" { /* Red */ }
strokeWidth = "8"
opacity = { 0.2 } />
< path d = { segments . neutral }
stroke = "hsl(220,10%,46%)" { /* Gray */ }
strokeWidth = "8"
opacity = { 0.15 } />
< path d = { segments . positive }
stroke = "hsl(152,55%,38%)" { /* Green */ }
strokeWidth = "8"
opacity = { 0.2 } />
Score Display
< span className = { `text-2xl font-semibold font-mono ${ labelColor } ` } >
{ score > 0 ? '+' : '' }{ score }
</ span >
< p className = { `text-xs font-medium ${ labelColor } ` } > { label } </ p >
Dynamic Classes:
.text-joy → Green (positive)
.text-destructive → Red (negative)
.text-muted-foreground → Gray (neutral)
Labels
< div className = "mt-3 flex w-full justify-between text-[10px] text-muted-foreground" >
< span > Negative </ span >
< span > Neutral </ span >
< span > Positive </ span >
</ div >
Component Structure
const SentimentGauge = ({ positive , negative , neutral } : Props ) => {
const total = positive + negative + neutral ;
const score = total > 0 ? Math . round ((( positive - negative ) / total ) * 100 ) : 0 ;
const label = score > 20 ? 'Positive' : score < - 20 ? 'Negative' : 'Neutral' ;
const labelColor = score > 20 ? 'text-joy' : score < - 20 ? 'text-destructive' : 'text-muted-foreground' ;
const angle = ( score / 100 ) * 90 ;
const segments = useMemo (() => {
// Arc generation logic
}, []);
return (
< div className = "w-full h-full flex flex-col items-center justify-center pb-2" >
< svg viewBox = "0 0 160 95" className = "w-full max-w-[200px]" >
{ /* Background arcs */ }
{ /* Animated needle */ }
{ /* Center dot */ }
</ svg >
< div className = "mt-1 text-center" >
{ /* Score and label */ }
</ div >
< div className = "mt-3 flex w-full justify-between" >
{ /* Zone labels */ }
</ div >
</ div >
);
};
useMemo for Arcs
const segments = useMemo (() => {
const createArc = ( startAngle : number , endAngle : number ) => { /* ... */ };
return {
negative: createArc ( 180 , 230 ),
neutral: createArc ( 233 , 307 ),
positive: createArc ( 310 , 360 ),
};
}, []); // Never recomputes — arcs are static
Arc paths are memoized because they never change. Only the needle angle updates based on props.
Accessibility
< div
role = "img"
aria-label = { `Sentiment gauge showing ${ label } sentiment with a score of ${ score } ` }
className = "w-full h-full flex flex-col items-center justify-center pb-2"
>
{ /* SVG content */ }
</ div >
Responsive Behavior
< svg viewBox = "0 0 160 95" className = "w-full max-w-[200px]" >
Viewport: Fixed aspect ratio (160:95)
Max Width: 200px prevents oversizing
Scaling: SVG scales proportionally within container
Integration Example
import SentimentGauge from '@/components/SentimentGauge' ;
import type { TopicCard } from '@/lib/mockData' ;
function TopicOverview ({ topic } : { topic : TopicCard }) {
// Calculate sentiment percentages from topic data
const positive = 45 ;
const negative = 35 ;
const neutral = 20 ;
return (
< div className = "panel p-4" >
< h4 className = "text-xs font-semibold mb-2" > Overall Sentiment </ h4 >
< SentimentGauge
positive = { positive }
negative = { negative }
neutral = { neutral }
/>
</ div >
);
}
Edge Cases
Zero Total
Maximum Positive
Maximum Negative
Borderline Neutral
< SentimentGauge positive = { 0 } negative = { 0 } neutral = { 0 } />
Result: Score = 0, Label = “Neutral”, Needle at center< SentimentGauge positive = { 100 } negative = { 0 } neutral = { 0 } />
Result: Score = 100, Label = “Positive”, Needle at +90°< SentimentGauge positive = { 0 } negative = { 100 } neutral = { 0 } />
Result: Score = -100, Label = “Negative”, Needle at -90°< SentimentGauge positive = { 40 } negative = { 20 } neutral = { 40 } />
Result: Score = 20, Label = “Neutral” (exactly on threshold)
Customization
Change Color Palette
// Replace HSL values in path strokes
< path d = { segments . negative } stroke = "hsl(350,75%,55%)" /> // Custom red
< path d = { segments . positive } stroke = "hsl(140,60%,45%)" /> // Custom green
Adjust Sensitivity
// Make gauge more sensitive to changes
const label = score > 10 ? 'Positive' : score < - 10 ? 'Negative' : 'Neutral' ;
Modify Animation Timing
transition = {{
type : 'spring' ,
stiffness : 100 , // Faster response
damping : 20 // More damping
}}
SentimentChart Time-series sentiment trends over multiple ranges
EmotionBreakdown Detailed emotion distribution with six-emotion classification