Ross Moody

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

A short walkthrough of setting up CSS-in-JS theming functionality with Stitches and Remix leveraging SSR color-scheme preferences.

I have been working on a side project called 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.

1import { 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

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.

1import 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

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.

1import { 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

2. Tracking Color Scheme State

We want our theming functionality to have pretty simple logic:

  1. If the user has set a theme manually for the site (determined by a theme cookie we will create), we set the selected theme
  2. 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
  3. If neither of these is true, we set to our default theme: light
1import { 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

3. Setting the correct theme

1: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

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.

1import { 2 LiveReload, 3 LoaderFunction, 4 Outlet, 5 useLoaderData, 6 HeadersFunction, 7} from 'remix' 8import { getColorScheme } from './cookies' 9 10export const headers: HeadersFunction = () => ({ 11 'Accept-CH': 'Sec-CH-Prefers-Color-Scheme', 12}) 13 14export const loader: LoaderFunction = async ({ request }) => ({ 15 colorScheme: await getColorScheme(request), 16}) 17 18export default function App() { 19 const { colorScheme } = useLoaderData() 20 21 return ( 22 <html lang="en"> 23 <head> 24 <meta charSet="utf-8" /> 25 <meta name="viewport" content="width=device-width,initial-scale=1" /> 26 </head> 27 <body className={colorScheme}> 28 <Outlet /> 29 {process.env.NODE_ENV === 'development' && <LiveReload />} 30 </body> 31 </html> 32 ) 33} 34

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.

1import { 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

If you have any suggestions for ways to improve this documentation or the GitHub example repo, lemme have ‘em.