Prevent page flash in Next.js 12 with Tailwind CSS class-based dark mode
Asked Answered
R

4

16

How can one prevent the page flash when using class-based dark mode in Tailwind CSS with Next.js v12 without using any 3rd party pkgs like next-themes?

I've looked at:

// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
    document.documentElement.classList.add('dark')
} else {
    document.documentElement.classList.remove('dark')
}

I think they restrict putting things in Head from v12 to prepare for Suspense / Streaming / React v18.

Either way, I'm lost on how to do it without next-themes, does anyone know how can we just inject that bit of script to prevent that page flash?

Hope this question makes sense, if not, please give me a shout.

I like simple and minimalistic things, hence the aim to reduce the dependency on 3rd party pkgs, such a simple thing should be possible without overcomplicated solution IMO.

Replevin answered 26/2, 2022 at 14:56 Comment(0)
P
19

I had the same problem and solved it like this in Next 12.1.0:

  1. Create theme.js within the public folder (mine has the following content):
;(function initTheme() {
  var theme = localStorage.getItem('theme') || 'light'
  if (theme === 'dark') {
    document.querySelector('html').classList.add('dark')
  }
})()
  1. Add <Script src="/theme.js" strategy="beforeInteractive" /> to _app.tsx or _app.jsx. This is important - I tried putting it inside _document.tsx which didn't work. The final _app.tsx looks like this:
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import Head from 'next/head'
import Script from 'next/script'

function App({ Component, pageProps }: AppProps) {
  return <>
    <Head>
      <meta name="viewport" content="initial-scale=1.0, width=device-width" />
    </Head>
    <Script src="/theme.js" strategy="beforeInteractive" />
    <Component {...pageProps} />
  </>
}

export default App

Works like a charm for me - no flickering and all Next.js magic. :) Hope this works for you too.

Panther answered 14/3, 2022 at 15:14 Comment(5)
I'm getting an error: Hydration failed because the initial UI does not match what was rendered on the server. when refreshing the page - any ideas?Pharmacology
@Pharmacology were you able to fix that hydration failed issue?Albina
@RonaldBluthl the Next.js docs say that beforeInteractive only works from _document, did you encounter any issues? nextjs.org/docs/basic-features/script#beforeinteractiveAlbina
@Pharmacology I believe this is since the v12 update, I had this solution in place before and had no issue but since 12 I'm seeing this console log as well. It looks like we'll need to find a solution or live with the temporary error.Limbus
@Albina yes in the end the error was resulting for me due to the dark mode library i was using, in the end i followed their documentation and resolved the bugPharmacology
P
9

NextJS 13 with App Router:

After looking at the Page Source Code for the Twailwind website directly (by clicking command+option+u on Chrome MacOS), we can see that they are using the exact strategy they describe in their Dark Mode documentation.

However, combining this with NextJs' Inline Script Optimizations will not work because they wrap the content of inline scripts inside a call to next_js, which ultimately requires the page to load NextJS before setting the dark mode (hence the FOUC).

To avoid this, we can use a plain script tag without optimization inside our root layout and set its content using the dangerouslySetInnerHTML property like so:

<html lang="en" suppressHydrationWarning>
  <head>
    <script dangerouslySetInnerHTML={{
      __html: `
        try {
          if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
            document.documentElement.classList.add('dark')
          } else {
            document.documentElement.classList.remove('dark')
          }
        } catch (_) {}
      `
    }}/>

    {/* Other Meta Tags, Links, Etc... */}
  </head>
  
  <body className="text-slate-500 dark:text-slate-400 bg-white dark:bg-slate-900">
    {/* Content */}
  </body>
</html>

That way, our script will be one of the first to execute when the page loads, preventing a flash of unstyled content.

[EDIT]:

This approach causes NextJS to throw a Prop 'className' did not match warning in dev mode when the "dark" class is present in the client HTML but not in the server-rendered HTML. This is to be expected since we changed the structure of the HTML before NextJS had a chance to verify it. We can manually suppress the warning by setting suppressHydrationWarning on the opening HTML tag.

Cheers!

Ponderable answered 28/5, 2023 at 6:21 Comment(0)
B
2

Piggybacking on @Fausto's answer:

NextJS 13 with App Router:

With the app router, there is a way to do this using fully server side rendering, but I had to go with @Fausto's approach, as I needed SSG. You can use the cookies() dynamic function in the RootLayout (since that can be an async function itself), and then the theme will be applied to the html prior to the page being rendered.

    const cookieStore = cookies();
    const theme = cookieStore.get("theme");

    return (
        <html lang="en" data-theme={theme}>
        ...
Bor answered 18/7, 2023 at 17:59 Comment(0)
H
1

In nextjs 13 it should work in the _document.tsx in the Pages Router if you write it like this:

import { Html, Head, Main, NextScript } from 'next/document'
import Script from 'next/script'

export default function Document() {
  return (
    <Html lang="en">
      <Head >
        <Script src="/theme.js" strategy="beforeInteractive"/>
      </Head>
      <body 
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}
Hendecasyllable answered 9/3, 2023 at 15:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.