Why my biohacking dashboards kept breaking
I track too many things.
HRV, resting heart rate, sleep stages, pitch count from baseball practice, cold plunge time, caffeine intake, sunlight. If I can get it into a CSV or API, it ends up in a dashboard.
The problem: every new metric broke my layout.
On my laptop, it looked fine. On my phone at the gym, labels wrapped badly, cards stretched, and charts felt cramped. Every experiment meant tweaking breakpoints again. I was living inside media queries.
So I stopped trying to perfectly target devices. Instead, I started targeting relationships. Things like:
- How many cards per row feels readable for this density of data?
- How much padding do I need if the user is in bright sunlight?
- What is the minimum tap target I accept for a tired, post-workout brain?
CSS custom properties became the control panel. Not Tailwind classes. Not another utility system. Plain variables.
The core idea: styles as health metrics
For my biohacking dashboards, I treat CSS variables like another layer of metrics. They represent the current “state” of the interface.
Instead of hardcoding everything like this:
.card {
padding: 1.5rem;
border-radius: 12px;
font-size: 0.95rem;
}
.dashboard {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
I push decisions into custom properties:
:root {
--dashboard-columns: 4;
--card-padding: 1.5rem;
--card-radius: 12px;
--card-font-size: 0.95rem;
}
.dashboard {
display: grid;
grid-template-columns: repeat(var(--dashboard-columns), minmax(0, 1fr));
gap: var(--dashboard-gap, 1.25rem);
}
.card {
padding: var(--card-padding);
border-radius: var(--card-radius);
font-size: var(--card-font-size);
}
On its own that is not very exciting. The shift happens when you stop thinking “mobile vs desktop” and start thinking “lightweight vs dense vs focus mode” and let those modes rewrite the variables.
Defining “modes” for your body, not your devices
I care more about my mental state than my device type.
If I am half-asleep checking morning HRV, I want big numbers, low noise, and high contrast. If I am at my desk reviewing a week of data, I want density and context.
So I started defining layout modes as CSS variables first, then pairing them with media queries and data- attributes.
:root {
/* default “desk review” mode */
--dashboard-columns: 4;
--card-padding: 1.25rem;
--card-font-size: 0.95rem;
--chart-height: 260px;
--touch-target-size: 36px;
}
/* narrow screens: inherently more “single focus” */
@media (max-width: 900px) {
:root {
--dashboard-columns: 2;
--card-padding: 1rem;
--card-font-size: 0.98rem;
--chart-height: 220px;
--touch-target-size: 44px;
}
}
/* ultra narrow: pure focus mode */
@media (max-width: 600px) {
:root {
--dashboard-columns: 1;
--card-padding: 1.1rem;
--card-font-size: 1.05rem;
--chart-height: 260px;
}
}
Then I added an explicit focus mode that I can toggle from JavaScript based on context. For example, when I open a “pre-sleep” routine dashboard on my phone.
html[data-mode="focus"] {
--dashboard-columns: 1;
--card-padding: 1.5rem;
--card-font-size: 1.1rem;
--chart-height: 300px;
--touch-target-size: 48px;
}
Now the JS is dead simple:
// example: turn on focus mode for “bedtime check-in”
function setMode(mode) {
document.documentElement.setAttribute('data-mode', mode);
}
setMode('focus');
No component-level overrides. No Tailwind gymnastics. One attribute, all the right knobs turn.
Responsive grids without chasing breakpoints
The dashboard layout is almost always a grid of “cards”. Sleep summary. HRV trend. Training load. Recovery score.
I got tired of guessing how many cards I could fit per row for random device widths. So I stopped being strict about it. I use a variable for “target column width” and let the layout find its own shape.
:root {
--card-min-width: 260px;
}
.dashboard {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(var(--card-min-width), 1fr)
);
gap: var(--dashboard-gap, 1.25rem);
}
Then I let context rewrite --card-min-width instead of hunting down grid definitions.
html[data-mode="focus"] {
--card-min-width: 320px;
}
@media (max-width: 600px) {
:root {
--card-min-width: 100%;
}
}
This is boring code. That is the point.
When I added a “Travel” dashboard that has smaller, less important stats, I did not need a new layout. I only nudged the knobs.
html[data-dashboard="travel"] {
--card-min-width: 220px;
--card-padding: 0.9rem;
}
The same CSS. Three very different layouts. It feels like a design system finally doing its job.
Adaptable charts without 20 config files
The ugly part of biohacking dashboards usually lives around charts. Fonts too small. Lines too thin. Legends unreadable.
I wanted charts to respect the same variables as the rest of the UI. So I pushed chart styling decisions into CSS and let the JS just read them.
Example: I use CSS variables to control stroke width, gridline opacity, and font size. Then the chart library reads from computed styles.
:root {
--chart-stroke-width: 2;
--chart-grid-opacity: 0.12;
--chart-font-size: 0.8rem;
}
html[data-mode="focus"] {
--chart-stroke-width: 3;
--chart-grid-opacity: 0.18;
--chart-font-size: 0.9rem;
}
.chart-container {
font-size: var(--chart-font-size);
}
function getCssNumber(varName, fallback) {
const styles = getComputedStyle(document.documentElement);
const value = styles.getPropertyValue(varName).trim();
return value ? Number.parseFloat(value) : fallback;
}
const chartStyle = {
strokeWidth: getCssNumber('--chart-stroke-width', 2),
gridOpacity: getCssNumber('--chart-grid-opacity', 0.12),
fontSize: getComputedStyle(
document.querySelector('.chart-container')
).fontSize
};
// pass chartStyle into whatever chart lib you use
Now when I toggle modes, the charts adapt without needing a second config object for “mobile” or “large screen”.
It also matches my mental model better. “Focus mode means thicker lines and higher contrast”. That is a UI statement, not a chart library statement.
Light, dark, and “sunlight” themes driven by variables
My worst decisions happen when I am squinting at a low-contrast UI in bright sun after a workout.
I started with basic light and dark themes, then ended up with a third “sunlight” theme that is basically “dark but more brutal”. Again, the work happens in custom properties.
:root {
color-scheme: light;
--bg: #fafafa;
--bg-alt: #ffffff;
--surface: #ffffff;
--text: #111827;
--muted: #6b7280;
--accent: #22c55e;
--danger: #ef4444;
--border-subtle: #e5e7eb;
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #020617;
--bg-alt: #030712;
--surface: #020617;
--text: #e5e7eb;
--muted: #6b7280;
--accent: #22c55e;
--danger: #f97373;
--border-subtle: #111827;
}
html[data-theme="sunlight"] {
color-scheme: dark;
--bg: #000000;
--bg-alt: #020617;
--surface: #020617;
--text: #f9fafb;
--muted: #9ca3af;
--accent: #4ade80;
--danger: #fb7185;
}
body {
background: var(--bg);
color: var(--text);
}
.card {
background: var(--surface);
border: 1px solid var(--border-subtle);
}
While building this, I stopped wiring theme switches deep into components. Instead, I just toggle data-theme on html.
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
// crude heuristic: “sunlight mode” on high ambient light
if ('AmbientLightSensor' in window) {
const sensor = new AmbientLightSensor();
sensor.addEventListener('reading', () => {
if (sensor.illuminance > 25000) setTheme('sunlight');
});
sensor.start();
}
On most devices the ambient sensor is not available, so I usually just expose a manual toggle. The point stands. The heavy work happens in CSS variables. JS only flips states.
Scaling typography for late-night and early-morning brains
Health dashboards are not marketing sites. You read them when you are tired. Or stressed. Or distracted.
I treat typography like another variable set, then let time and mode tweak it. I am not precious about perfect modular scales. I care about legibility when my HRV tanked.
:root {
--font-base-size: 16px;
--font-scale: 1.15;
--font-size-xs: calc(var(--font-base-size) / var(--font-scale));
--font-size-sm: var(--font-base-size);
--font-size-md: calc(var(--font-base-size) * var(--font-scale));
--font-size-lg: calc(var(--font-size-md) * var(--font-scale));
--font-size-xl: calc(var(--font-size-lg) * var(--font-scale));
}
html[data-mode="focus"] {
--font-base-size: 17px;
}
html[data-time-of-day="night"] {
--font-base-size: 18px;
--font-scale: 1.12;
}
body {
font-size: var(--font-size-sm);
}
.card-title {
font-size: var(--font-size-lg);
}
.metric-primary {
font-size: var(--font-size-xl);
}
Then a small bit of JS sets data-time-of-day:
const hour = new Date().getHours();
const timeOfDay = hour >= 20 || hour < 6 ? 'night' : 'day';
document.documentElement.setAttribute(
'data-time-of-day',
timeOfDay
);
This is one of those details that feels unnecessary until you use it for a week. Then regular dashboards feel too small and hostile at 23:30.
Container queries plus variables for modular panels
One concrete problem: I wanted to reuse the same “HRV card” component in a full-width view and in a cramped lateral sidebar. Copying layout logic felt lame.
I ended up combining container queries with custom properties. The card owns its own thresholds. The parent only defines its size.
.card {
container-type: inline-size;
}
.card[data-metric="hrv"] {
--metric-layout: "compact";
}
@container (min-width: 420px) {
.card[data-metric="hrv"] {
--metric-layout: "regular";
}
}
.hrv-card-root[data-layout="compact"] .trend {
display: none;
}
.hrv-card-root[data-layout="regular"] .trend {
display: block;
}
I do not love using strings inside variables, so I usually pair them with attributes:
.card[data-metric="hrv"] {
--hrv-show-trend: 0;
}
@container (min-width: 420px) {
.card[data-metric="hrv"] {
--hrv-show-trend: 1;
}
}
.hrv-trend {
display: none;
}
.hrv-card-root {
/* 0 or 1 */
--trend-visible: var(--hrv-show-trend);
}
.hrv-card-root[data-trend-visible="1"] .hrv-trend {
display: block;
}
Or lean into utility classes that read from variables with opacity and pointer-events. The exact pattern is less important than the mindset. The card decides what it can show based on its container. The rest of the dashboard does not care.
Practical pitfalls I hit
This all sounds neat. In practice I hit some annoying problems before the setup became pleasant.
- I over-nested variables at first. I had variables referencing variables referencing variables. That turned debugging into archaeology. Now I keep it to one indirection level most of the time.
- I tried to mirror design tokens perfectly. That made the CSS feel like a generated artifact, not a tool. These days I keep only the tokens that match real decisions: spacing scale, typography, state, density.
- I misused media queries as a “config system”. Throwing every variable change into
@mediablocks became unreadable. Modes helped.data-mode,data-theme,data-dashboard. Each owns a narrow set of variables.
The golden rule I ended up with: if you cannot describe the variable in one clear English sentence, it is probably too abstract.
“--touch-target-size is the minimum tap target size for the current context.” That is good.
“--ui-scale-factor is a multiplier for all interaction elements depending on mode and viewport size and density.” That is vague nonsense.
Why this beats piling on more JS
I could absolutely drive all of this from JavaScript. Compute breakpoints in code. Inject styles dynamically. Use a heavy design token system.
I do not want to.
CSS custom properties let the browser do what it is good at. They keep responsive decisions in one place. They make “focus mode for pre-sleep check” a styling concern, not a re-render concern.
For biohacking dashboards specifically, the ground keeps moving. You will add a new metric. Swap out a sensor. Change your morning routine. The UI needs to adapt as fast as your experiments.
Variables give you a control panel for that. Not another rewrite. Just a few new knobs.
That is the type of flexibility I want from my health tools. Quiet, boring, predictable. So my energy goes into the experiment, not the CSS.
Subscribe to my newsletter to get the latest updates and news
Member discussion