WellAlly Logo
WellAlly康心伴
Development

Building a Beautiful Sleep Hypnogram: Visualizing Health Data with React & Recharts

Learn how to build a responsive Sleep Hypnogram in React using Recharts. Master custom axes, tooltips, and step-interpolation for health data visualization.

W
2025-12-10
9 min read

If you own an Oura Ring, Apple Watch, or Fitbit, you've seen a Hypnogram. It’s that blocky, "cityscape" looking chart that shows your journey through sleep stages during the night.

For HealthTech developers, visualizing this data is tricky. Unlike a stock price (which is continuous and smooth), sleep stages are discrete states that occur over time. You are either "Awake" or "Asleep," in "Deep" sleep or "Light" sleep. A standard smoothed line chart looks wrong here; it implies you "drifted" through a decimal value between stages.

In this tutorial, we will build a production-ready Sleep Hypnogram using React, Recharts, and date-fns. We'll tackle the specific challenges of time-series axes and responsive design to create a component that looks great on mobile.

Understanding the Problem

Raw sleep data usually comes from an API as a chaotic series of timestamps and enums:

code
[
  { "timestamp": "2023-10-10T23:00:00Z", "stage": "AWAKE" },
  { "timestamp": "2023-10-10T23:15:00Z", "stage": "LIGHT" },
  ...
]
Code collapsed

The Challenges:

  1. Mapping Strings to Numbers: Chart libraries need numbers, not "REM".
  2. The "Stepped" Look: We need a chart that holds a value constant until the next change (step-after interpolation).
  3. Readable Axes: The Y-axis needs to show labels, not numbers (0, 1, 2, 3).
  4. Time Formatting: The X-axis spans across midnight (e.g., 10 PM to 7 AM), which can break simple sorting logic.

Prerequisites

  • Basic React knowledge (Hooks).
  • A React project set up (Vite or CRA).
  • Node.js installed.

We need two libraries for this build:

code
npm install recharts date-fns
Code collapsed

Step 1: Data Modeling and Simulation

First, let's create a utility to mock a night's worth of sleep. In a real app, this would come from your backend.

We need to map sleep stages to numeric values to plot them on a Y-axis. A common convention for Hypnograms is vertical ordering by "depth":

  • 3: Awake (Top)
  • 2: REM
  • 1: Light
  • 0: Deep (Bottom)

Implementation

Create src/utils/sleepData.js:

code
import { addMinutes } from 'date-fns';

export const SLEEP_STAGES = {
  3: 'Awake',
  2: 'REM',
  1: 'Light',
  0: 'Deep',
};

// Generate a mock sleep session from 11 PM to 7 AM
export const generateSleepData = () => {
  const startTime = new Date();
  startTime.setHours(23, 0, 0, 0); // 11:00 PM
  
  const data = [];
  let currentTime = startTime;
  
  // Create 8 hours of data in 15-minute chunks
  for (let i = 0; i < 32; i++) {
    // Randomize stages with some "logic" (Deep sleep is usually earlier)
    let stage;
    const hour = currentTime.getHours();
    
    if (i < 5) stage = 1; // Light sleep onset
    else if (i > 30) stage = 3; // Waking up
    else {
      // Random generation for demo purposes
      const rand = Math.random();
      if (rand > 0.7) stage = 2; // REM
      else if (rand > 0.4) stage = 1; // Light
      else stage = 0; // Deep
    }

    data.push({
      timestamp: currentTime.getTime(), // Recharts loves timestamps
      stage: stage,
      stageName: SLEEP_STAGES[stage]
    });

    currentTime = addMinutes(currentTime, 15);
  }

  return data;
};
Code collapsed

Step 2: The Core Chart Component

Now, let's build the visualizer. We will use an AreaChart instead of a LineChart. Why? Because filling the area under the curve gives it that "sleep depth" weight that looks great in UI.

Implementation

src/components/SleepHypnogram.jsx:

