บทความ

รู้มั้ยว่า Dark Mode มี 3 สถานะ ไม่ใช่ 2 นะ

เว็บส่วนใหญ่สร้างไว้แค่สว่างกับมืด แล้วทำสถานะที่สามหายไปตั้งแต่ผู้ใช้กดปุ่มครั้งแรก

เผยแพร่

เพื่อน ๆ ที่ใช้แมคแล้วตั้งค่าให้เปลี่ยนธีมตามเวลาของวัน คงเคยเห็นนะครับ พอพระอาทิตย์ตกเครื่องก็สลับเป็นโหมดมืดให้เอง เราไม่ได้ไปกดอะไรเลยสักนิด ทั้งเครื่องมืดลงพร้อมกันอย่างเงียบ ๆ

แต่ลองนึกภาพตามนะครับ บนเว็บหลายแห่ง แท็บที่คุณเปิดค้างไว้ตั้งแต่บ่ายยังขาวโพลนอยู่อย่างนั้น เพราะเมื่อสามสัปดาห์ก่อนคุณเคยกดปุ่มสลับธีมไปหนึ่งครั้ง แล้วตั้งแต่วินาทีนั้นเว็บก็เลิกฟังระบบปฏิบัติการไปตลอดกาล

เพื่อน ๆ รู้มั้ยครับว่านี่คือบั๊กที่เจอบ่อยที่สุดเรื่องหนึ่งของโหมดมืด และต้นเหตุของมันก็คือการนับสถานะผิดมาตั้งแต่ต้น

จริง ๆ แล้วมันมีสามสถานะ ไม่ใช่สอง

หนึ่ง ตามระบบปฏิบัติการ อันนี้เป็นค่าเริ่มต้นของคนที่ยังไม่เคยเลือกอะไรเลย และมันต้องตามแบบสด ๆ ด้วยนะครับ คือถ้าเครื่องเปลี่ยนธีมระหว่างที่หน้าเว็บเปิดค้างอยู่ หน้าเว็บก็ต้องเปลี่ยนตามไปด้วย สอง บังคับสว่าง และสาม บังคับมืด สองอันหลังนี้คือคนที่เลือกไว้เองแล้ว เราก็ต้องเคารพสิ่งที่เขาเลือก แม้เครื่องจะตั้งไว้ตรงข้ามก็ตาม

ที่ท่านผู้อ่านตั้งค่าของเครื่องกันได้ ก็ที่ macOS System Settings แล้วไป Appearance, iOS ที่ Settings แล้ว Display & Brightness, Windows 11 ที่ Settings แล้ว Personalisation แล้ว Colours ส่วน Android อยู่ที่ Settings แล้ว Display แล้ว Dark theme หลายระบบยังตั้งให้สลับตามเวลาของวันได้อีกด้วย เว็บของเราจึงต้องคอยฟังอยู่ตลอดครับ ไม่ใช่อ่านค่าครั้งเดียวตอนโหลดหน้าแล้วจำไว้เฉย ๆ

กฎของธีมมืดในเว็บนี้เราเลยเขียนไว้สองชุด ชุดแรกอยู่ใต้ @media (prefers-color-scheme: dark) ไว้ให้คนที่ไม่เคยเลือก โดยมี :root:not([data-theme="light"]) กันไว้ เพื่อให้การเลือกสว่างชนะเสมอ อีกชุดอยู่ใต้ :root[data-theme="dark"] สำหรับคนที่เลือกไว้แล้ว จุดสำคัญคือ การไม่มีค่าที่เก็บไว้แปลว่าให้ตามเครื่องนะครับ ไม่ได้แปลว่าสว่าง

จอกะพริบเป็นธีมผิดอยู่หนึ่งเฟรม

การอ่านค่าที่เก็บไว้แล้วใส่ data-theme ให้ <html> ต้องเกิดก่อนการวาดเฟรมแรกครับ และวิธีเดียวที่รับประกันได้จริง ๆ คือสคริปต์แบบบล็อกใน <head> เพราะเบราว์เซอร์จะหยุดสร้างเอกสารเพื่อรันมันให้จบก่อน แล้วค่อยวาด

สิ่งที่ทำไม่ได้เลยคือย้ายไปไว้ใน effect ของ React ครับ เพราะ effect ทำงานหลังการวาดตามนิยามของมันเอง เขียนให้เร็วแค่ไหนก็สายไปแล้วหนึ่งเฟรม แล้วหนึ่งเฟรมสีขาวเต็มจอในห้องมืดเนี่ย คนสังเกตเห็นทุกครั้งจริง ๆ นะครับ

