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:
[
{ "timestamp": "2023-10-10T23:00:00Z", "stage": "AWAKE" },
{ "timestamp": "2023-10-10T23:15:00Z", "stage": "LIGHT" },
...
]
The Challenges:
- Mapping Strings to Numbers: Chart libraries need numbers, not "REM".
- The "Stepped" Look: We need a chart that holds a value constant until the next change (step-after interpolation).
- Readable Axes: The Y-axis needs to show labels, not numbers (0, 1, 2, 3).
- 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:
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:2pxThis 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:
npm install recharts date-fns
”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:
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;
};
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:
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;
Key Technical Details
type="stepAfter": This is the magic prop. A standardmonotoneline would curve the data.stepAfterkeeps the line horizontal until the next data point arrives, perfectly representing state changes.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.ticks={[0, 1, 2, 3]}: We force the Y-Axis to only show these 4 integers, then usetickFormatterto 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:
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;
};
Update your AreaChart to use it:
<Tooltip content={<CustomTooltip />} />
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
minTickGap: In the XAxis, settingminTickGap={30}ensures that if the screen is too narrow, Recharts will automatically hide some time labels to prevent overlapping.- Touch Targets: If this is for a mobile app, the default tooltip triggers on hover. For mobile, Recharts handles touch, but ensure your
Tooltipisn't blocking the user's finger. - Memoization: Note the
useMemoin 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.
// 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;
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-fnsis 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 toCanvas-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:
- Add "Wake up" and "Bedtime" annotations using Recharts
<ReferenceLine />. - Color-code the area fill based on the stage (e.g., Red for Awake sections).
- Connect this to the Google Fit or Apple HealthKit API.
Resources
- Recharts Documentation
- date-fns Documentation
- Understanding Hypnograms
- Related Articles:
- Offline-First PWA with React & Dexie.js - Store sleep data locally
- React Native HealthKit Integration - Fetch sleep data from Apple Health
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.