Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to force theme (app router) #226

Open
dvvolynkin opened this issue Oct 26, 2023 · 14 comments
Open

How to force theme (app router) #226

dvvolynkin opened this issue Oct 26, 2023 · 14 comments

Comments

@dvvolynkin
Copy link

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
Copy link

linkb15 commented Oct 27, 2023

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
Copy link
Author

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

having two global folders looks too much in this case

@dvvolynkin
Copy link
Author

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
Copy link

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
Copy link
Author

dvvolynkin commented Mar 6, 2024

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
Copy link

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
Copy link
Author

dvvolynkin commented Mar 6, 2024

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
Copy link

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
Copy link
Owner

pacocoursey commented Mar 13, 2024

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
Copy link

dynjo commented Jul 23, 2024

Anybody found a better way to do this yet?

@rohitDalalStrique
Copy link

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
Copy link

dynjo commented Jul 28, 2024

@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
Copy link

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
Copy link

kaijuh commented Aug 27, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants