Skip to main content

Overview

EmotionBreakdown visualizes sentiment analysis results using a six-emotion model (joy, anger, sadness, fear, surprise, disgust). Each emotion is displayed as an animated horizontal bar with percentage values.

Props

emotions
EmotionData[]
required
Array of emotion data objects. Each object contains:
interface EmotionData {
  emotion: Emotion;
  percentage: number;
  count: number;
}

type Emotion = 'joy' | 'anger' | 'sadness' | 'fear' | 'surprise' | 'disgust';
title
string
default:"Emotion Distribution"
Optional panel header title. Set to empty string to hide the header.

Emotion Configuration

Each emotion has a predefined color and label:
const emotionConfig: Record<string, { color: string; label: string }> = {
  joy:      { color: 'bg-joy',      label: 'Joy' },      // Green
  anger:    { color: 'bg-anger',    label: 'Anger' },    // Red
  sadness:  { color: 'bg-sadness',  label: 'Sadness' },  // Blue
  fear:     { color: 'bg-fear',     label: 'Fear' },     // Orange
  surprise: { color: 'bg-surprise', label: 'Surprise' }, // Purple
  disgust:  { color: 'bg-disgust',  label: 'Disgust' },  // Brown
};
CSS Color Definitions:
.bg-joy      { background: hsl(152, 55%, 38%); } /* Green */
.bg-anger    { background: hsl(0, 65%, 48%); }   /* Red */
.bg-sadness  { background: hsl(210, 50%, 45%); } /* Blue */
.bg-fear     { background: hsl(40, 70%, 50%); }  /* Orange */
.bg-surprise { background: hsl(280, 60%, 55%); } /* Purple */
.bg-disgust  { background: hsl(30, 45%, 40%); }  /* Brown */

Usage

import EmotionBreakdown from '@/components/EmotionBreakdown';
import type { EmotionData } from '@/lib/mockData';

const emotions: EmotionData[] = [
  { emotion: 'joy', percentage: 35, count: 350 },
  { emotion: 'anger', percentage: 28, count: 280 },
  { emotion: 'fear', percentage: 18, count: 180 },
  { emotion: 'surprise', percentage: 12, count: 120 },
  { emotion: 'sadness', percentage: 5, count: 50 },
  { emotion: 'disgust', percentage: 2, count: 20 },
];

function Dashboard() {
  return <EmotionBreakdown emotions={emotions} />;
}

Component Structure

const EmotionBreakdown = ({ emotions, title = 'Emotion Distribution' }: Props) => (
  <div className="panel p-5">
    <h3 className="panel-header mb-4">{title}</h3>
    <div className="space-y-3">
      {emotions.map((e, i) => {
        const cfg = emotionConfig[e.emotion];
        return (
          <div key={e.emotion} className="flex items-center gap-3">
            {/* Emotion indicator dot */}
            <span className={`indicator-dot ${cfg.color} shrink-0`} />
            
            {/* Emotion label */}
            <span className="w-16 text-xs text-muted-foreground">{cfg.label}</span>
            
            {/* Animated bar */}
            <div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
              <motion.div
                initial={{ width: 0 }}
                animate={{ width: `${e.percentage}%` }}
                transition={{ delay: i * 0.06, duration: 0.5 }}
                className={`absolute inset-y-0 left-0 rounded-full ${cfg.color}`}
              />
            </div>
            
            {/* Percentage display */}
            <span className="w-10 text-right font-mono text-xs text-foreground">
              {e.percentage}%
            </span>
          </div>
        );
      })}
    </div>
  </div>
);

Animation

Staggered Entry

Each bar animates with a sequential delay:
<motion.div
  initial={{ width: 0 }}
  animate={{ width: `${e.percentage}%` }}
  transition={{ 
    delay: i * 0.06,  // 60ms stagger per emotion
    duration: 0.5     // 500ms animation duration
  }}
  className={cfg.color}
/>
Animation Sequence (6 emotions):
  • Joy: 0ms delay
  • Anger: 60ms delay
  • Sadness: 120ms delay
  • Fear: 180ms delay
  • Surprise: 240ms delay
  • Disgust: 300ms delay
Total Animation Time: 800ms (300ms delay + 500ms duration)

Spring Physics

Framer Motion uses default spring physics:
  • Type: Spring
  • Stiffness: 100
  • Damping: 10
  • Mass: 1

Styling

Panel Layout

<div className="panel p-5"> {/* Glass panel with padding */}
  <h3 className="panel-header mb-4">{title}</h3>
  <div className="space-y-3"> {/* 12px vertical spacing */}
    {/* Emotion bars */}
  </div>
</div>

Bar Structure

<div className="flex items-center gap-3"> {/* 12px horizontal gap */}
  {/* 1. Indicator dot (8x8px) */}
  <span className={`indicator-dot ${cfg.color} shrink-0`} />
  
  {/* 2. Label (64px fixed width) */}
  <span className="w-16 text-xs text-muted-foreground">{cfg.label}</span>
  
  {/* 3. Bar track (flex-1 takes remaining space) */}
  <div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
    {/* Animated fill */}
    <motion.div className={`absolute inset-y-0 left-0 rounded-full ${cfg.color}`} />
  </div>
  
  {/* 4. Percentage (40px fixed width, right-aligned) */}
  <span className="w-10 text-right font-mono text-xs text-foreground">
    {e.percentage}%
  </span>
</div>

Indicator Dot

.indicator-dot {
  width: 8px;
  height: 8px;
  border-radius: 9999px; /* fully rounded */
}

Data Normalization

EmotionBreakdown expects percentages to sum to 100. If using raw counts, normalize first:
function normalizeEmotions(emotions: EmotionData[]): EmotionData[] {
  const total = emotions.reduce((sum, e) => sum + e.count, 0);
  
  const normalized = emotions.map(e => ({
    ...e,
    percentage: Math.round((e.count / total) * 100)
  }));
  
  // Ensure sum is exactly 100
  const sum = normalized.reduce((s, e) => s + e.percentage, 0);
  if (sum !== 100 && sum > 0) {
    normalized[0].percentage += (100 - sum);
  }
  
  return normalized.sort((a, b) => b.percentage - a.percentage);
}

Integration with TopicDetail

import EmotionBreakdown from '@/components/EmotionBreakdown';
import type { TopicCard } from '@/lib/mockData';

function TopicAnalysis({ topic }: { topic: TopicCard }) {
  const [liveEmotions, setLiveEmotions] = useState<EmotionData[] | null>(null);
  
  // Use live emotions if available, else fall back to topic.emotions
  const displayEmotions = liveEmotions || topic.emotions;
  
  return (
    <div className="panel p-5">
      <div className="flex items-center justify-between mb-4">
        <h4 className="panel-header text-sm">Emotional Breakdown</h4>
        {liveEmotions && (
          <span className="text-[9px] bg-primary/10 text-primary px-2 py-0.5 rounded">
            📊 Live — {emotionCount} texts analyzed
          </span>
        )}
      </div>
      <EmotionBreakdown emotions={displayEmotions} title="" />
    </div>
  );
}

Accessibility

<div 
  role="list" 
  aria-label="Emotion distribution percentages"
  className="space-y-3"
>
  {emotions.map((e) => (
    <div 
      key={e.emotion}
      role="listitem"
      aria-label={`${emotionConfig[e.emotion].label}: ${e.percentage} percent`}
      className="flex items-center gap-3"
    >
      {/* Bar content */}
    </div>
  ))}
</div>

Responsive Behavior

  • Label Width: Fixed at 64px (w-16) to ensure consistent alignment
  • Percentage Width: Fixed at 40px (w-10) for right-aligned numbers
  • Bar Track: flex-1 takes all remaining horizontal space
  • Vertical Spacing: 12px gap (space-y-3) prevents crowding

Edge Cases

<EmotionBreakdown emotions={[]} />
Renders panel with title but no bars.

Performance Optimization

Key Prop

{emotions.map((e, i) => (
  <div key={e.emotion}> {/* Stable key prevents re-animation */}
Use e.emotion as key instead of array index to preserve animation state when data reorders.

Animation Triggers

Framer Motion only re-animates when the component unmounts and remounts. To force re-animation on data change:
<EmotionBreakdown 
  key={updateTimestamp} // Forces remount
  emotions={emotions} 
/>

Customization

Change Animation Timing

transition={{ 
  delay: i * 0.1,    // Slower stagger (100ms)
  duration: 0.8,     // Longer animation (800ms)
  ease: 'easeOut'    // Custom easing
}}

Custom Colors

const emotionConfig = {
  joy: { color: 'bg-emerald-500', label: 'Happiness' },
  anger: { color: 'bg-rose-600', label: 'Frustration' },
  // ...
};

Add Count Display

<span className="w-16 text-right font-mono text-xs text-foreground">
  {e.percentage}% <span className="text-[10px] text-muted-foreground">({e.count})</span>
</span>

Vertical Layout

<div className="grid grid-cols-3 gap-4">
  {emotions.map((e) => (
    <div key={e.emotion} className="flex flex-col items-center">
      <div className="w-4 h-32 bg-secondary rounded-full overflow-hidden relative">
        <motion.div 
          className={`absolute bottom-0 inset-x-0 ${cfg.color}`}
          initial={{ height: 0 }}
          animate={{ height: `${e.percentage}%` }}
        />
      </div>
      <span className="mt-2 text-xs">{cfg.label}</span>
      <span className="text-xs font-mono">{e.percentage}%</span>
    </div>
  ))}
</div>

SentimentGauge

Overall sentiment polarity

TopicDetail

Full analytics panel

Build docs developers (and LLMs) love