Skip to main content

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

positive
number
required
Percentage of positive sentiment (0-100). Used in score calculation.
negative
number
required
Percentage of negative sentiment (0-100). Used in score calculation.
neutral
number
required
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

import SentimentGauge from '@/components/SentimentGauge';

function Dashboard() {
  return (
    <SentimentGauge
      positive={45}
      negative={35}
      neutral={20}
    />
  );
}
Score: ((45 - 35) / 100) * 100 = 10Neutral

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>
  );
};

Performance Optimization

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

<SentimentGauge positive={0} negative={0} neutral={0} />
Result: Score = 0, Label = “Neutral”, Needle at center

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

Build docs developers (and LLMs) love