Skip to content

From 'dark' to 'light' mode

Allowing users to switch between a ‘light’ and ‘dark’ mode on your website is something most people expect from a modern web application. It also helps improve accessibility for the site, allowing users to settle on a theme that suits them best.

The only problem is that implementing a light and dark mode is surprisingly difficult. Luckily, the Epic Stack has most of the code for this already included.

All we need to do is implement it 🚀.

Trigger ‘light’ and ‘dark’ mode with code

Section titled “Trigger ‘light’ and ‘dark’ mode with code”

Open app/root.tsx and look carefully at the App function being exported:

app/root.tsx
7 collapsed lines
import { useLoaderData } from 'react-router'
import { type Route } from './+types/root.ts'
import { type loader } from './__root.server.tsx'
import { GeneralErrorBoundary } from './components/error-boundary.tsx'
import Document from './components/shared-layout/Document.tsx'
import { useNonce } from './utils/nonce-provider.ts'
import rootLinkElements from './utils/providers/rootLinkElements.ts'
export const links: Route.LinksFunction = () => {
return rootLinkElements
}
export { meta } from './__root.client.tsx'
export { headers, loader } from './__root.server.tsx'
export default function App() {
const data = useLoaderData<typeof loader | null>()
const nonce = useNonce()
return (
<Document nonce={nonce} honeyProps={data?.honeyProps}>
<div className="flex h-screen flex-col justify-between">
<div className="flex-1">
<main className="grid h-full place-items-center">
<h1 className="text-mega">Welcome to Epic News!</h1>
</main>
</div>
</div>
</Document>
)
}
export const ErrorBoundary = GeneralErrorBoundary

Notice the Document component that wraps everything being returned from the App function?

app/root.tsx
import { useLoaderData } from 'react-router'
import { type Route } from './+types/root.ts'
import { type loader } from './__root.server.tsx'
import { GeneralErrorBoundary } from './components/error-boundary.tsx'
import Document from './components/shared-layout/Document.tsx'
import { useNonce } from './utils/nonce-provider.ts'
import rootLinkElements from './utils/providers/rootLinkElements.ts'
5 collapsed lines
export const links: Route.LinksFunction = () => {
return rootLinkElements
}
export { meta } from './__root.client.tsx'
export { headers, loader } from './__root.server.tsx'
export default function App() {
const data = useLoaderData<typeof loader | null>()
const nonce = useNonce()
return (
<Document nonce={nonce} honeyProps={data?.honeyProps}>
<div className="flex h-screen flex-col justify-between">
<div className="flex-1">
<main className="grid h-full place-items-center">
<h1 className="text-mega">Welcome to Epic News!</h1>
</main>
</div>
</div>
</Document>
)
}
export const ErrorBoundary = GeneralErrorBoundary

Right-click the opening Document tag, and select ‘Go to Source Definition’ from the option menu:

Selecting Go to source definition

This will open the file where the Document component code is held:

app/components/shared-layout/Document.tsx
import { OpenImgContextProvider } from 'openimg/react'
import { Meta, Links, ScrollRestoration, Scripts } from 'react-router'
import { HoneypotProvider } from 'remix-utils/honeypot/react'
import { ClientHintCheck } from '#app/utils/client-hints'
import { getImgSrc } from '#app/utils/misc.tsx'
import { type Theme } from '#app/utils/theme.server'
interface DocumentProps {
children: React.ReactNode
nonce: string
honeyProps:
| {
nameFieldName: string
validFromFieldName: string | null
encryptedValidFrom: string
}
| undefined
theme?: Theme
env?: Record<string, string | undefined>
}
export default function Document({
children,
honeyProps,
nonce,
theme = 'dark',
env = {},
}: DocumentProps) {
const allowIndexing = ENV.ALLOW_INDEXING !== 'false'
return (
<HoneypotProvider {...honeyProps}>
<OpenImgContextProvider
optimizerEndpoint="/resources/images"
getSrc={getImgSrc}
>
<html lang="en" className={`${theme} h-full overflow-x-hidden`}>
<head>
<ClientHintCheck nonce={nonce} />
<Meta />
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1"
/>
{allowIndexing ? null : (
<meta name="robots" content="noindex, nofollow" />
)}
<Links />
</head>
<body className="bg-background text-foreground">
{children}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(env)}`,
}}
/>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
</OpenImgContextProvider>
</HoneypotProvider>
)
}

Manually updating from ‘light’ to ‘dark’ themes

Section titled “Manually updating from ‘light’ to ‘dark’ themes”

Head back over to app/components/shared-layout/Document.tsx if you are not in it already.

Find the line that reads theme = 'dark', around line 16.

Change this to theme = 'light' and save the file:

app/components/shared-layout/Document.tsx
export default function Document({
children,
honeyProps,
nonce,
theme = 'dark',
theme = 'light',
env = {},
}: DocumentProps) {

You should see the website in the browser looks completely different:

Project in Light Mode

Why? Tailwind classes and string interpolation

Section titled “Why? Tailwind classes and string interpolation”

Take a closer look at the opening html component being returned from the Document function.

Can you see how the value of the theme variable has been slotted into the className property (or ‘prop’)?

export default function Document({
children,
honeyProps,
nonce,
theme = 'light',
env = {},
}: DocumentProps) {
return (
<html lang="en" className={`${theme} h-full overflow-x-hidden`}>
21 collapsed lines
<head>
<ClientHintCheck nonce={nonce} />
<Meta />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
{allowIndexing ? null : (
<meta name="robots" content="noindex, nofollow" />
)}
<Links />
</head>
<body className="bg-background text-foreground">
{children}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(env)}`,
}}
/>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
)
}

This is called interpolation, and is a way of inserting the value of a variable into a string in JavaScript.

In this tutorial, we’ve learned:

  • How to switch between ‘light’ and ‘dark’ themes in the Epic Stack.
  • How to manually update the theme from ‘dark’ to ‘light’ in the Document component.
  • How to use string interpolation to insert the value of a variable into a string.
  • How to use TypeScript interfaces to define the shape of an object.
  • How to use React props to pass data into a component.

What we need now is a way for users to control the value of theme dynamically from the front-end, so they can switch easily between both.

In our next tutorial, we add a button that users can click to toggle between the two states.