Health App UI Design: Calm Colors, Data Cards and Progress Rings
Build calming, accessible health app UIs in React — calm color palettes, data cards, animated progress rings, and dashboard layouts that don't overwhelm users.
Why Health App UI Design Is a Completely Different Problem
Health apps are not productivity tools dressed up in green. They carry genuine emotional weight — someone opening a fitness tracker at 11pm might be celebrating a streak or spiraling about not hitting 10,000 steps. That emotional context changes every UI decision you make, from color temperature to how much red you're willing to put on the screen.
The biggest mistake developers make is treating health data the same as financial data. Both are numbers, sure. But a banking dashboard can afford to be cold and precise — 14px Roboto Mono, hard grids, stark contrast. A health dashboard needs the same precision but wrapped in something that doesn't spike the user's cortisol the moment they open it. In practice, the goal is 'medical accuracy, spa-level calm.'
Worth noting: the most successful health apps since around 2022 — Apple Health, Whoop, Oura — all converge on the same palette philosophy. Desaturated midtones as background, one warm accent (usually a terracotta or sage green), and white or near-white card surfaces. That's not a coincidence. It's the result of extensive user testing showing that high-saturation reds and aggressive oranges correlate with user anxiety and app abandonment.
Honestly, if your health app looks like a Black Friday sale page, you've already lost. The color work has to come first, before you touch a single component.
Building a Calm Color System
Start with a desaturated base. Something like hsl(210, 14%, 97%) for the page background — almost white but with a tiny blue-grey cast that reads as clean without being clinical. Your card surfaces can go slightly warmer: hsl(30, 20%, 98%) gives that off-white linen feel that health and wellness brands love.
For accent colors, limit yourself to two. One functional accent (used for interactive elements, progress fills, CTAs) and one semantic accent (used specifically for alerts or negative metrics). A good functional accent for health: hsl(162, 63%, 41%) — that's a medium sage-teal that reads as 'healthy' without screaming neon green. For the semantic accent, avoid pure red #FF0000. Use something like hsl(0, 72%, 61%) — it reads as a warning without triggering the same alarm-bell response.
// tokens/colors.ts
export const health = {
// backgrounds
pageBg: 'hsl(210, 14%, 97%)',
cardBg: 'hsl(30, 20%, 98%)',
cardBorder: 'hsl(210, 16%, 90%)',
// text
textPrimary: 'hsl(220, 20%, 16%)',
textSecondary: 'hsl(220, 12%, 46%)',
textMuted: 'hsl(220, 10%, 64%)',
// accents
positive: 'hsl(162, 63%, 41%)', // sage-teal
warning: 'hsl(38, 92%, 50%)', // amber
negative: 'hsl(0, 72%, 61%)', // muted red
neutral: 'hsl(210, 20%, 56%)', // slate-blue
// fills (for progress rings etc.)
fillSteps: 'hsl(162, 63%, 41%)',
fillSleep: 'hsl(250, 60%, 65%)',
fillHeart: 'hsl(0, 72%, 61%)',
fillCalories: 'hsl(38, 92%, 50%)',
} as const;Typography matters just as much as color. Use a 16px base size minimum — health users skew older than gaming or developer tools audiences, and you need readability on a phone screen held at arm's length. Pair a humanist sans-serif for numbers (Inter or Plus Jakarta Sans) with a slightly warmer secondary face for body labels. The humanist letterforms at 16px feel approachable rather than bureaucratic.
One more thing — dark mode in health apps needs special care. Flipping a light health palette to dark is not as simple as inverting values. Dark health UIs tend toward deep blue-greys (hsl(222, 30%, 10%)) not pure black, and the accent colors need to shift slightly warmer and more saturated to maintain perceived brightness on OLED. Test at 50% brightness on a real phone, not your calibrated 27-inch monitor.
Data Cards That Don't Overwhelm
A health dashboard is usually four to eight metric cards arranged in a grid. The card is where everything lives — the label, the current value, the trend, and maybe a small sparkline or icon. Get this component wrong and the whole dashboard feels anxious. Get it right and users actually look forward to opening the app.
The key structural rule: one primary number per card, maximum. Secondary context (yesterday's value, weekly average, target) gets pushed to a smaller weight or a muted color. Users should be able to parse the most important number in under 200ms — that means 32px+ font size, high contrast against the card background, and zero competing visual elements at the same weight.
// components/MetricCard.tsx
import { ReactNode } from 'react';
interface MetricCardProps {
label: string;
value: string | number;
unit?: string;
trend?: 'up' | 'down' | 'neutral';
trendLabel?: string;
icon?: ReactNode;
accentColor?: string;
}
export function MetricCard({
label,
value,
unit,
trend,
trendLabel,
icon,
accentColor = '#34a87a',
}: MetricCardProps) {
const trendColor =
trend === 'up' ? '#34a87a' : trend === 'down' ? '#e05c5c' : '#8898aa';
return (
<div
style={{
background: 'hsl(30, 20%, 98%)',
border: '1px solid hsl(210, 16%, 90%)',
borderRadius: '16px',
padding: '20px 24px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{icon && (
<span style={{ color: accentColor, fontSize: '18px' }}>{icon}</span>
)}
<span
style={{
fontSize: '13px',
fontWeight: 500,
color: 'hsl(220, 12%, 46%)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
{label}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span
style={{
fontSize: '36px',
fontWeight: 700,
color: 'hsl(220, 20%, 16%)',
lineHeight: 1,
}}
>
{value}
</span>
{unit && (
<span
style={{ fontSize: '14px', color: 'hsl(220, 12%, 46%)', fontWeight: 400 }}
>
{unit}
</span>
)}
</div>
{trendLabel && (
<span style={{ fontSize: '12px', color: trendColor, fontWeight: 500 }}>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '—'} {trendLabel}
</span>
)}
</div>
);
}That 16px border radius is doing real work here. Sharp corners at 0px feel medical and cold. Fully rounded at 50% feels like a toy. The 16px sweet spot sits in 'polished consumer product' territory — same radius Apple uses on iPhone widgets in iOS 17+.
Quick aside: resist the urge to add sparklines or mini-charts inside every card. They look great in Dribbble mockups and become visual noise in production. Use them only where the trend over time is the actual message — sleep consistency is a good candidate, heart rate variability is not because the meaningful patterns require more than 7 days of sparkline data to interpret correctly.
Animated SVG Progress Rings
Progress rings are the most recognizable visual element in health UI. Apple Watch put them in everyone's pockets, and now users expect circular progress as shorthand for 'goal completion.' Done well they're satisfying. Done badly — choppy animation, wrong arc direction, rings that shrink when you want them to grow — they're just frustrating.
The SVG approach gives you full control. A single <circle> with stroke-dasharray and stroke-dashoffset handles the progress fill. The math: circumference = 2 * Math.PI * radius. Set stroke-dasharray to the full circumference, then animate stroke-dashoffset from circumference (empty) to circumference * (1 - progress) (filled). Animate on mount with a CSS transition for the smoothest result — no JS animation loop needed.
// components/ProgressRing.tsx
import { useEffect, useState } from 'react';
interface ProgressRingProps {
progress: number; // 0 to 1
size?: number;
strokeWidth?: number;
color?: string;
trackColor?: string;
label?: string;
sublabel?: string;
}
export function ProgressRing({
progress,
size = 120,
strokeWidth = 10,
color = '#34a87a',
trackColor = 'hsl(210, 16%, 90%)',
label,
sublabel,
}: ProgressRingProps) {
const [mounted, setMounted] = useState(false);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference * (1 - (mounted ? Math.min(progress, 1) : 0));
useEffect(() => {
const t = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(t);
}, []);
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
{/* Track */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={trackColor}
strokeWidth={strokeWidth}
/>
{/* Progress fill */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset 0.9s cubic-bezier(0.4, 0, 0.2, 1)' }}
/>
</svg>
{/* Center label */}
{label && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: '20px', fontWeight: 700, color: 'hsl(220, 20%, 16%)' }}>
{label}
</span>
{sublabel && (
<span style={{ fontSize: '11px', color: 'hsl(220, 12%, 46%)', marginTop: '2px' }}>
{sublabel}
</span>
)}
</div>
)}
</div>
);
}The cubic-bezier(0.4, 0, 0.2, 1) easing on that 0.9s transition is Material Design's standard easing curve. It accelerates quickly and decelerates gently, which reads as 'natural' to users trained on Android and iOS animations. You could also try a spring-physics library like react-spring for the fill, but honestly the CSS transition version is lighter and performs better on mid-range Android devices.
For accessibility, the SVG ring is purely visual. Wrap it in a container with role="img" and aria-label that describes the actual metric: aria-label="Steps: 7,240 of 10,000 (72% complete)". Screen readers don't parse SVG stroke-dashoffset — they need that text. Also guard the animation behind prefers-reduced-motion: if the user has it set, skip the mount transition and render the ring at its final state immediately.
Dashboard Grid Layouts for Health Data
The layout question in health dashboards is always: summary at the top, detail below. Users open the app wanting an instant answer to 'how am I doing today?' — that answer has to be above the fold, requiring zero scroll. Everything else — weekly charts, sleep stage breakdown, historical trends — is secondary and can live lower.
A responsive CSS Grid approach works well here. Two columns on mobile (each data card gets half the screen), four columns on tablet and desktop. The summary 'hero' section — usually the three main rings — spans full width or takes up a featured card slot at 2x width.
// layouts/HealthDashboard.tsx
export function HealthDashboard() {
return (
<div
style={{
minHeight: '100vh',
background: 'hsl(210, 14%, 97%)',
padding: '24px',
}}
>
{/* Hero rings row */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '16px',
marginBottom: '20px',
}}
>
<ProgressRing progress={0.72} color="#34a87a" label="7.2k" sublabel="steps" />
<ProgressRing progress={0.85} color="#8b5cf6" label="7h" sublabel="sleep" />
<ProgressRing progress={0.60} color="#e05c5c" label="72" sublabel="bpm avg" />
</div>
{/* Metric cards grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '12px',
}}
>
<MetricCard label="Calories" value="1,840" unit="kcal" trend="up" trendLabel="+12% vs yesterday" />
<MetricCard label="Active min" value="38" unit="min" trend="neutral" trendLabel="on target" />
<MetricCard label="Water" value="1.8" unit="L" trend="down" trendLabel="-0.4L vs goal" />
<MetricCard label="HRV" value="54" unit="ms" trend="up" trendLabel="+6ms this week" />
</div>
</div>
);
}That minmax(200px, 1fr) grid column definition is a small piece of CSS that saves you a lot of media query headaches. Cards are never smaller than 200px but scale up to fill available space equally. On a 375px phone you get two columns, on a 768px tablet you get three, on a 1280px desktop you get four or five — no breakpoint declarations required.
Look, the temptation to use card-level animation on scroll (fade-in each card as the user scrolls down) is real. Don't. Staggered fade-ins look cool in demos and feel sluggish in actual use. Health dashboards are opened dozens of times per day — users don't want to wait for a card to reveal itself. Just render everything immediately. Save animation budget for the progress rings and the data update transitions.
If you want a head start on dashboard layouts that already follow these principles, the Empire UI templates include several dashboard starters with clean grid systems and accessible color tokens baked in. Much faster than building the grid logic from scratch every project.
Glassmorphism in Health UIs — Use It Carefully
Glassmorphism can work in a health context — but its use case is narrow. The blur-and-transparency effect suits overlay panels, modals, and notification toasts. It does not suit primary data cards. Why? Because health metrics need to be readable fast, often in poor lighting (bedside table at midnight), and the variable contrast of a frosted glass card actively works against that.
Where glassmorphism earns its place in a health app: a weekly summary modal that slides over the dashboard, a 'goal achieved' celebration overlay, or a contextual tooltip that appears above a chart. In those cases the frosted surface creates visual hierarchy between the overlay content and the underlying data, which is exactly what you want.
// A glass overlay panel for weekly summary
<div
style={{
background: 'rgba(255, 255, 255, 0.72)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.4)',
borderRadius: '24px',
padding: '28px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)',
}}
>
<h2>This Week</h2>
{/* summary content */}
</div>That rgba(255,255,255,0.72) opacity is higher than you'd use for a decorative glass card — deliberately so. Health data overlays need legibility first. The blur still reads as glass but the near-opaque fill ensures contrast against whatever is blurred behind it. You can explore the full glass effect range in the glassmorphism generator to find the right opacity for your specific background.
That said, if you're building the entire health app in a dark glassmorphism aesthetic — think Oura's app meets vaporwave — there's a cohesive visual language there worth exploring. Check the glassmorphism components collection on Empire UI, particularly the dark-mode variants. Just make sure you're validating contrast ratios at every step.
Accessibility in Health App UI — Not Optional
Health apps sit in an interesting regulatory gray zone. Depending on what data they handle they may need to comply with ADA, Section 508, or WCAG 2.1 AA at minimum. Beyond compliance though — and this is worth saying plainly — health apps serve users who may be recovering from illness, managing chronic conditions, or dealing with vision changes from medications. Accessibility here is not a checkbox. It's the difference between someone being able to use your app or not.
Minimum contrast requirement for any health metric text: 4.5:1 against the card background under WCAG AA. That 36px bold number in your MetricCard? It only needs 3:1 because it qualifies as large text — but the 13px label above it and the 12px trend text below absolutely need 4.5:1. Run everything through a contrast checker before you ship. The most common fail point is that muted secondary text color that looks elegant in Figma and fails at 2.8:1 on a real screen.
Progress rings need text alternatives. Add role="progressbar" to the SVG container with aria-valuenow, aria-valuemin="0", and aria-valuemax="100". That way screen readers can announce '72% of daily step goal' without parsing your SVG geometry. Same principle applies to sparkline charts — they should have a visually hidden <caption> or aria-label describing the trend in plain language.
Touch targets are another common failure. A 32px interactive element inside a health card — a 'log water' button, a 'mark as done' toggle — does not meet the 44x44px minimum recommended by Apple's HIG and the 48x48dp minimum from Material Design. Even if the visual element is smaller, extend the tappable hit area with padding or an absolutely positioned pseudo-element. Users won't notice the larger hit area when everything works. They'll definitely notice when they mis-tap three times in a row.
One more consideration: font size scaling. iOS and Android both support system font size preferences. If your health app uses px values for font sizes in a WebView or React Native WebView, users who set their system font to 'Larger' or 'Accessibility Extra Large' won't benefit from that preference. Use rem units for text, with a 16px root on <html>. Your 36px primary metric value becomes 2.25rem — and users who need larger text get it automatically.
FAQ
Desaturated backgrounds (near-white with a slight blue-grey or warm cast), one sage-green or teal functional accent, and a muted red or amber for warnings. Avoid high-saturation primaries — user research consistently shows they correlate with anxiety and abandonment in wellness contexts.
Use CSS transition on stroke-dashoffset rather than a JS animation loop. Set the transition to roughly 0.9s with cubic-bezier(0.4, 0, 0.2, 1), trigger it by updating state on mount, and the browser's compositor handles everything on the GPU. For users with prefers-reduced-motion set, skip to the final value immediately.
Sparingly. Glassmorphism works well for overlay panels, modals, and celebration toasts — contexts where a frosted surface creates useful visual hierarchy. It's a bad fit for primary metric cards because variable contrast hurts readability, especially in low-light environments where health apps are often used.
At minimum, WCAG 2.1 AA: 4.5:1 contrast for normal text, 3:1 for large text (18pt+ or 14pt+ bold), 44x44px touch targets, and text alternatives for all SVG charts and rings via role="progressbar" or aria-label. Use rem units for font sizes so system font-size preferences scale correctly.