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.
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
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:
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;
};
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:
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.
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:
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 />} />
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
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.
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.