Implementing Dark Mode in applications can be challenging, especially for large or legacy applications. It requires incrementally updating different parts of your app before rolling out the complete dark mode feature to your users. However, there are libraries available, such as Radix UI, that can simplify the process of implementing Dark Mode for your application. or legacy. You need to incrementally update parts of your app one by one before you finally roll out the whole dark mode feature to your users.
Even if you did succeed in implementing Dark mode on your application, you will encounter the annoying but also inevitable flickering issue if your application is server-side rendered or hybrid between client and server if you are trying to load the state from localStorage or cookie on the client side. This happens due to the latency of downloading the script, executing it, and toggling the state if a mismatch has been found. This can be bypassed if the initial checks are performed on the server side instead of the client side.
To solve this issue, we need to initialize the user preferences of dark mode, light mode, or system on the server side and then send the rendered HTML with the initialized classes to the client for initial hydration which avoids this flickering as there is no logic being applied on the client side.
Server Side (NextJs)
We'll be taking this website as a reference in this example. It's built using Next.js, but these principles can be applied across any framework and tech stack. Here’s how we've implemented Dark Mode and solved the flickering issue:
- Read the Cookie: On the server side, read the cookie to get the value of a specific variable—darkMode in this case.
- Apply CSS Classes: Apply the necessary CSS classes that control the Dark Mode theme on the front end.
- Hydrate Client Component: Pass the dark mode state down to the client component so it's hydrated with the correct default value. This ensures that it doesn't render the wrong user-selected preference initially, thereby eliminating the flickering issue.
import { cookies } from 'next/headers' export default function RootLayout({children}){ const cookieStore = cookies() const darkModeSsrValue = cookieStore.get('darkMode')?.value || false return <div id="base" className={`bg-gray-100 flex flex-col ${darkModeSsrValue==="true"?'rad-ui-dark-theme':''} `}> <NavBar darkModeSsrValue={darkModeSsrValue} /> {children} </div> }
Client Side
We're almost done with 90% of the implementation, we only need to update the cookie when the user toggles it on the client side
We're using nookies on the client side to set cookie values, we start by initializing the dark mode value that the server has supplied, maintaining a state on the client side.
import { parseCookies, setCookie, destroyCookie } from 'nookies' const NavBar = ({darkModeSsrValue})=>{ const [theme, setTheme] = useState(darkModeSsrValue === "true" ? 'dark' : 'light') return <div> NavBar stuff</div> } The toggling can now be carried out on the client side. const toggleTheme = () => { const cookies = parseCookies(); let elem = window?.document.querySelector("#base"); let classList = elem?.classList || []; if (theme === "light") { // switching to dark setTheme("dark"); classList.add("rad-ui-dark-theme"); } else { // switching to light classList.remove("rad-ui-dark-theme"); setTheme("light"); } let cookieVal = theme === "dark" ? false : true; setCookie(cookies, "darkMode", cookieVal, { maxAge: 30 * 24 * 60 * 60, }); };
that should take care of it, you can now update the cookie so the server can track it, as well as keep a separate state on the client while directly manipulating the DOM. Of course the code can be better, you can control it via a React context, I've done it by using good old vanilla JS in this example to keep things simple