สคริปต์ตัวนี้เราเลยเขียนให้สั้นมากโดยตั้งใจ อ่านค่าจาก localStorage ถ้าเป็น dark หรือ light ก็ใส่แอตทริบิวต์ลงไป ถ้าไม่มีค่าก็ไม่ต้องใส่อะไรเลย ปล่อยให้ media query ทำงานต่อ แล้วทั้งก้อนเราครอบด้วย try catch ไว้ เพราะการอ่าน storage มันโยน error ได้ในโหมดส่วนตัว

ปุ่มที่ไม่เก็บ state ไว้เลยสักตัว

ปุ่มสลับธีมของเว็บนี้ไม่มี useState เลยครับ ฟังครั้งแรกอาจดูแปลก แต่เหตุผลน่าสนใจมากเลยแหละ ธีมเป็นสิ่งที่รู้ได้เฉพาะในเบราว์เซอร์ ส่วนเซิร์ฟเวอร์นั้นเรนเดอร์ HTML ชุดเดียวกันให้ทุกคน มันไม่รู้หรอกว่าเครื่องของผู้อ่านตั้งไว้อย่างไร และไม่รู้ว่าคุณเคยเลือกอะไรไว้ ถ้าเราเรนเดอร์ธีมปัจจุบันลงไปใน JSX มาร์กอัปที่เซิร์ฟเวอร์ส่งมา กับมาร์กอัปที่เบราว์เซอร์คิดว่าถูกต้องในการเรนเดอร์ครั้งแรก จะไม่ตรงกัน นั่นแหละครับคือ hydration mismatch แบบตรง ๆ

ทางออกของเราคือเรนเดอร์ทั้งสองอย่างไปเลย ทั้งไอคอนดวงอาทิตย์และดวงจันทร์ ทั้งข้อความสองชุด แล้วให้ CSS เป็นคนตัดสินว่าอันไหนจะแสดง HTML ที่ทุกคนได้รับจึงเหมือนกันหมด และไอคอนที่ถูกก็ปรากฏตั้งแต่เฟรมแรกโดยไม่ต้องรอ JavaScript ส่วนการกดปุ่มก็แค่เขียนแอตทริบิวต์บน <html> แล้วบันทึกลง localStorage ไม่มีสำเนาของความจริงชุดที่สองให้ต้องคอยไล่จูนให้ตรงกัน

ป้ายควรบอกสิ่งที่จะเกิด ไม่ใช่สิ่งที่เป็นอยู่

ปุ่มสองสถานะมีความกำกวมแบบคลาสสิกอยู่ครับ ปุ่มที่เขียนว่า "โหมดมืด" ตกลงมันหมายความว่าตอนนี้เราอยู่ในโหมดมืด หรือกดแล้วจะเข้าโหมดมืดกันแน่

เว็บนี้เราเลือกให้ป้ายบอกการกระทำครับ อยู่ในธีมสว่าง ปุ่มก็เขียนว่า "โหมดมืด" เพราะนั่นคือสิ่งที่จะเกิดขึ้นถ้าคุณกด เหตุผลง่าย ๆ คือสถานะปัจจุบันคุณมองเห็นอยู่แล้วจากทั้งหน้าจอ สิ่งที่มองไม่เห็นต่างหากคือผลของการกด

color-scheme บรรทัดที่คนลืมกันบ่อยที่สุด

color-scheme ทำหน้าที่บอกเบราว์เซอร์ว่าหน้านี้เป็นธีมอะไรครับ ผลของมันไม่ได้อยู่ที่สิ่งที่เราวาดเอง แต่อยู่ที่ส่วนที่เบราว์เซอร์วาดให้ ได้แก่แถบเลื่อน ตัวควบคุมของฟอร์ม และพื้นที่ที่โผล่มาเวลาดึงหน้าเกินขอบ ถ้าไม่ตั้งค่านี้ คุณจะได้หน้าเว็บสีเข้มที่นั่งอยู่ในกรอบสีขาว ซึ่งขัดตามากครับ

อีกรายละเอียดที่เกี่ยวกันและเราอยากฝากไว้ คือสีพื้นต้องอยู่บน html ไม่ใช่แค่ body เพราะช่องว่างระหว่างส่วนต่าง ๆ และพื้นที่ overscroll มันไปเอาสีจาก html ก่อน

สีแบรนด์สีเดียวทำสองหน้าที่ไม่ได้

