วิธีดั้งเดิมในการทำ Dark Mode ด้วย Tailwind คือการเขียนคลาสประกบคู่ เช่น bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 ซึ่งใช้งานได้ดีสำหรับโปรเจกต์ขนาดเล็ก แต่เมื่อระบบเติบโตขึ้น ปัญหาที่ตามมาคือ:
- โค้ดบวมและซ้ำซ้อน ต้องคอยเขียน prefix
dark:กำกับในทุก ๆ คอมโพเนนต์ - เสี่ยงต่อการที่ Developer เผลอเลือกคู่สีที่ ตกเกณฑ์ WCAG Contrast AA ในโหมดใดโหมดหนึ่ง
- ไม่ยืดหยุ่นหากอนาคตต้องการเพิ่มธีมที่ 3 หรือ 4 (เช่น High Contrast Theme หรือ Brand Theme)
ทางออกที่ดีที่สุดในระดับ Enterprise คือการสร้าง Semantic Design Tokens โดยใช้ CSS Variables ผสานเข้ากับ Tailwind CSS ซึ่งช่วยให้คลาสใน HTML สะอาดขึ้น เช่น เรียกใช้เพียง bg-surface text-content-main แล้วปล่อยให้ตัวแปร CSS สลับค่าสีและรักษาระดับคอนทราสต์ที่ผ่านเกณฑ์ AA อัตโนมัติ
กำหนดค่า CSS Variables (เทคนิค Color Channels เพื่อรองรับ Opacity)
ข้อจำกัดทั่วไปเมื่อนำ CSS Variables มาผูกกับ Tailwind คือ utility สำหรับปรับความโปร่งใส (เช่น bg-surface/80) มักใช้งานไม่ได้ ทางแก้คือ เก็บค่าสีเป็น RGB หรือ HSL Channels โดยไม่ต้องใส่ฟังก์ชันครอบ ไว้ในไฟล์ globals.css
CSS
/* globals.css */
@layer base {
:root {
/* ---------------- Light Theme ---------------- */
/* Surfaces */
--color-surface-base: 255 255 255; /* #ffffff */
--color-surface-subtle: 248 250 252; /* slate-50 */
--color-surface-card: 255 255 255; /* #ffffff */
--color-surface-border: 226 232 240; /* slate-200 */
/* Typography (รับประกัน Contrast Ratio บน Base >= 4.5:1) */
--color-content-main: 15 23 42; /* slate-900 (Contrast ~19:1) */
--color-content-muted: 71 85 105; /* slate-600 (Contrast ~5.8:1 - ผ่าน AA) */
/* Brand / Action */
--color-primary-base: 37 99 235; /* blue-600 */
--color-primary-fg: 255 255 255; /* white */
}
.dark {
/* ---------------- Dark Theme ---------------- */
/* Surfaces */
--color-surface-base: 15 23 42; /* slate-900 */
--color-surface-subtle: 2 6 23; /* slate-950 */
--color-surface-card: 30 41 59; /* slate-800 */
--color-surface-border: 51 65 85; /* slate-700 */
/* Typography (ปรับคู่สีป้องกัน Halation และผ่าน AA) */
--color-content-main: 248 250 252; /* slate-50 (Contrast ~15:1) */
--color-content-muted: 203 213 225; /* slate-300 (Contrast ~6.3:1 - ผ่าน AA บน card 800) */
/* Brand / Action */
--color-primary-base: 59 130 246; /* blue-500 */
--color-primary-fg: 15 23 42; /* slate-900 */
}
}
ผูกตัวแปรเข้ากับ Tailwind Config
ในไฟล์ tailwind.config.js ให้กำหนด darkMode: 'class' และตั้งฟังก์ชันแปลง CSS Variable ให้รองรับ Slash Opacity ():
CSS
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
function withOpacity(variableName) {
return ({ opacityValue }) => {
if (opacityValue !== undefined) {
return `rgb(var(${variableName}) / ${opacityValue})`;
}
return `rgb(var(${variableName}))`;
};
}
module.exports = {
darkMode: 'class', // ใช้คลาส .dark เป็นตัว Trigger
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
surface: {
DEFAULT: withOpacity('--color-surface-base'),
subtle: withOpacity('--color-surface-subtle'),
card: withOpacity('--color-surface-card'),
border: withOpacity('--color-surface-border'),
},
content: {
main: withOpacity('--color-content-main'),
muted: withOpacity('--color-content-muted'),
},
primary: {
DEFAULT: withOpacity('--color-primary-base'),
fg: withOpacity('--color-primary-fg'),
},
},
},
},
plugins: [],
};
เปรียบเทียบความสะอาดของโค้ดคอมโพเนนต์
หลังจากเซต Semantic Tokens เสร็จสิ้น การเขียนคอมโพเนนต์จะทำได้เร็วขึ้น ไม่ต้องเขียนโค้ดสีแยก Light/Dark ในทุกจุด
| วิธีแบบดั้งเดิม (คลาสยาวและเสี่ยงสีหลุดเกณฑ์) | วิธี Dynamic Design Tokens (สะอาดและปลอดภัย) |
|---|---|
className="bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-200 dark:border-slate-700 p-4 rounded-xl"
|
className="bg-surface-card text-content-main border border-surface-border p-4 rounded-xl"
|
นอกจากนี้ยังสามารถใส่ Opacity ได้ตามปกติ เช่น bg-surface-card/80 backdrop-blur-md ตัว Tailwind จะแปลงเป็น rgb(var(--color-surface-card) / 0.8) ให้ทันที
การจัดการ Toggle State แบบไร้อาการหน้าเว็บกะพริบ (No Flash of Unstyled Theme – FOUC)
ปัญหาสำคัญของ Next.js และ SSR คือเมื่อโหลดหน้าเว็บครั้งแรก หากใช้ useEffect สลับคลาส .dark จะเกิดอาการหน้าขาวแวบขึ้นมาก่อนเป็นสีมืด (Theme Flashing)
วิธีแก้: ฝัง Script ขนาดเล็กไว้ใน <head> เพื่อตรวจสอบ localStorage หรือ prefers-color-scheme ก่อนที่ React จะเริ่ม Mount DOM
CSS
<!-- วางใน <head> ของ root layout.tsx หรือ _document.tsx -->
<script>
(function() {
try {
const storedTheme = localStorage.getItem('theme');
const supportDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (storedTheme === 'dark' || (!storedTheme && supportDarkMode)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (_) {}
})();
</script>
ตัวอย่างปุ่มสลับธีม (Toggle Component ใน React)
CSS
'use client';
import React, { useState, useEffect } from 'react';
export const ThemeToggle = () => {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, []);
const toggleTheme = () => {
const nextDark = !isDark;
setIsDark(nextDark);
if (nextDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
};
return (
<button
type="button"
onClick={toggleTheme}
aria-label={isDark ? 'เปลี่ยนเป็นธีมสว่าง' : 'เปลี่ยนเป็นธีมมืด'}
className="p-2.5 rounded-lg border border-surface-border bg-surface text-content-main
hover:bg-surface-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{isDark ? '☀️️ โหมดสว่าง' : '🌙 โหมดมืด'}
</button>
);
};
การขยายสู่ Multi-theme (เช่น High-Contrast Theme สำหรับ A11y)
ข้อดีสูงสุดของสถาปัตยกรรมนี้คือ หากต้องการเพิ่มธีมที่ 3 เช่น High Contrast Mode เพื่อให้ผ่านเกณฑ์ WCAG Level AAA เพียงแค่ประกาศคลาสเพิ่มใน globals.css โดยไม่ต้องแตะต้อง Component HTML แม้แต่จุดเดียว:
CSS
/* โหมดคอนทราสต์เข้มข้นพิเศษ */
.high-contrast {
--color-surface-base: 0 0 0; /* ดำสนิท */
--color-surface-card: 0 0 0;
--color-surface-border: 255 255 255; /* ขอบขาวชัดเจน */
--color-content-main: 255 255 255; /* ตัวหนังสือขาวสนิท (Contrast 21:1) */
--color-content-muted: 255 255 0; /* สีเหลืองนีออนสำหรับคำอธิบายรอง */
--color-primary-base: 0 255 255; /* ฟ้า Cyan สด */
--color-primary-fg: 0 0 0;
}
โครงสร้างแบบ Semantic CSS Variables จึงเป็นการแยกชั้นความรับผิดชอบอย่างสมบูรณ์: นักออกแบบและ Lead Dev ดูแลกฎเกณฑ์ความปลอดภัยของ Contrast ในระดับ Global Theme ขณะที่ทีม Front-end สามารถหยิบคลาสมาตรฐานไปประกอบหน้าตาเว็บได้อย่างรวดเร็วและมั่นใจในมาตรฐานการเข้าถึง