pacocoursey / next-themes

Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme with no flashing
https://next-themes-example.vercel.app/
MIT License
5.14k stars 188 forks source link

How to force theme (app router) #226

Open dvvolynkin opened 1 year ago

dvvolynkin commented 1 year ago

How can I properly force a specific theme for a page using the app router?

I've looked at the example in the README, but it only cover the case for pages.

linkb15 commented 1 year ago

In my case, I have 2 theme providers where one is forced and one is not forced. And in App Router, we can use the grouping folder (group1) and (group2).

Put the non forced in (group1) folder and forced one inside the layout of (group2) folder. In this case, the layouts will affects only the pages inside each own groups.

dvvolynkin commented 1 year ago

I have 1 page that needs to be with a forced theme

having two global folders looks too much in this case

dvvolynkin commented 1 year ago

I made a provider like this But it doesn't solve the problem completely, for a second when loading the original theme appears

"use client"

import * as React from "react";
import { ThemeProvider as NextJSThemesProvider } from "next-themes";
import { ThemeProviderProps as NextJSThemesProviderProps } from "next-themes/dist/types";

interface ForcedThemeContextProps {
  forcedTheme: string | null;
  setForcedTheme: React.Dispatch<React.SetStateAction<string | null>>;
}

const ForcedThemeContext = React.createContext<ForcedThemeContextProps | undefined>(undefined);

export function useForcedThemeControl(): ForcedThemeContextProps {
  const context = React.useContext(ForcedThemeContext);
  if (!context) {
    throw new Error("useForcedThemeControl must be used within a ForcedThemeContextProvider");
  }
  return context;
}

interface ForcedThemeContextProviderProps {
  children: React.ReactNode;
}

export function ForcedThemeContextProvider({ children }: ForcedThemeContextProviderProps): JSX.Element {
  const [forcedTheme, setForcedTheme] = React.useState<string | null>(null);
  return (
    <ForcedThemeContext.Provider value={{ forcedTheme, setForcedTheme }}>
      {children}
    </ForcedThemeContext.Provider>
  );
}

interface ThemeSetterProps {
  children: React.ReactNode;
}

function DarkTheme({ children }: ThemeSetterProps): JSX.Element {
  const { setForcedTheme } = useForcedThemeControl();
  React.useEffect(() => {
    setForcedTheme("dark");
  }, []);
  return <>{children}</>;
}

function LightTheme({ children }: ThemeSetterProps): JSX.Element {
  const { setForcedTheme } = useForcedThemeControl();
  React.useEffect(() => {
    setForcedTheme("light");
  }, []);
  return <>{children}</>;
}

interface CombinedThemeProviderProps extends NextJSThemesProviderProps {
  children: React.ReactNode;
}

const CombinedThemeProvider = ({ children, ...props }: CombinedThemeProviderProps): JSX.Element => {
  const { forcedTheme } = useForcedThemeControl();
  return (
    <NextJSThemesProvider {...props} forcedTheme={forcedTheme || undefined}>
      {children}
    </NextJSThemesProvider>
  );
};

function ThemeProvider({ children, ...props }: CombinedThemeProviderProps): JSX.Element {
  return (
    <ForcedThemeContextProvider>
      <CombinedThemeProvider {...props}>
        {children}
      </CombinedThemeProvider>
    </ForcedThemeContextProvider>
  );
}

export {
  ThemeProvider,
  LightTheme,
  DarkTheme
}
rafaelquintanilha commented 8 months ago

I was facing the same problem. Creating route segments would be enough, but I wanted to avoid this as I also had only one page with forced theme.

The solution I came up with was the following:

  1. Create a DarkModeWrapper client component:
"use client";

import { useTheme } from "next-themes";
import { useEffect } from "react";

export function DarkModeWrapper({ children }: { children: React.ReactNode }) {
  const { setTheme } = useTheme();

  useEffect(() => {
    setTheme("dark");
    return () => {
      setTheme("light");
    };
  }, []);

  return children;
}
  1. Wrap the desired page with it:
import { DarkModeWrapper } from "@/components/common/DarkModeWrapper";

export default Page = () => {
  return (
    <DarkModeWrapper>
      <h1>Hello Dark Mode</h1>
    </DarkModeWrapper>
  );
};
dvvolynkin commented 8 months ago

Isn't setTheme setting theme globally?

In this case, your local wrapper is affecting the whole website

    setTheme("dark");
    return () => {
      setTheme("light");
    };
  }, []);

This code will make the dark theme on one page but will set the light theme for other pages even if the dark theme is selected there.