ส่วนนี้กินเวลาเรานานที่สุดในงานทั้งหมดเลยครับ และยอมรับตามตรงว่าเซอร์ไพรส์พวกเราเหมือนกัน สีน้ำเงินของแบรนด์ทำสองหน้าที่พร้อมกันมาตลอด เป็นสีพื้นของปุ่ม และเป็นสีของตัวอักษรลิงก์ ตราบใดที่พื้นหลังยังขาว สองอย่างนี้ก็อยู่ด้วยกันได้อย่างสงบสุข

พอเพิ่มธีมมืดเข้ามา สีเดียวกันนี้บนพื้นเกือบดำกลับอ่านไม่ออกครับ เราลองทำให้สว่างขึ้น ก็แก้ตัวหนังสือได้จริง แต่ปุ่มทุกปุ่มก็สว่างขึ้นตามไปด้วย แล้วตัวอักษรขาวบนปุ่มก็เสียคอนทราสต์ทันที แก้อย่างหนึ่งพังอีกอย่าง วนอยู่แบบนั้น

ทางออกคือแยกออกเป็นสองโทเคนครับ สีพื้นของปุ่มยังเป็น #0049ab ของแบรนด์ในทั้งสองธีมและไม่ขยับไปไหน ส่วนตัวอักษรและลิงก์ได้โทเคนของตัวเอง ที่ขยับขึ้นเป็นน้ำเงินสว่างเมื่ออยู่บนพื้นมืด บทเรียนที่เราได้และอยากส่งต่อคือ เมื่อสีหนึ่งถูกใช้ในสองบทบาทที่มีข้อกำหนดคอนทราสต์ต่างกัน จริง ๆ แล้วมันคือสองโทเคนที่บังเอิญมีค่าเท่ากันมาตลอด ธีมมืดเป็นแค่สิ่งที่ทำให้เรื่องนี้โผล่ขึ้นมาให้เราเห็น

ธีมมืดไม่ใช่การกลับสีนะครับ

ธีมมืดที่ทำโดยกลับค่าสีทั้งหมด จะได้ตัวอักษรขาวสนิทบนพื้นดำสนิท ซึ่งอ่านนาน ๆ แล้วล้าตามากครับ เว็บนี้เราเลยใช้พื้นเกือบดำที่มีสีเจืออยู่นิดหน่อย และใช้สีตัวอักษรที่นวลลงมาหนึ่งขั้นจากขาวบริสุทธิ์ ความต่างนี้แทบไม่เห็นเลยในภาพหน้าจอ แต่คุณจะรู้สึกได้หลังอ่านไปสักสิบนาที

แล้วที่ GIPSIC เราทำส่วนไหน

พวกเราทำเว็บไซต์และระบบให้องค์กรครับ รายละเอียดระดับที่เล่าให้ฟังในบทความนี้คือส่วนหนึ่งของงาน ไม่ใช่ของแถม

ถ้าคุณกำลังจะเพิ่มธีมมืดให้ระบบที่มีอยู่ เรื่องแรกที่เราจะชวนคุยตั้งแต่ประชุมนัดแรกคือชุดสีของคุณครับ เพราะงานที่กินเวลาจริง ๆ มักไม่ใช่การเขียน CSS แต่คือการค้นพบว่าสีหนึ่งสีถูกใช้ทำหลายหน้าที่มาตลอดโดยไม่มีใครทันสังเกต

อยากคุยเรื่องระบบของคุณ ติดต่อเราได้

เกี่ยวกับผู้เขียน

ภาพถ่ายของFilm

ฟิล์ม — วิสิฏฐ์ ครับ ทำธุรกิจเป็นหลัก แต่ยังลงมือทำ BA เองอยู่บ่อยกว่าที่ควร และเขียนโค้ดได้ประปรายทั้งหน้าบ้านหลังบ้าน มีธุรกิจเล็ก ๆ อยู่สองสามบริษัท ติดตามวงการเทคโนโลยีและธุรกิจแบบคลั่งไคล้ ทั้งในไทยและต่างประเทศ นอกเวลางานชอบฟิสิกส์ ดาราศาสตร์ งาน DIY และเรื่องเน็ตเวิร์ก เปิดเพลงฟังตลอดเวลาแต่ร้องไม่เป็น ชอบเล่นดนตรีทั้งที่เล่นได้ไม่ค่อยดี เล่นกีฬาหลายอย่าง โดยเฉพาะกีฬาแร็กเกต เสียอยู่อย่างเดียวคือแทบไม่เล่นเกม

เขียนร่วมกับ Claude Opus 5