user profiles beyond dark mode
A binary light and dark toggle only covers a single preference axis. Users with low vision, motion sensitivity or a need for more overview on small screens need more choice. Tailwind CSS theme presets combine contrast, density and motion into named profiles that can be selected through Alpine.js and persisted server side.
Table of Contents
- 1. Why light and dark alone are not enough
- 2. The three axes: color scheme, density and motion
- 3. Building named theme presets: Comfort, Focus, Contrast, Compact
- 4. Preset tokens with @theme and CSS custom properties
- 5. Preset selection UI with Alpine.js
- 6. Persistence: localStorage, cookies and SSR sync
- 7. Detecting system preferences: prefers-contrast and prefers-reduced-motion
- 8. Avoiding FOUC: bootstrap script before first paint
- 9. Theme presets compared to a simple dark mode toggle
- 10. Summary
- 11. FAQ
1. Why light and dark alone are not enough
Most Tailwind projects implement theming as a single binary decision: light or dark. That solves a real problem, but it only covers one of several preference axes users actually have. Someone with reduced vision may need more contrast, regardless of whether the color scheme is light or dark. Someone with vestibular issues wants to minimize animation, completely independent of the color scheme. And someone using a backoffice screen full of tables benefits from a denser layout that has nothing to do with light or dark at all. Theme presets solve exactly this problem by modeling several independent axes and combining them into meaningful, named combinations.
The mistake in many theme implementations is squeezing every preference into a single toggle. A project that only knows dark: has to add a new conditional branch for every new requirement: dark:contrast:, compact:dark:, and so on, until the variant combinations become unmanageable. Theme presets as a standalone concept cleanly separate the axes at the CSS level and only combine them into user friendly profiles at the UI level. That keeps the underlying custom properties simple while the selection still stays clear for the user.
2. The three axes: color scheme, density and motion
A robust system of theme presets starts by defining independent axes before a single combination is even named. The first axis is the color scheme: light, dark, or a high contrast derivative of both. The second axis is density: comfortable with generous line height and padding, or compact with reduced spacing for data heavy views. The third axis is motion: normal with full transitions and animations, or reduced, where transitions are minimized or replaced entirely by instant state changes.
Each axis is modeled as its own group of CSS custom properties that can be set independently of the others. That is the key difference from a classic dark mode system: instead of a single .dark class, there are three independent attributes, data-scheme, data-density and data-motion, which can theoretically appear in two times two times two combinations. In practice this combinatorics is reduced by not offering every single combination as a UI option, but only meaningful, named theme presets that cover the most common needs.
3. Building named theme presets: Comfort, Focus, Contrast, Compact
Instead of showing users three separate toggles for three axes, which feels cognitively overwhelming, four to five named theme presets are defined that map typical combinations. Comfort is the default with normal contrast, comfortable density and full animations. Focus reduces visual noise with more muted accent colors and less animation, intended for long working sessions. Contrast raises color contrast beyond WCAG AAA and is typically combined with a dark or light background depending on system settings. Compact reduces padding and line heights for table and dashboard heavy views.
The clever part of named theme presets is that each preset provides a fixed combination of values for all three axes, while the user can still override individual axes afterward. If someone chooses the Contrast preset but still wants reduced motion, they can override that as an additional setting. This two layer architecture, named presets as a quick selection plus granular overrides for detail control, is the difference between a real preference system and a simple theme toggle.
/* presets.css — token definitions for named theme presets, three independent axes */
@import "tailwindcss";
/* === Axis 1: color scheme (light / dark / high-contrast) === */
:root, [data-scheme="light"] {
--color-surface: #ffffff;
--color-text: #0f172a;
--color-border: #e2e8f0;
--color-accent: #0369a1;
}
[data-scheme="dark"] {
--color-surface: #0f172a;
--color-text: #f1f5f9;
--color-border: #334155;
--color-accent: #38bdf8;
}
[data-scheme="high-contrast"] {
--color-surface: #000000;
--color-text: #ffffff;
--color-border: #ffffff;
--color-accent: #ffff00;
}
/* === Axis 2: density (comfortable / compact) === */
[data-density="comfortable"], :root {
--spacing-row: 1rem;
--spacing-cell: 0.75rem;
--line-height-base: 1.6;
}
[data-density="compact"] {
--spacing-row: 0.375rem;
--spacing-cell: 0.375rem;
--line-height-base: 1.3;
}
/* === Axis 3: motion (normal / reduced) === */
[data-motion="normal"], :root {
--duration-transition: 200ms;
--duration-modal: 300ms;
}
[data-motion="reduced"] {
--duration-transition: 0ms;
--duration-modal: 0ms;
}
@theme {
--color-preset-surface: var(--color-surface);
--color-preset-text: var(--color-text);
--color-preset-accent: var(--color-accent);
--spacing-preset-row: var(--spacing-row);
}
4. Preset tokens with @theme and CSS custom properties
The connection between the axis based custom properties and the Tailwind utilities runs through the @theme directive of Tailwind CSS v4. Each axis defines its values in its own custom property namespace, and @theme only references the names that are actually needed as a utility. Important for a clean theme presets system: the utility classes themselves do not know about the axes. A class like bg-preset-surface knows nothing about density or motion, it simply points to the current value of --color-surface, whatever preset is active.
This decoupling is essential for maintainability. When a new preset is added, for example a seasonal contrast compact profile for the backoffice during a holiday sale, not a single Tailwind class in the templates needs to be touched. It is enough to define a new data-scheme, data-density or data-motion value, or register a new combination of existing values as a preset name. The utility layer stays stable while the underlying token layer can be extended freely.
5. Preset selection UI with Alpine.js
The user interface for selecting theme presets should be implemented as a standalone Alpine.js component, decoupled from the rest of the application. A central Alpine store holds the currently active preset name along with any granular overrides. When switching a preset, the component sets the three data attributes on the html element in a single step, which makes CSS custom properties resolve immediately without a single DOM node needing to re-render.
An important aspect for the accessibility of the UI itself: the preset selection should be implemented as a radiogroup with correct ARIA roles, not as plain buttons without semantic meaning. Keyboard users must be able to navigate between the theme presets with arrow keys, and screen readers must clearly announce the currently active selection. This matters especially because a system that offers accessibility as a core feature must not itself be inaccessible in its own control surface.
<!-- Theme preset switcher — accessible radiogroup with Alpine.js -->
<div
x-data="{
preset: $store.themePresets.active,
presets: [
{ id: 'comfort', label: 'Comfort' },
{ id: 'focus', label: 'Focus' },
{ id: 'contrast', label: 'Contrast' },
{ id: 'compact', label: 'Compact' }
],
select(id) {
this.preset = id;
$store.themePresets.apply(id);
}
}"
role="radiogroup"
aria-label="Select theme preset"
class="flex flex-wrap gap-2"
>
<template x-for="p in presets" :key="p.id">
<button
type="button"
role="radio"
:aria-checked="preset === p.id"
@click="select(p.id)"
:class="preset === p.id
? 'bg-preset-accent text-white'
: 'bg-white text-slate-700 border border-slate-200'"
class="px-4 py-2 rounded-lg text-sm font-semibold transition-colors focus-visible:outline focus-visible:outline-2"
x-text="p.label"
></button>
</template>
</div>
6. Persistence: localStorage, cookies and SSR sync
A system of theme presets is only as useful as its persistence across page visits. For purely client side applications, localStorage is enough. For server rendered pages such as a Magento 2 shop with Hyvä themes, that is not enough: the very first HTML the server delivers must already know the correct preset, otherwise the default preset briefly flashes before JavaScript switches it. The solution is an additional cookie that is set alongside localStorage on every preset change and can be read by the server on every request.
For logged in customers it is worth going further and storing the active preset as a customer attribute in Magento, so the preference persists across devices instead of being tied to a single cookie in one browser. The flow: on login the stored customer attribute is read and set as a cookie, after which the cookie takes over the ongoing synchronization. This combination of a cookie for fast SSR access and a customer attribute for cross device persistence covers virtually all scenarios for theme presets in a Magento context.
// theme-presets.js — persistence layer with cookie sync for SSR
'use strict';
const COOKIE_NAME = 'theme_preset';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // one year
/**
* Persist the active preset in both localStorage and a cookie.
* The cookie is what makes server-side rendering aware of the preset
* on the very first request, avoiding a flash of the default preset.
* @param {string} presetId - e.g. 'comfort', 'focus', 'contrast', 'compact'
*/
function persistPreset(presetId) {
localStorage.setItem(COOKIE_NAME, presetId);
document.cookie = `${COOKIE_NAME}=${presetId}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax`;
}
/**
* Read the active preset, preferring localStorage over the cookie
* since it is updated more frequently on the client.
* @returns {string} The active preset id, defaults to 'comfort'
*/
function readPreset() {
return localStorage.getItem(COOKIE_NAME) || readCookie(COOKIE_NAME) || 'comfort';
}
function readCookie(name) {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
document.addEventListener('alpine:init', () => {
Alpine.store('themePresets', {
active: readPreset(),
apply(presetId) {
this.active = presetId;
persistPreset(presetId);
document.documentElement.setAttribute('data-preset', presetId);
}
});
});
7. Detecting system preferences: prefers-contrast and prefers-reduced-motion
Before a user makes any conscious selection at all, a theme presets system should try to derive sensible defaults from the operating system settings. The CSS media query prefers-contrast: more signals that the user has already set increased contrast at the operating system level. The media query prefers-reduced-motion: reduce signals a preference for reduced motion, often for medical reasons such as vestibular disorders. Both signals should pre-populate the preset selection on first visit, but should never override a later manual choice made by the user.
The correct priority is: a stored cookie or customer attribute always takes precedence, because it represents a conscious decision. If no stored preference exists, the system media queries are evaluated to suggest a matching preset. Only if that yields no clear preference either does the default Comfort preset apply. This ordering ensures that theme presets behave like a respectful system that never silently overrides user decisions.
8. Avoiding FOUC: bootstrap script before first paint
The biggest technical risk with server rendered pages combined with client side preset logic is a brief flash of the wrong theme, known as Flash of Unstyled Content, or in this case more precisely Flash of Incorrect Theme. The solution is a tiny, synchronously executed inline script placed directly in the head, before the rest of the CSS and before the large Alpine.js bundle. This script reads the cookie or localStorage and sets the data attributes immediately, before the browser renders anything at all.
It is important to keep this bootstrap script as small and as fast as possible. It must not trigger any network requests and must not perform any heavy computation, because every millisecond of delay at this point blocks the rendering of the entire page. In Hyvä themes such a script is registered directly in the layout XML of the head block and exposed for the content security policy via $hyvaCsp->registerInlineScript(), so it is allowed to run despite strict CSP rules.
<!-- Bootstrap script in <head>, before any stylesheet or Alpine bundle -->
<script>
(function () {
function readCookie(name) {
var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
// Synchronous, tiny, no network calls — prevents Flash of Incorrect Theme
var preset = localStorage.getItem('theme_preset') || readCookie('theme_preset') || 'comfort';
document.documentElement.setAttribute('data-preset', preset);
})();
</script>
9. Theme presets compared to a simple dark mode toggle
The difference between a classic dark mode toggle and a full system of theme presets becomes most obvious in practice, once real user requirements start piling up. The following table compares both approaches across the most important criteria.
| Criterion | Simple dark mode toggle | Theme presets |
|---|---|---|
| Covered axes | Color scheme only | Color scheme, density, motion, extensible |
| Contrast needs | Not covered | Dedicated contrast preset |
| Motion sensitivity | Not covered | prefers-reduced-motion integrated |
| Granular overrides | Not supported | Preset plus individual override possible |
| Extensibility | New requirement breaks binary logic | New axis or preset without refactoring |
In practice, projects that adopt several independent axes early on need far fewer large refactorings when new accessibility or comfort requirements arrive. A simple dark mode toggle feels easier at the start but becomes a bottleneck with every new requirement. Theme presets invest a bit more architectural effort up front and pay that off consistently over the lifetime of a project.