Dark Mode โหมดมืด
A dark colour scheme built only from CSS custom properties, saved per browser in SQL Server and rendered by the server so it never flashes.
Live demo
Everything here talks to the real Spring Boot + SQL Server back end.Choose a mode
The choice is saved in SQL Server (ui_preference.theme_mode). Reload the page: the server prints data-theme on <html>, so there is no flash.
What the page knows right now
-saved mode (theme_mode)
-active theme (data-theme)
-OS prefers-color-scheme
Dark tokens
Each box paints one CSS variable. Switch modes and watch the values change.
What to notice
- All colours are tokens (--bg, --surface, --text ...) redefined under [data-theme=dark].
- The server reads ui_preference before rendering and prints data-theme on <html>.
- 'System' follows prefers-color-scheme and updates live when the OS switches.
Try it
Source code
10 parts, read from the files in this project
src/main/java/com/study/uiux/config/ClientIdFilter.java
Anonymous client id cookie
@Component
public class ClientIdFilter extends OncePerRequestFilter {
public static final String COOKIE = "UIUX_CLIENT";
public static final String ATTR = "clientId";
private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String id = readCookie(request);
if (id == null) {
id = UUID.randomUUID().toString();
ResponseCookie cookie = ResponseCookie.from(COOKIE, id)
.path("/").httpOnly(true).sameSite("Lax").maxAge(Duration.ofDays(365)).build();
response.addHeader("Set-Cookie", cookie.toString());
}
request.setAttribute(ATTR, id);
chain.doFilter(request, response);
}
private static String readCookie(HttpServletRequest request) {
if (request.getCookies() == null) return null;
for (Cookie c : request.getCookies()) {
if (COOKIE.equals(c.getName()) && UUID_PATTERN.matcher(c.getValue()).matches()) return c.getValue();
}
return null;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri.startsWith("/css/") || uri.startsWith("/js/") || uri.startsWith("/art/") || uri.endsWith(".svg");
}
}
src/main/java/com/study/uiux/web/LayoutAdvice.java
The server renders the saved appearance into <html>
@ModelAttribute
public void layout(HttpServletRequest request, Model model) {
String clientId = (String) request.getAttribute(ClientIdFilter.ATTR);
UiPreference pref = preferences.load(clientId);
model.addAttribute("pref", pref);
model.addAttribute("rootStyle", String.format(Locale.ROOT, "--font-scale:%.2f;--primary:%s;--on-primary:%s",
pref.getFontScale() / 100.0, pref.getThemeColor(), PreferenceService.onColor(pref.getThemeColor())));
// "system" cannot be known on the server; a tiny inline script in <head> resolves it before paint.
model.addAttribute("initialTheme", "dark".equals(pref.getThemeMode()) ? "dark" : "light");
model.addAttribute("groups", catalog.groups());
model.addAttribute("lessonCount", catalog.all().size());
model.addAttribute("presets", PreferenceService.PRESETS);
model.addAttribute("shortcutList", shortcuts.all().stream()
.map(s -> Map.of("id", s.getId(), "keys", s.getKeys(), "action", s.getActionCode(), "description", s.getDescription(), "scope", s.getScope()))
.toList());
model.addAttribute("lessonIndex", catalog.all().stream()
.map(l -> Map.of("no", l.no(), "name", l.name(), "thai", l.thai(), "group", l.group(), "url", l.url()))
.toList());
model.addAttribute("path", request.getRequestURI());
}
src/main/java/com/study/uiux/web/PreferenceApi.java
PUT /api/preferences: the browser sends only what changed
@PutMapping
public Map<String, Object> update(@RequestAttribute(ClientIdFilter.ATTR) String clientId, @RequestBody PreferenceUpdate change) {
return view(preferences.update(clientId, change));
}
src/main/java/com/study/uiux/service/PreferenceService.java
Validate and save one change
@Transactional
public UiPreference update(String clientId, PreferenceUpdate change) {
UiPreference pref = repository.findById(clientId).orElseGet(() -> new UiPreference(clientId));
if (change.themeMode() != null) {
String mode = change.themeMode().toLowerCase(Locale.ROOT);
if (!MODES.contains(mode)) throw ApiException.badRequest("Theme mode must be light, dark or system.");
pref.setThemeMode(mode);
}
if (change.fontScale() != null) {
int scale = change.fontScale();
if (scale < FONT_MIN || scale > FONT_MAX) {
throw ApiException.badRequest("Font size must be between " + FONT_MIN + "% and " + FONT_MAX + "%.");
}
pref.setFontScale(scale);
}
if (change.themeColor() != null) {
String hex = change.themeColor().toLowerCase(Locale.ROOT);
if (!HEX.matcher(hex).matches()) throw ApiException.badRequest("Theme colour must look like #4f46e5.");
pref.setThemeColor(hex);
}
if (change.sidebarCollapsed() != null) pref.setSidebarCollapsed(change.sidebarCollapsed());
return repository.save(pref);
}
src/main/java/com/study/uiux/domain/UiPreference.java
UiPreference entity
@Entity
@Table(name = "ui_preference")
public class UiPreference {
@Id
@Column(name = "client_id", length = 36)
private String clientId;
/** light, dark or system (follow the operating system). */
@Column(name = "theme_mode", nullable = false, length = 10)
private String themeMode = "system";
/** Root font size in percent: 100 = 16px. */
@Column(name = "font_scale", nullable = false)
private int fontScale = 100;
/** Primary colour as #rrggbb. */
@Column(name = "theme_color", nullable = false, length = 7)
private String themeColor = "#4f46e5";
@Column(name = "sidebar_collapsed", nullable = false)
private boolean sidebarCollapsed;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected UiPreference() {
}
public UiPreference(String clientId) {
this.clientId = clientId;
}
@PrePersist
void onCreate() {
createdAt = updatedAt = Instant.now();
}
@PreUpdate
void onUpdate() {
updatedAt = Instant.now();
}
src/main/resources/db/migration/V1__schema.sql
ui_preference: one row per browser
-- Appearance settings. The browser keeps only a random id in the UIUX_CLIENT cookie;
-- the server reads this row before rendering, so the page never "flashes" the wrong theme.
CREATE TABLE ui_preference (
client_id NVARCHAR(36) NOT NULL CONSTRAINT pk_ui_preference PRIMARY KEY,
theme_mode NVARCHAR(10) NOT NULL CONSTRAINT df_pref_mode DEFAULT N'system',
font_scale INT NOT NULL CONSTRAINT df_pref_font DEFAULT 100,
theme_color NVARCHAR(7) NOT NULL CONSTRAINT df_pref_color DEFAULT N'#4f46e5',
sidebar_collapsed BIT NOT NULL CONSTRAINT df_pref_side DEFAULT 0,
created_at DATETIME2(0) NOT NULL CONSTRAINT df_pref_created DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2(0) NOT NULL CONSTRAINT df_pref_updated DEFAULT SYSUTCDATETIME(),
CONSTRAINT ck_pref_mode CHECK (theme_mode IN (N'light', N'dark', N'system')),
CONSTRAINT ck_pref_font CHECK (font_scale BETWEEN 80 AND 140),
CONSTRAINT ck_pref_color CHECK (theme_color LIKE N'#[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]')
);
src/main/resources/templates/layout.html
<html> carries the saved appearance, so CSS is right before any JS runs
<html lang="en" th:fragment="page(content)" xmlns:th="http://www.thymeleaf.org"
th:attr="data-theme=${initialTheme},data-mode=${pref.themeMode}"
th:style="${rootStyle}"
th:classappend="${pref.sidebarCollapsed} ? 'sidebar-collapsed'">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${title} + ' · UI/UX Lab'">UI/UX Lab</title>
<!-- "System" mode can only be resolved in the browser: do it before the first paint. -->
<script>(function () { var d = document.documentElement; if (d.dataset.mode === 'system') { d.dataset.theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } })();</script>
src/main/resources/templates/layout.html
Appearance panel: every control here calls UI.prefs.set()
<aside class="drawer" id="appearance" aria-label="Appearance" aria-hidden="true" inert>
<div class="drawer-head">
<h2>Appearance</h2>
<button class="icon-btn" type="button" data-action="appearance-close" aria-label="Close"><svg class="i"><use href="#i-x"/></svg></button>
</div>
<div class="drawer-body">
<section class="setting">
<h3>Mode</h3>
<div class="seg" role="radiogroup" aria-label="Colour mode">
<button type="button" role="radio" data-pref-mode="light"><svg class="i"><use href="#i-sun"/></svg>Light</button>
<button type="button" role="radio" data-pref-mode="dark"><svg class="i"><use href="#i-moon"/></svg>Dark</button>
<button type="button" role="radio" data-pref-mode="system"><svg class="i"><use href="#i-system"/></svg>System</button>
</div>
</section>
<section class="setting">
<h3>Text size <output class="pref-font-label">100%</output></h3>
<div class="font-row">
<button type="button" class="btn btn-sm btn-ghost" data-pref-font-step="-10" aria-label="Smaller text">A−</button>
<input type="range" min="80" max="140" step="5" data-pref-font aria-label="Text size">
<button type="button" class="btn btn-sm btn-ghost" data-pref-font-step="10" aria-label="Larger text">A+</button>
</div>
</section>
<section class="setting">
<h3>Theme colour</h3>
<div class="swatches">
<button type="button" th:each="p : ${presets}" class="swatch" th:style="'--sw:' + ${p.hex}"
th:attr="data-pref-color=${p.hex},aria-label=${p.name},data-tip=${p.name}"></button>
<label class="swatch swatch-custom" data-tip="Custom colour">
<input type="color" data-pref-color-input aria-label="Custom colour">
</label>
</div>
</section>
<section class="setting">
<label class="switch-row">
<span><b>Collapsed sidebar</b><small>Icons only, names in tooltips</small></span>
<input type="checkbox" class="switch" data-pref-sidebar>
</label>
</section>
<p class="muted small">Saved in SQL Server for this browser (table <code>ui_preference</code>).</p>
<button type="button" class="btn btn-ghost w-full" data-action="prefs-reset"><svg class="i"><use href="#i-refresh"/></svg>Reset to defaults</button>
</div>
</aside>
src/main/resources/static/css/app.css
Dark tokens: same names, darker values. Components never change.
:root[data-theme="dark"] {
--bg: #0a0f1d;
--surface: #111829;
--surface-2: #172036;
--surface-3: #1f2a44;
--border: #243050;
--border-strong: #33406a;
--text: #e6eaf5;
--text-2: #b0b9d0;
--text-3: #94a0bb;
--primary-text: color-mix(in srgb, var(--primary) 62%, #fff);
--primary-soft: color-mix(in srgb, var(--primary) 18%, var(--surface));
--primary-soft-2: color-mix(in srgb, var(--primary) 30%, var(--surface));
--primary-hover: color-mix(in srgb, var(--primary) 85%, #fff);
--success: #4ade80; --success-soft: #12301f;
--warning: #fbbf24; --warning-soft: #33270d;
--danger: #f87171; --danger-soft: #3a1418;
--info: #60a5fa; --info-soft: #132a4a;
--shadow-sm: 0 0 0 1px rgb(255 255 255 / .03);
--shadow: 0 0 0 1px rgb(255 255 255 / .04), 0 8px 24px -10px rgb(0 0 0 / .6);
--shadow-lg: 0 0 0 1px rgb(255 255 255 / .06), 0 20px 50px -12px rgb(0 0 0 / .75);
color-scheme: dark;
}
src/main/resources/static/js/core.js
UI.prefs.set(): apply at once, save to the server a moment later
const prefs = (() => {
const state = Object.assign({ themeMode: 'system', fontScale: 100, themeColor: '#4f46e5', sidebarCollapsed: false }, cfg.prefs);
const dark = matchMedia('(prefers-color-scheme: dark)');
let pending = {}, saveTimer = null;
function resolveTheme(mode) { return mode === 'system' ? (dark.matches ? 'dark' : 'light') : mode; }
function apply(change) {
if (change.themeMode !== undefined) {
root.classList.add('theme-anim');
root.dataset.mode = change.themeMode;
root.dataset.theme = resolveTheme(change.themeMode);
setTimeout(() => root.classList.remove('theme-anim'), 300);
}
if (change.fontScale !== undefined) root.style.setProperty('--font-scale', (change.fontScale / 100).toFixed(2));
if (change.themeColor !== undefined) {
root.style.setProperty('--primary', change.themeColor);
root.style.setProperty('--on-primary', onColor(change.themeColor));
}
if (change.sidebarCollapsed !== undefined) root.classList.toggle('sidebar-collapsed', change.sidebarCollapsed);
Object.assign(state, change);
document.dispatchEvent(new CustomEvent('prefs:change', { detail: { ...state } }));
}
function set(change) {
apply(change);
Object.assign(pending, change);
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 300); // dragging the slider sends one PUT, not fifty
}
async function save() {
const body = pending; pending = {};
try {
await api('/api/preferences', { method: 'PUT', body, silent: true });
} catch (e) {
toast({ type: 'error', title: 'Not saved', message: e.message });
}
}
async function reset() {
const saved = await api('/api/preferences', { method: 'DELETE' });
apply({ themeMode: saved.themeMode, fontScale: saved.fontScale, themeColor: saved.themeColor, sidebarCollapsed: saved.sidebarCollapsed });
toast({ type: 'success', title: 'Appearance reset', message: 'The ui_preference row for this browser was deleted.' });
}
// "System" follows the operating system live.
dark.addEventListener('change', () => { if (state.themeMode === 'system') apply({ themeMode: 'system' }); });
return { get: () => ({ ...state }), set, reset, resolveTheme, toggleTheme: () => set({ themeMode: root.dataset.theme === 'dark' ? 'light' : 'dark' }) };
})();