Setting up Dark Theme with Remix and Stitches
A short walkthrough of setting up CSS-in-JS theming functionality with Stitches and Remix leveraging SSR color-scheme preferences.
Last updated: Mon Jan 03 2022
I have been working on a side project called Cryptocons.io for the past few months. It’s not even close to presentable, but I typically use side projects to try new frameworks and tools. This time, I wanted to try a combination of 3 tools I have been gushing over lately: Remix, Stitches, and Radix.
Setting up a Remix project was really easy. I remarked about this on Twitter and how excited I am for how Remix prompts a different component/project structure approach.
Initial Remix Reaction
So far it feels like Remix blurs the line between pages, components, and state in how the structure of the project’s folder architecture dictates the actual UI. Typically I set up a project by creating a page layout and passing components in willy-nilly. I then handle each component child’s data and state via some form of Context Provider.
Remix feels conceptually similar but where files live and how they are named seem to play a much more significant role. I like this about it so far (time will tell long-term). It inherently encourages me to be consistent and intentional about my project's structure. Not solving every problem in the context of a React hook has already caused me to learn quite a lot about web development and how web pages are fundamentally created.
Setting Up Stitches and Remix
I ran into some snags for setting up Stitches and Remix at first. The setup was a breeze: install both packages, run the dev server, and import a Stitches styled component into a page route. However, the nuance of SSR, having the site load without an initial flash of unstyled content (also known as FART), and tracking a user’s theme state were a little trickier. This is especially true if I wanted to detect prefers-color-scheme dynamically.
Failed Attempt #1
At first, I set up the site using Stitches SSR documentation. My styles were rendered correctly, but this approach caused some odd hydration errors in the console and didn’t fix my FART. Interestingly, my styles rendered fine without doing this part at all.
Meh Attempt #2
I was about to start following the CSS-in-JS instructions on Remix’s site, and I imagine it would have worked just fine, but I wanted to eliminate the need for a “throwaway” render.
Anthony on Twitter suggested loading the CSS via a loader function in the root.tsx
server file. This eliminated the hydration warning, but I got a FART on my screen during refreshes.
TSX1import { 2 Links, 3 LiveReload, 4 Meta, 5 Outlet, 6 Scripts, 7 ScrollRestoration, 8 LoaderFunction, 9 useLoaderData, 10} from 'remix' 11 12import { getCssText } from 'path/to/stitches.config' 13 14export const loader: LoaderFunction = async () => { 15 return new Response(getCssText(), { 16 headers: { 'Content-Type': 'text/css; charset=UTF-8' }, 17 }) 18} 19 20export default function App() { 21 const styles = useLoaderData() 22 23 return ( 24 <html lang="en"> 25 <head> 26 <meta charSet="utf-8" /> 27 <Meta /> 28 <Links /> 29 <style id="stitches">{styles}</style> 30 </head> 31 <body> 32 <Outlet /> 33 <ScrollRestoration /> 34 <Scripts /> 35 {process.env.NODE_ENV === 'development' && <LiveReload />} 36 </body> 37 </html> 38 ) 39} 40
root.tsx
Winning Attempt #3
Jenna Smith from Modulz hopped in with a simple gist that worked like a charm. I have to imagine altering the head tag via regex isn’t the ideal solution, but it works. Speculating from the tweet, Stitches may be hot on the trail of a static CSS mechanism, which will make all this a whole lot easier.
TSX1import type { EntryContext } from 'remix' 2import ReactDOMServer from 'react-dom/server' 3import { RemixServer } from 'remix' 4import { getCssText } from '../stitches.config' 5 6export default function handleRequest( 7 request: Request, 8 responseStatusCode: number, 9 responseHeaders: Headers, 10 remixContext: EntryContext 11) { 12 const markup = ReactDOMServer.renderToString( 13 <RemixServer context={remixContext} url={request.url} /> 14 ).replace(/<\/head>/, `<style id="stitches">${getCssText()}</style></head>`) 15 16 return new Response('<!DOCTYPE html>' + markup, { 17 status: responseStatusCode, 18 headers: { 19 ...Object.fromEntries(responseHeaders), 20 'Content-Type': 'text/html', 21 }, 22 }) 23} 24
entry.server.tsx
Stitches and Remix Color Scheme Theme Setup
Most of my experience with web development so far is front-of-the-frontend and has sidestepped server considerations. I typically use Gatsby or Next with CSS-in-JS. Theme considerations and state in those cases are taken care of via Javascript on the client-side by a supplied ThemeProvider or custom ThemeContext that addresses the FART.
This was a unique opportunity to learn how to lean on the browser’s Sec-CH-Prefers-Color-Scheme client headers. Remix embraces the server, time to get on board.
1. Create the theming color aliases
For our example, let’s set up the minimal theme functionality for a demo. This means setting color aliases for text color, anchor color, and our body’s background color in both light and dark themes.
TSX1import { createStitches } from '@stitches/react' 2 3export const { styled, createTheme, globalCss, getCssText, theme } = 4 createStitches({ 5 theme: { 6 colors: { 7 text: '#191919', 8 bgBody: '#f8f9fa', 9 anchor: 'DarkGoldenRod', 10 }, 11 }, 12 }) 13 14export const darkTheme = createTheme('dark', { 15 colors: { 16 text: '#f8f9fa', 17 bgBody: '#191919', 18 anchor: 'BlanchedAlmond', 19 }, 20}) 21 22export const globalStyles = globalCss({ 23 body: { 24 color: '$text', 25 backgroundColor: '$bgBody', 26 }, 27 28 a: { 29 color: '$anchor', 30 }, 31}) 32
stitches.config.ts
2. Tracking Color Scheme State
We want our theming functionality to have pretty simple logic:
- If the user has set a theme manually for the site (determined by a theme cookie we will create), we set the selected theme
- If the user hasn’t set a theme manually and has a user
prefers-color-mode
preference, we grab it from the request header (so we can process it server-side) and set the theme accordingly - If neither of these is true, we set to our default theme:
light
TS1import { createCookie } from 'remix' 2 3// Create a cookie to track color scheme state 4export let colorSchemeCookie = createCookie('color-scheme') 5 6// Helper function to get the value of the color scheme cookie 7export const getColorSchemeToken = async (request: Request) => 8 await colorSchemeCookie.parse(request.headers.get('Cookie')) 9 10export const getColorScheme = async (request: Request) => { 11 // Manually selected theme 12 const userSelectedColorScheme = await getColorSchemeToken(request) 13 // System preferred color scheme header 14 const systemPreferredColorScheme = request.headers.get( 15 'Sec-CH-Prefers-Color-Scheme' 16 ) 17 18 // Return the manually selected theme 19 // or system preferred theme or default theme 20 return userSelectedColorScheme ?? systemPreferredColorScheme ?? 'light' 21} 22
cookies.ts
3. Setting the correct theme
CSS1:root, 2.t-cIBkYY { 3 --colors-text: #191919; 4 --colors-bgBody: #f8f9fa; 5 --colors-anchor: DarkGoldenRod; 6} 7 8.dark { 9 --colors-text: #f8f9fa; 10 --colors-bgBody: #191919; 11 --colors-anchor: BlanchedAlmond; 12} 13
styles.css
I borrowed an approach I picked up from Radix Design System GitHub. They have a DarkMode button component that essentially adds or removes the darkTheme
class name to the body element.
I love how simple this is. The default theme’s :root
css color alias variables are overridden via specificity by using the className of the dark theme. In our case, we are aligning the names of the themes to the browser’s color-scheme
strings but really the class="light"
has no effect.
Stitches generates a unique class hash for the default theme (which is light in our case) and we specify class="dark"
for our darkTheme
class to override it.
TSX1import { 2 LiveReload, 3 LoaderFunction, 4 Outlet, 5 useLoaderData, 6 HeadersFunction, 7} from 'remix' 8import { getColorScheme } from './cookies' 9import { globalStyles } from '../stitches.config' 10 11export const headers: HeadersFunction = () => ({ 12 'Accept-CH': 'Sec-CH-Prefers-Color-Scheme', 13}) 14 15export const loader: LoaderFunction = async ({ request }) => ({ 16 colorScheme: await getColorScheme(request), 17}) 18 19export default function App() { 20 const { colorScheme } = useLoaderData() 21 globalStyles() 22 23 return ( 24 <html lang="en"> 25 <head> 26 <meta charSet="utf-8" /> 27 <meta name="viewport" content="width=device-width,initial-scale=1" /> 28 </head> 29 <body className={colorScheme}> 30 <Outlet /> 31 {process.env.NODE_ENV === 'development' && <LiveReload />} 32 </body> 33 </html> 34 ) 35} 36
root.tsx
Set up the theme switcher
The last piece of the puzzle is to put a Button on a route that changes the cookie’s theme-token
value. This is the code snippet I used in my index.tsx
file but I imagine there are lots of ways you could do this.
TSX1import { Button } from '~/components/Button' 2import { ActionFunction, Form, redirect, Link } from 'remix' 3import { colorSchemeCookie, getColorScheme } from '../cookies' 4 5export const action: ActionFunction = async ({ request }) => { 6 const currentColorScheme = await getColorScheme(request) 7 const newColorScheme = currentColorScheme === 'light' ? 'dark' : 'light' 8 9 return redirect(request.url, { 10 headers: { 11 'Set-Cookie': await colorSchemeCookie.serialize(newColorScheme), 12 }, 13 }) 14} 15 16export default function Index() { 17 return ( 18 <div> 19 <Form method="post"> 20 <Button type="submit">Change Theme</Button> 21 </Form> 22 </div> 23 ) 24} 25
index.tsx
If you have any suggestions for ways to improve this documentation or the GitHub example repo, lemme have ‘em.
Resources
- Hello darkness, my old friend. Amazing read on
prefers-color-scheme
functionality and the history of dark-mode. - Improved dark mode default styling: The color-scheme CSS property and the corresponding meta tag