rafaelquintanilha commented 8 months ago

Isn't setTheme setting theme globally?

In this case, your local wrapper is affecting the whole website

    setTheme("dark");
    return () => {
      setTheme("light");
    };
  }, []);

This code will make the dark theme on one page but will set the light theme for other pages even if the dark theme is selected there.

That was my case, but I suppose you can set resolvedTheme to a useRef and then just pass it when unmounting the component.

dvvolynkin commented 8 months ago

That was my case, but I suppose you can set resolvedTheme to a useRef and then just pass it when unmounting the component.

Open two pages, one with the light theme and one with your implementation of forced dark. Opening the dark theme page will change the theme of the first one.

Also selecting any theme on the main website theme selector will change the dark theme page.

rafaelquintanilha commented 8 months ago

That was my case, but I suppose you can set resolvedTheme to a useRef and then just pass it when unmounting the component.

Open two pages, one with the light theme and one with your implementation of forced dark. Opening the dark theme page will change the theme of the first one.

Also selecting any theme on the main website theme selector will change the dark theme page.

You're right. Sorry, I just covered my very narrow use-case.

What I ended up doing, however, is what is described here. That works fine with Tailwind because I can set the dark specifier on the inner component. It works correctly in multiple tabs as well. However, if it's important for you, this won't change resolvedTheme, as pointed out.

pacocoursey commented 7 months ago

I solved this in a hacky way: using usePathname() in a client component, then determining whether the theme should be forced based on some regex matching for specific paths. Then, I passed the forced theme to the ThemeProvider. Pseudo code example here:

"use client"
import { usePathname } from 'next/navigation'
import { ThemeProvider } from 'next-themes'

export const Providers = (props) => {
    const pathname = usePathname();
    const forcedThemeFromPathname = pathname === "/dark-only" ? "dark" : undefined;

    return (
        <ThemeProvider forcedTheme={forcedThemeFromPathname}>
            {props.children}
        </ThemeProvider>
    )
}

I'd like to find a better solution but so far I've got nothing. We need a way to pass information up from a page.tsx (server component) file to the root layout.

dynjo commented 3 months ago

Anybody found a better way to do this yet?

rohitDalalStrique commented 3 months ago

here is what i did (suggestions & improvements are welcome)


//providers/theme-provider.tsx

'use client';

import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';
import { usePathname } from 'next/navigation';

/*
Most likely your app will have a pre-defined
set of static routes to apply specific themeing.
If this is your case, yay!
*/

const forcedThemeRoutes: Record<string, string> = {
  '/app/onboarding': 'light'

   //keep adding themes for routes
};

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  const pathname = usePathname();
  const isForcedThemeRoute = pathname in forcedThemesRoutes;

  return (
    <NextThemesProvider
      {...(isForcedThemeRoute && { forcedTheme: forcedThemesRoutes[pathname] })}
      {...props}>
      {children}
    </NextThemesProvider>
  );
}
dynjo commented 3 months ago

@rohitDalalStrique that is basically what I ended up doing (as per @pacocoursey guidance above) but it is pretty damn hacky and dynamic paths become problematic. Would be great to find a better solution.

theoludwig commented 3 months ago

Another solution is to put the ThemeProvider in 2 separated layouts. @dynjo

For example: given that I would like light/dark theme for the whole website (/, /blog, /blog/[slug]) but not for /curriculum-vitae which need to always be in light mode. The structure in the app directory can be the following:

├── curriculum-vitae
│   ├── layout.tsx
│   └── page.tsx
├── layout.tsx
├── (main)
│   ├── blog
│   │   ├── page.tsx
│   │   └── [slug]
│   │       └── page.tsx
│   ├── layout.tsx
│   └── page.tsx

With app/(main)/layout.tsx:

import { ThemeProvider } from "next-themes"

const MainLayout: React.FC = () => {
  return <ThemeProvider attribute="class">{children}</ThemeProvider>
}

export default MainLayout

With app/curriculum-vitae/layout.tsx:

import { ThemeProvider } from "next-themes"

const CurriculumVitaeLayout: React.FC = () => {
  return <ThemeProvider attribute="class" forcedTheme="light">{children}</ThemeProvider>
}

export default CurriculumVitaeLayout

This effectively force light theme mode under /curriculum-vitae paths, but everywhere else in (main) route group, the theme is dynamic and can be light or dark.

kaijuh commented 2 months ago

has someone found a better way? I am using the app router but the solutions work but like some say, are a bit hacky