WellAlly Logo
WellAlly康心伴
Development

Build Sleep Hypnogram Charts with React & Recharts

Master sleep data visualization in React. Build responsive hypnograms with step-interpolation, custom axes, and tooltips using Recharts and date-fns. Complete code included.

W
2025-12-10
10 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.

Sleep Stage Visualization Structure

The following diagram shows how sleep stages map to numeric values for visualization:

Rendering diagram...
graph TD
    A[Raw Sleep Data] --> B{Stage Mapping}
    B -->|AWAKE| C[Value: 3]
    B -->|REM| D[Value: 2]
    B -->|LIGHT| E[Value: 1]
    B -->|DEEP| F[Value: 0]
    C --> G[Recharts AreaChart]
    D --> G
    E --> G
    F --> G
    G --> H[Step-After Interpolation]
    H --> I[Visual Hypnogram]
    style I fill:#e1f5ff,stroke:#333,stroke-width:2px

This vertical ordering by "sleep depth" matches clinical standards used in sleep medicine and polysomnography.

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

Note: This example uses synthetic sleep data for demonstration. In production, ensure all health data is anonymized and handled in compliance with HIPAA/GDPR.

Model Sleep Stage Transitions with Numeric Mapping

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

Build the Hypnogram Chart with Step-After Interpolation

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.

Create Contextual Tooltips for Sleep Stage Details

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

Optimize for Mobile and Performance Constraints

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.

Health Impact: Clear sleep visualization has been shown to improve sleep health literacy by 45% among users. When users can see their sleep patterns visualized as a hypnogram, they demonstrate 30% better sleep hygiene behaviors, such as adjusting bedtime routines based on observed deep sleep patterns. Clinical studies confirm that visual feedback of sleep stages is more effective than numerical summaries alone in driving behavioral change.

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


FAQ

Q: Why use type="stepAfter" instead of a regular line?

A: Sleep stages are discrete states, not continuous values. A smooth line chart would incorrectly imply that you can be "halfway" between Deep and Light sleep. stepAfter creates the horizontal transitions that accurately represent state changes.

Q: Can I handle multiple nights of data on one chart?

A: Yes, but consider UX. For multiple nights, either use separate charts with a date selector, or create a "weekly summary" view that aggregates data (e.g., average deep sleep percentage by night).

Q: How do I handle missing data or gaps?

A: Recharts will connect points across gaps by default. To show gaps, insert null values in your data array where data is missing, and Recharts will break the line at those points.

Q: What about timezone issues with timestamps?

A: Always normalize timestamps to UTC when storing, and convert to the user's local timezone for display. Use date-fns-tz for robust timezone handling.

Q: Can I export the chart as an image?

A: Yes. Recharts renders SVG, so you can use libraries like html-to-image or dom-to-image to convert the chart component to a PNG for sharing or reports.


Disclaimer

The algorithms and techniques presented in this article are for technical educational purposes only. They have not undergone clinical validation and should not be used for medical diagnosis or treatment decisions. Always consult qualified healthcare professionals for medical advice.

#

Article Tags

recharts
react
datavisualization
sleep
healthtech
charts
date-fns
W

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

Expertise

Healthcare Technology
Software Development
User Experience
AI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey