PlusMagi's Blog By Pitt Phunsanit Technology เทคนิคทำระบบ Dynamic Dark/Light Theme ด้วย Tailwind CSS และ CSS Custom Properties

เทคนิคทำระบบ Dynamic Dark/Light Theme ด้วย Tailwind CSS และ CSS Custom Properties

วิธีดั้งเดิมในการทำ 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 สามารถหยิบคลาสมาตรฐานไปประกอบหน้าตาเว็บได้อย่างรวดเร็วและมั่นใจในมาตรฐานการเข้าถึง


Exit mobile version