
Introduction
Welcome to part 5 of the Remix Todo App Series! This series aims to teach you everything you’ll use daily in Remix. In part 4, we improved the todo app by adding a pending UI, making it network-aware and enhancing the user experience.
In this part, we’ll explore different ways to implement a theme switcher in Remix, examining the pros and cons of each approach. Finally, we’ll implement the theme switcher in our todo app using the method that supports progressive enhancement.
Different ways to implement a theme switcher
A theme switcher lets users select a visual theme for an app, typically between light and dark modes. Some implementations may also let users match their system’s preference.
Depending on your app’s needs and your comfort with complexity, there are four main ways to implement a theme switcher:
- React state
- URL search params
- Browser local storage
- Browser cookies
React state
The simplest way to manage state in a React app is with useState
or the
Context API for global state. This allows you to store the user’s theme, style
the app accordingly, and update the state on theme changes. It’s simple, easy to
implement, and component-scoped.
Here’s a theme switcher implementation in a Remix app using useState
and the
Context API:
import React from "react";
export type Theme = "system" | "light" | "dark";type ThemeContextType = [Theme, React.Dispatch<React.SetStateAction<Theme>>];
const ThemeContext = React.createContext<ThemeContextType>([ "system", () => {},]);
export const useTheme = () => { const context = React.useContext(ThemeContext); if (context === undefined) { throw new Error("useTheme must be used within a ThemeProvider"); } return context;};
export default function ThemeProvider({ children }: React.PropsWithChildren) { const [theme, setTheme] = React.useState<Theme>("system");
React.useEffect(() => { switch (theme) { case "system": { const syncTheme = (media: MediaQueryList | MediaQueryListEvent) => { document.documentElement.classList.toggle("dark", media.matches); }; const media = window.matchMedia("(prefers-color-scheme: dark)"); syncTheme(media); media.addEventListener("change", syncTheme); return () => media.removeEventListener("change", syncTheme); } case "light": { document.documentElement.classList.remove("dark"); break; } case "dark": { document.documentElement.classList.add("dark"); break; } default: { console.error("Invalid theme:", theme); } } }, [theme]);
return ( <ThemeContext.Provider value={[theme, setTheme]}> {children} </ThemeContext.Provider> );}
// app/components/ThemeSwitcher.tsximport { type Theme, useTheme } from "~/components/ThemeProvider";
export default function ThemeSwitcher() { const [theme, setTheme] = useTheme();
return ( <select value={theme} onChange={(event) => setTheme(event.target.value as Theme)} > <option value="system">System</option> <option value="light">Light</option> <option value="dark">Dark</option> </select> );}
// tailwind.config.tsimport type { Config } from "tailwindcss";
export default { darkMode: "selector", content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], theme: { extend: {}, }, plugins: [],} satisfies Config;
// app/root.tsx// ...existing importsimport ThemeProvider from "~/components/ThemeProvider";
export function Layout({ children }: { children: React.ReactNode }) { // ...existing code}
export default function App() { return ( <ThemeProvider> <Outlet /> </ThemeProvider> );}
But, the drawbacks of this approach are that it requires JavaScript to function, the state isn’t available for server-side rendering, and the state isn’t persistent across component remounting, page refreshes, or browsing sessions. It fails the test for a good user experience and progressive enhancement.
URL search params
Another way to manage global state, like a theme, is by using the URL. The URL can act as the single source of truth, allowing us to read and set the theme state from it. This approach makes the UI shareable and ensures the state persists across component remounts, page refreshes, or browsing sessions.
Here’s a theme switcher implementation in a Remix app using the URL and the Context API:
import { useSearchParams } from "@remix-run/react";import React from "react";
export type Theme = "system" | "light" | "dark";type ThemeContextType = [Theme, React.Dispatch<React.SetStateAction<Theme>>];
const ThemeContext = React.createContext<ThemeContextType>([ "system", () => {},]);
export const useTheme = () => { const context = React.useContext(ThemeContext); if (context === undefined) { throw new Error("useTheme must be used within a ThemeProvider"); } return context;};
export default function ThemeProvider({ children }: React.PropsWithChildren) { const [searchParams, setSearchParams] = useSearchParams();
const theme = (searchParams.get("theme") as Theme) || "system"; const setTheme = (theme: Theme) => { setSearchParams( (prev) => { prev.set("theme", theme); return prev; }, { replace: true, preventScrollReset: true }, ); };
React.useEffect(() => { switch (theme) { case "system": { const syncTheme = (media: MediaQueryList | MediaQueryListEvent) => { document.documentElement.classList.toggle("dark", media.matches); }; const media = window.matchMedia("(prefers-color-scheme: dark)"); syncTheme(media); media.addEventListener("change", syncTheme); return () => media.removeEventListener("change", syncTheme); } case "light": { document.documentElement.classList.remove("dark"); break; } case "dark": { document.documentElement.classList.add("dark"); break; } default: { console.error("Invalid theme:", theme); } } }, [theme]);
return ( <ThemeContext.Provider value={[theme, setTheme]}> {children} </ThemeContext.Provider> );}
But, this method has some drawbacks. It relies on JavaScript to work, doesn’t support server-side rendering, and requires the theme state to be appended as a search param on every page. If a user manually enters a URL without the search param, the theme state is lost. While it improves the user experience slightly, it fails for progressive enhancement.
Browser local storage
The easiest way to persist a global state, like a theme, beyond the component lifecycle or URL is with local storage. Local storage keeps data across browser sessions without expiration. This makes it ideal for preserving the theme state through component remounting, page refreshes, and browsing sessions.
Here’s a theme switcher implementation in a Remix app using local storage and the Context API:
import React from "react";
export type Theme = "system" | "light" | "dark" | null;type ThemeContextType = [Theme, React.Dispatch<React.SetStateAction<Theme>>];
const ThemeContext = React.createContext<ThemeContextType>([ "system", () => {},]);
export const useTheme = () => { const context = React.useContext(ThemeContext); if (!context) { throw new Error("useTheme must be used within a ThemeProvider"); } return context;};
export default function ThemeProvider({ children }: React.PropsWithChildren) { const [theme, setTheme] = React.useState<Theme>(null);
React.useEffect(() => { const storedTheme = localStorage.getItem("theme"); if (!storedTheme) { localStorage.setItem("theme", "system"); } setTheme(storedTheme ? (storedTheme as Theme) : "system"); }, []);
React.useEffect(() => { if (theme !== null) { localStorage.setItem("theme", theme); }
switch (theme) { case "system": { const syncTheme = (media: MediaQueryList | MediaQueryListEvent) => { document.documentElement.classList.toggle("dark", media.matches); }; const media = window.matchMedia("(prefers-color-scheme: dark)"); syncTheme(media); media.addEventListener("change", syncTheme); return () => media.removeEventListener("change", syncTheme); } case "light": { document.documentElement.classList.remove("dark"); break; } case "dark": { document.documentElement.classList.add("dark"); break; } default: { console.error("Invalid theme:", theme); } } }, [theme]);
return ( <ThemeContext.Provider value={[theme, setTheme]}> {children} </ThemeContext.Provider> );}
// app/components/ThemeSwitcher.tsximport { type Theme, useTheme } from "~/components/ThemeProvider";
export default function ThemeSwitcher() { const [theme, setTheme] = useTheme();
return ( <select value={theme ?? ""} onChange={(event) => setTheme(event.target.value as Theme)} > <option value="system">System</option> <option value="light">Light</option> <option value="dark">Dark</option> </select> );}
But, like React state or URL search params, this approach requires JavaScript and isn’t available during server-side rendering, which causes the UI to flicker. While it improves the user experience compared to the previous approaches, it doesn’t support progressive enhancement, as JavaScript is necessary for the theme switcher to work.
Browser cookies
A cookie is a small piece of data sent from a server to a user’s browser via an HTTP response. The browser can store, create, or modify cookies and send them back to the server on future requests. This allows seamless data sharing between server and browser, making cookies ideal for managing user preferences.
Although cookies require more code and result in a global state that can’t be isolated to a single component, they offer significant benefits. Cookies make the state available server-side for rendering, persist across component remounts, page refreshes, browsing sessions, and serve as a single source of truth to eliminate state synchronization issues. Most importantly, they work without JavaScript, making them ideal for progressive enhancement.
Implementing the theme switcher
To manage theme state with cookies, though it requires more code, the steps are
simple. First, create a cookie object, then use it in your server loader and
action. In the loader
, read the theme from the cookie to style the UI. When
the user changes their theme, update the cookie in the action
, and Remix
reloads the page with the new preference.
Creating the cookie object
Start by creating the file for the cookie object:
touch app/lib/theme-cookie.server.ts
Next, update app/types.ts
to include the Theme
type:
// ...existing code here remains the same
/** * Represents the available theme options for the application. * * - "system": Follows the system's color scheme, but defaults to light if JavaScript is disabled. * - "light": Applies the light color scheme. * - "dark": Applies the dark color scheme. */export type Theme = "system" | "light" | "dark";
Finally, copy the following code into app/lib/theme-cookie.server.tsx
:
import { createCookie } from "@remix-run/node";import type { Theme } from "~/types";
const cookie = createCookie("theme", { maxAge: 31_536_000,});
export function validateTheme(value: unknown): value is Theme { return value === "system" || value === "light" || value === "dark";}
export async function parseTheme(request: Request) { const header = request.headers.get("Cookie"); const vals = await cookie.parse(header);
const theme = vals?.theme; if (validateTheme(theme)) { return theme; } else { return "system"; }}
export function serializeTheme(theme: Theme) { const eatCookie = theme === "system"; if (eatCookie) { return cookie.serialize({}, { expires: new Date(0), maxAge: 0 }); } else { return cookie.serialize({ theme }); }}
This code may seem extensive, so let’s break it down. First, we create a cookie
object using the createCookie
utility from Remix, which creates a logical
container for managing a browser cookie on the server. We name the cookie
theme
and set its maxAge
attribute to one year, while defaults are used for
other
cookie attributes 🔗
that aren’t specified. Next, we define three helper functions: validateTheme
,
parseTheme
, and serializeTheme
. validateTheme
is a
type guard 🔗
that checks if a value is a valid theme, returning true
if the value is valid
or false
otherwise. parseTheme
reads the theme from the request Cookie
header, returning the theme or defaulting to "system"
. Finally,
serializeTheme
clears the cookie if the theme is "system"
or updates it with
the new value otherwise.
Reading the cookie object
With the cookie object created, the next step is to read the theme from the
cookie and apply it to the UI. Since we’re using Tailwind CSS and want users to
manually select a theme instead of relying on the operating system’s preference,
we need to add the selector
strategy to tailwind.config.ts
. Then, based on
the cookie’s theme value, we’ll add the dark
class to the <html>
element.
First, update tailwind.config.ts
to use the selector
strategy:
import type { Config } from "tailwindcss";
export default { darkMode: "selector", content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], theme: { extend: { fontFamily: { system: [ "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", "sans-serif", "Apple Color Emoji", "Twemoji Mozilla", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", ], }, }, }, plugins: [],} satisfies Config;
Next, add a loader function for the root route to read the theme from the cookie:
import type { LoaderFunctionArgs } from "@remix-run/node";import { Links, Meta, Outlet, Scripts, ScrollRestoration, json,} from "@remix-run/react";
import { parseTheme } from "~/lib/theme-cookie.server";
import "./tailwind.css";
export async function loader({ request }: LoaderFunctionArgs) { const theme = await parseTheme(request);
return json({ theme }, { headers: { Vary: "Cookie" } });}
// ...existing code here remains the same
// ...existing code here remains the same
The Vary: Cookie
header informs caching mechanisms that the response may vary
based on the Cookie
header in the request.
Next, we’ll style the UI using the theme value returned from the root route
loader. But before that, let’s create a ThemeScript.tsx
file in
app/components/
that contains a utility function to read the theme value. Run
the following command:
touch app/components/ThemeScript.tsx
Copy the following code into app/components/ThemeScript.tsx
:
import { useRouteLoaderData } from "@remix-run/react";import type { loader as rootLoader } from "~/root";import type { Theme } from "~/types";
export function useTheme(): Theme { const rootLoaderData = useRouteLoaderData<typeof rootLoader>("root"); const rootTheme = rootLoaderData?.theme ?? "system";
return rootTheme;}
The useRouteLoaderData
hook accesses data returned from a route’s loader
outside the route’s default component. The value passed to useRouteLoaderData
must be the path of the route file relative to the app folder, excluding the
extension. In this case, useTheme
retrieves the root loader data and returns
the theme.
Finally, update app/root.tsx
to use the theme value read from the cookie to
style the UI:
import { LoaderFunctionArgs } from "@remix-run/node";import { Links, Meta, Outlet, Scripts, ScrollRestoration, json,} from "@remix-run/react";
import { useTheme } from "~/components/ThemeScript";
import { parseTheme } from "~/lib/theme-cookie.server";
import "./tailwind.css";
// ...existing code here remains the same
export function Layout({ children }: { children: React.ReactNode }) { const theme = useTheme() === "dark" ? "dark" : "";
return ( <html lang="en" className={`font-system bg-white/90 antialiased dark:bg-gray-900 ${theme}`} > <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <Meta /> <Links /> </head> <body className="flex min-h-screen max-w-[100vw] flex-col overflow-x-hidden bg-gradient-to-r from-[#00fff0] to-[#0083fe] px-4 py-8 text-black dark:from-[#8E0E00] dark:to-[#1F1C18] dark:text-white"> {children} <ScrollRestoration /> <Scripts /> </body> </html> );}
// ...existing code here remains the same
Updating the cookie object
The final step is updating the cookie when the user selects a theme. We’ll need
a ThemeSwitcher
component that enables users to select a theme. Upon selecting
a theme, an action
will be called to update the cookie with the new value.
First, we need icons for the ThemeSwitcher
component. Run this command to
create files for each icon:
touch app/components/icons/{MonitorIcon.tsx,MoonIcon.tsx,SunIcon.tsx,UpDownIcon.tsx}
Next, add the respective code for each icon:
export default function MonitorIcon({ ...props }: React.ComponentProps<"svg">) { return ( <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props} > <rect width="20" height="14" x="2" y="3" rx="2" /> <line x1="8" x2="16" y1="21" y2="21" /> <line x1="12" x2="12" y1="17" y2="21" /> </svg> );}
export default function MoonIcon({ ...props }: React.ComponentProps<"svg">) { return ( <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props} > <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" /> </svg> );}
export default function SunIcon({ ...props }: React.ComponentProps<"svg">) { return ( <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props} > <circle cx="12" cy="12" r="4" /> <path d="M12 2v2" /> <path d="M12 20v2" /> <path d="m4.93 4.93 1.41 1.41" /> <path d="m17.66 17.66 1.41 1.41" /> <path d="M2 12h2" /> <path d="M20 12h2" /> <path d="m6.34 17.66-1.41 1.41" /> <path d="m19.07 4.93-1.41 1.41" /> </svg> );}
export default function UpDownIcon({ ...props }: React.ComponentProps<"svg">) { return ( <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props} > <path d="m7 15 5 5 5-5" /> <path d="m7 9 5-5 5 5" /> </svg> );}
Now, create the file for the ThemeSwitcher
component:
touch app/components/ThemeSwitcher.tsx
Copy the following code into app/components/ThemeSwitcher.tsx
:
import { Form, useLocation } from "@remix-run/react";import { useRef } from "react";
import { useTheme } from "~/components/ThemeScript";import MonitorIcon from "~/components/icons/MonitorIcon";import MoonIcon from "~/components/icons/MoonIcon";import SunIcon from "~/components/icons/SunIcon";import UpDownIcon from "~/components/icons/UpDownIcon";
export default function ThemeSwitcher() { const location = useLocation(); const theme = useTheme(); const detailsRef = useRef<HTMLDetailsElement>(null);
return ( <details ref={detailsRef} className="group relative cursor-pointer"> <summary role="button" aria-haspopup="listbox" aria-label="Select your theme preference" tabIndex={0} className="flex w-28 items-center justify-between rounded-3xl border border-gray-200 bg-gray-50 px-4 py-2 transition hover:border-gray-500 group-open:before:fixed group-open:before:inset-0 group-open:before:cursor-auto dark:border-gray-700 dark:bg-gray-900 [&::-webkit-details-marker]:hidden" > {theme.replace(/^./, (c) => c.toUpperCase())} <UpDownIcon className="ml-2 h-4 w-4" /> </summary>
<Form role="listbox" aria-roledescription="Theme switcher" preventScrollReset replace action="/_actions/theme" method="post" onSubmit={() => { detailsRef.current?.removeAttribute("open"); }} className="absolute right-0 top-full z-50 mt-2 w-36 overflow-hidden rounded-3xl border border-gray-200 bg-gray-50 py-1 text-sm font-semibold shadow-lg ring-1 ring-slate-900/10 dark:border-gray-700 dark:bg-gray-900 dark:ring-0" > <input type="hidden" name="returnTo" value={location.pathname + location.search + location.hash} /> {[ { name: "system", icon: MonitorIcon }, { name: "light", icon: SunIcon }, { name: "dark", icon: MoonIcon }, ].map((option) => ( <button key={option.name} role="option" aria-selected={option.name === theme} name="theme" value={option.name} className={`flex w-full items-center px-4 py-2 transition hover:bg-gray-200 dark:hover:bg-gray-700 ${option.name === theme ? "text-sky-500 dark:text-red-500" : ""}`} > <option.icon className="mr-2 h-5 w-5" />{" "} {option.name.replace(/^./, (c) => c.toUpperCase())} </button> ))} </Form> </details> );}
The ThemeSwitcher
displays the current theme and opens a menu with theme
options. Selecting an option sends a POST
request to _actions/theme
with the
selected theme and a redirect URL (which is the current page) as the request
data.
Next, create the _actions/theme
route to handle the POST
request. We’re not
doing this in the index route to keep the code and logic clean. Run the
following command to create the _actions/theme
route:
touch app/routes/\[\_\]actions.theme.tsx
Copy the following code into app/routes/[_]actions/theme.tsx
:
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { serializeTheme, validateTheme } from "~/lib/theme-cookie.server";
export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData();
const theme = formData.get("theme"); if (!validateTheme(theme)) { throw new Response("Invalid theme", { status: 400 }); }
let returnTo = formData.get("returnTo"); if ( !returnTo || typeof returnTo !== "string" || !returnTo.startsWith("/") || returnTo.startsWith("//") ) { returnTo = "/"; }
return redirect(returnTo, { headers: { "Set-Cookie": await serializeTheme(theme) }, });}
The action
throws a 400
response if no theme or an invalid one is provided.
It validates the redirect URL, defaulting to the index route if invalid.
Finally, it redirects with the theme serialized via serializeTheme
from
theme-cookie.server.tsx
.
With all that done, we can now test our implementation. Import the
ThemeSwitcher
component into app/routes/_index.tsx
:
import type { ActionFunctionArgs, MetaFunction } from "@remix-run/node";import { Await, Form, Link, defer, json, useFetcher, useLoaderData, useSearchParams,} from "@remix-run/react";import { Suspense, useEffect, useRef } from "react";import type { Item, View } from "~/types";
import ThemeSwitcher from "~/components/ThemeSwitcher";import TodoActions from "~/components/TodoActions";import TodoList from "~/components/TodoList";
import { todos } from "~/lib/db.server";
// ...existing code here remains the same
// ...existing code here remains the same
// ...existing code here remains the same
export default function Home() { const { tasks } = useLoaderData<typeof loader>(); const fetcher = useFetcher(); const [searchParams] = useSearchParams(); const view = searchParams.get("view") || "all"; const addFormRef = useRef<HTMLFormElement>(null); const addInputRef = useRef<HTMLInputElement>(null);
const isAdding = fetcher.state === "submitting" && fetcher.formData?.get("intent") === "create task";
useEffect(() => { if (!isAdding) { addFormRef.current?.reset(); addInputRef.current?.focus(); } }, [isAdding]);
return ( <div className="flex flex-1 flex-col md:mx-auto md:w-[720px]"> <header className="mb-12 flex items-center justify-between"> <h1 className="text-4xl font-extrabold tracking-tight lg:text-5xl"> TODO </h1> <select className="appearance-none rounded-3xl border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-900"> <option>System</option> <option>Light</option> <option>Dark</option> </select> <ThemeSwitcher /> </header>
{/* ...existing code here remains the same */}
{/* ...existing code here remains the same */} </div> );}
Once done, you should be able to select a theme and see the app’s color scheme change.
Enhancing the user experience
This implementation works with or without JavaScript, meaning the theme switcher is available to all users. How sweet 😊. Now, let’s enhance it for those with JavaScript enabled.
Did you notice when selecting a theme, the server action completes and Remix
revalidates before the theme updates? Also, selecting "system"
defaults to
light, even if your system prefers dark mode. Let’s fix that.
First, we’ll optimistically update the theme immediately after selection, then
revert to the cookie’s state once Remix revalidates. Update
app/components/ThemeScript.tsx
with the following changes:
import { useNavigation, useRouteLoaderData } from "@remix-run/react";import type { loader as rootLoader } from "~/root";import type { Theme } from "~/types";
export function useTheme(): Theme { const rootLoaderData = useRouteLoaderData<typeof rootLoader>("root"); const rootTheme = rootLoaderData?.theme ?? "system"; const navigation = useNavigation();
const theme = navigation.formData?.has("theme") ? (navigation.formData.get("theme") as Theme) : rootTheme;
return theme;}
Now, the app’s theme will instantly change when a theme is selected, with no delay.
To resolve the second issue, we’ll need a script and useEffect
. On page load,
the script will automatically apply the dark
class to the <html>
element if
the theme
is "system"
and the system prefers dark mode. The useEffect
will
run when theme
changes, ensuring the dark
class is applied based on the
system’s preference.
Update app/components/ThemeScript.tsx
with the following changes to create a
ThemeScript
component:
import { useNavigation, useRouteLoaderData } from "@remix-run/react";import { useEffect, useMemo } from "react";import type { loader as rootLoader } from "~/root";import type { Theme } from "~/types";
// ...existing code here remains the same
export function ThemeScript() { const theme = useTheme();
const script = useMemo( () => ` const theme = ${JSON.stringify(theme)}; const media = window.matchMedia("(prefers-color-scheme: dark)") if (theme === "system" && media.matches) { document.documentElement.classList.add("dark"); } `, [], // eslint-disable-line -- we don't want this script to ever change );
useEffect(() => { switch (theme) { case "system": { const syncTheme = (media: MediaQueryList | MediaQueryListEvent) => { document.documentElement.classList.toggle("dark", media.matches); }; const media = window.matchMedia("(prefers-color-scheme: dark)"); syncTheme(media); media.addEventListener("change", syncTheme); return () => media.removeEventListener("change", syncTheme); } case "light": { document.documentElement.classList.remove("dark"); break; } case "dark": { document.documentElement.classList.add("dark"); break; } default: { console.error("Invalid theme:", theme); } } }, [theme]);
return <script dangerouslySetInnerHTML={{ __html: script }} />;}
Then, include the ThemeScript
component in the <head>
of app/root.tsx
:
import { LoaderFunctionArgs } from "@remix-run/node";import { Links, Meta, Outlet, Scripts, ScrollRestoration, json,} from "@remix-run/react";
import { ThemeScript, useTheme } from "~/components/ThemeScript";
import { parseTheme } from "~/lib/theme-cookie.server";
import "./tailwind.css";
// ...existing code here remains the same
export function Layout({ children }: { children: React.ReactNode }) { const theme = useTheme() === "dark" ? "dark" : "";
return ( <html lang="en" className={`font-system bg-white/90 antialiased dark:bg-gray-900 ${theme}`} > <head> <ThemeScript /> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <Meta /> <Links /> </head> <body className="flex min-h-screen max-w-[100vw] flex-col overflow-x-hidden bg-gradient-to-r from-[#00fff0] to-[#0083fe] px-4 py-8 text-black dark:from-[#8E0E00] dark:to-[#1F1C18] dark:text-white"> {children} <ScrollRestoration /> <Scripts /> </body> </html> );}
// ...existing code here remains the same
Now, selecting "system"
correctly reflects your system’s preference, and the
script will run on page load or refresh to set the theme accordingly. Huzzah!
Conclusion
In this part of the series, you learned how to implement a theme switcher in Remix. You set up the switcher to support light and dark modes and sync with the system’s preferences.
In the next part, you’ll learn how to deploy your Remix app to production. Stay tuned!