code
import React, { useMemo } from 'react';
import {
  AreaChart,
  Area,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from 'recharts';
import { format } from 'date-fns';
import { generateSleepData, SLEEP_STAGES } from '../utils/sleepData';

const SleepHypnogram = () => {
  const data = useMemo(() => generateSleepData(), []);

  return (
    <div className="chart-container" style={{ width: '100%', height: '300px', background: '#0f172a', padding: '20px', borderRadius: '12px' }}>
      <h3 style={{ color: '#fff', marginBottom: '20px' }}>Sleep Cycle Analysis</h3>
      
      <ResponsiveContainer width="100%" height="100%">
        <AreaChart data={data}>
          
          {/* Gradient Definition for a sleek look */}
          <defs>
            <linearGradient id="colorSleep" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/>
              <stop offset="95%" stopColor="#8884d8" stopOpacity={0}/>
            </linearGradient>
          </defs>

          <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#334155" />
          
          {/* X-AXIS: TIME */}
          <XAxis 
            dataKey="timestamp" 
            domain={['dataMin', 'dataMax']} 
            type="number"
            tickFormatter={(unixTime) => format(new Date(unixTime), 'h:mm a')}
            stroke="#94a3b8"
            tick={{ fontSize: 12 }}
            minTickGap={30}
          />

          {/* Y-AXIS: STAGES */}
          <YAxis 
            dataKey="stage" 
            domain={[0, 3]} 
            ticks={[0, 1, 2, 3]}
            tickFormatter={(value) => SLEEP_STAGES[value]}
            stroke="#94a3b8"
            tick={{ fontSize: 12 }}
            width={60}
          />

          {/* THE GRAPH */}
          <Area 
            type="stepAfter" // CRITICAL: This makes it blocky/stepped
            dataKey="stage" 
            stroke="#8884d8" 
            fillOpacity={1} 
            fill="url(#colorSleep)" 
          />
          
        </AreaChart>
      </ResponsiveContainer>
    </div>
  );
};

export default SleepHypnogram;
Code collapsed

Key Technical Details

  1. type="stepAfter": This is the magic prop. A standard monotone line would curve the data. stepAfter keeps the line horizontal until the next data point arrives, perfectly representing state changes.
  2. domain={['dataMin', 'dataMax']}: This tells the XAxis to strictly follow the timestamps provided, ensuring no empty space at the start or end of the chart.
  3. ticks={[0, 1, 2, 3]}: We force the Y-Axis to only show these 4 integers, then use tickFormatter to swap "0" for "Deep", etc.

Step 3: Improving UX with Custom Tooltips

The default Recharts tooltip will show "Stage: 2" and a long timestamp integer. That's bad UX. Let's create a custom tooltip that speaks human.

Implementation

Add this component inside src/components/SleepHypnogram.jsx:

code
const CustomTooltip = ({ active, payload, label }) => {
  if (active && payload && payload.length) {
    const timeStr = format(new Date(label), 'h:mm a');
    const stageVal = payload[0].value;
    const stageName = SLEEP_STAGES[stageVal];

    // Dynamic color coding based on stage
    const getColor = (s) => {
        if(s === 3) return '#ef4444'; // Awake (Red)
        if(s === 0) return '#3b82f6'; // Deep (Blue)
        return '#8884d8'; // Others
    }

    return (
      <div style={{ 
        backgroundColor: '#1e293b', 
        border: '1px solid #334155', 
        padding: '10px', 
        borderRadius: '4px',
        color: '#f8fafc' 
      }}>
        <p style={{ margin: 0, fontWeight: 'bold' }}>{timeStr}</p>
        <p style={{ margin: 0, color: getColor(stageVal) }}>
          State: {stageName}
        </p>
      </div>
    );
  }
  return null;
};
Code collapsed

Update your AreaChart to use it:

code
<Tooltip content={<CustomTooltip />} />
Code collapsed

Step 4: Mobile Responsiveness & Performance

Mobile viewports are where data visualization often fails. We used ResponsiveContainer, which handles width resizing automatically, but we need to ensure the labels don't get crushed.

Optimization Tips

  1. minTickGap: In the XAxis, setting minTickGap={30} ensures that if the screen is too narrow, Recharts will automatically hide some time labels to prevent overlapping.
  2. Touch Targets: If this is for a mobile app, the default tooltip triggers on hover. For mobile, Recharts handles touch, but ensure your Tooltip isn't blocking the user's finger.
  3. Memoization: Note the useMemo in Step 2. Generating random data (or parsing heavy JSON) on every render causes jank during animations. Always memoize your dataset.

Putting It All Together

Here is the final, polished component integrating styles, tooltips, and responsive behavior.

code
// src/components/SleepHypnogram.jsx
import React, { useMemo } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { format } from 'date-fns';
import { generateSleepData, SLEEP_STAGES } from '../utils/sleepData';

const CustomTooltip = ({ active, payload, label }) => {
  if (active && payload && payload.length) {
    return (
      <div className="bg-slate-800 p-3 border border-slate-600 rounded shadow-lg text-white">
        <p className="font-bold mb-1">{format(new Date(label), 'h:mm a')}</p>
        <p className="text-sm">Stage: <span className="text-purple-400 font-semibold">{SLEEP_STAGES[payload[0].value]}</span></p>
      </div>
    );
  }
  return null;
};

const SleepHypnogram = () => {
  const data = useMemo(() => generateSleepData(), []);

  return (
    <div className="w-full max-w-4xl mx-auto p-4 bg-slate-900 rounded-xl shadow-xl">
      <div className="mb-6 flex justify-between items-center">
        <h2 className="text-xl font-bold text-white">Sleep Stages</h2>
        <span className="text-sm text-slate-400">8h 15m Total</span>
      </div>
      
      <div className="h-64 w-full">
        <ResponsiveContainer width="100%" height="100%">
          <AreaChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
            <defs>
              <linearGradient id="colorSleep" x1="0" y1="0" x2="0" y2="1">
                <stop offset="5%" stopColor="#818cf8" stopOpacity={0.8}/>
                <stop offset="95%" stopColor="#818cf8" stopOpacity={0.1}/>
              </linearGradient>
            </defs>
            <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#334155" opacity={0.5} />
            <XAxis 
              dataKey="timestamp" 
              type="number" 
              domain={['dataMin', 'dataMax']} 
              tickFormatter={(unix) => format(new Date(unix), 'HH:mm')}
              stroke="#94a3b8"
              tick={{ fontSize: 12 }}
              minTickGap={40}
            />
            <YAxis 
              dataKey="stage" 
              domain={[0, 3]} 
              ticks={[0, 1, 2, 3]} 
              tickFormatter={(val) => SLEEP_STAGES[val]}
              stroke="#94a3b8"
              tick={{ fontSize: 12 }}
              width={50}
            />
            <Tooltip content={<CustomTooltip />} cursor={{ stroke: '#fff', strokeWidth: 1 }} />
            <Area 
              type="stepAfter" 
              dataKey="stage" 
              stroke="#818cf8" 
              strokeWidth={3}
              fill="url(#colorSleep)" 
              animationDuration={1500}
            />
          </AreaChart>
        </ResponsiveContainer>
      </div>
    </div>
  );
};

export default SleepHypnogram;
Code collapsed

Security & Performance

  • Data Validation: Always sanitize incoming API data. If a timestamp is null or a stage is undefined (e.g., stage: 5), your mapping function will crash the UI. Use a fallback: SLEEP_STAGES[value] || 'Unknown'.
  • Bundle Size: date-fns is tree-shakeable, which is great. If you were using Moment.js, you'd be adding unnecessary bulk to your bundle.
  • Rendering: If plotting a week's worth of data (thousands of points), Recharts (which is SVG-based) might get sluggish. For massive datasets, you might need to switch to Canvas-based rendering, but for a single night's sleep, SVG is perfect.

Conclusion

Visualizing health data is about more than just plotting points; it's about telling a story of the user's wellbeing. By using stepAfter interpolation and custom axis formatting, we transformed chaotic JSON into a clear, readable Hypnogram that rivals native apps like Oura or Apple Health.

Next Steps:

  1. Add "Wake up" and "Bedtime" annotations using Recharts <ReferenceLine />.
  2. Color-code the area fill based on the stage (e.g., Red for Awake sections).
  3. Connect this to the Google Fit or Apple HealthKit API.

Resources

#

Article Tags

datavisualizationreactfrontendhealthtech
W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare TechnologySoftware DevelopmentUser ExperienceAI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey

© 2024 康心伴 WellAlly · Professional Health Management
Building a Beautiful Sleep Hypnogram: Visualizing Health Data with React & Recharts