My plan to port Electron app CSS themes into React Native

My plan to port Electron app CSS themes into React Native

Hey, what's up? This is Takuya. I'd like to share my plan to make custom UI themes support across both my Electron and React Native apps.

Why React Native (A brief history)

I have a cross-platform app, Inkdrop, which runs on Electron for desktop platforms and React Native for mobile.

I started this project 8 years ago. So, my each decision was influenced by the tech trends of the time. I first developed the desktop version with Electron, which is a fork of Atom Editor. Then, I built the mobile version with React Native.

I intentionally avoided to choose web-based hybrid app frameworks like Cordova and Ionic (Capacitor) because I believe strongly that the UI design should be optimized for touch interfaces. React Native looked great because it lets you use native iOS & Android UI components via React, offering a native feel and accessibility. While adoping a hybrid app framework would help you reuse large amount of your codebase, I wanted to focus on the user experience with high quality UIs.

Of course, it has been involving a lot of challenges to overcome such as performance, as I've shared tips on my blog:

The next big challenge is theming. The desktop app comes with multiple themes, including community-made ones. So, you can enjoy customizing the app looking as you want. On the other hand, the mobile app supports only a few themes at the moment because custom themes are not available.
React Native apps can't directly use CSS to apply styles since they don't run in WebView. So, you would need to convert stylesheets into any compatible format like JSON somehow. How to achieve that? Well, first, I had to rethink how theming worked in the desktop app...

The new Solarized Dark UI theme

Migrating to the CSS-variable-based theming architecture

Inkdrop's UI components were originally based on Semantic UI, and Inkdrop has relied on its well-designed theming system based on LESS, an alternative stylesheet language with some extended syntaxes like variables.

The main issue was that Semantic UI themes include everything from resets and layouts to buttons, dropdowns, and menus. This is because Semantic UI's theming architecture wasn't designed to be dynamically loaded or switched. This architecture was too complicated to convert the stylesheets for the React Native app.

Modern CSS now supports a lot of features that LESS has provided, such as CSS variables and CSS nesting. Moreover, CSS supports cascade layers, which allow you to control the specificity and order of rule sets across stylesheets. These improvements in CSS have made it possible to significantly simplify theming.

In the new theming system, all you have to do is customize CSS variables. No pre-compilations needed. For example, here is a part of the new Solarized Dark UI theme:

:root {
  --primary-color: hsl(var(--hsl-blue-500) / 90%);
  --input-background: var(--color-bg);
  --page-background: var(--color-bg);
  --text-color: hsl(var(--hsl-fg));
  --link-hover-color: var(--color-blue-300);
  --sidebar-background: var(--color-bg);
  --note-list-bar-background: hsl(var(--hsl-bg-muted-highlight));
  --note-list-bar-border-right: 1px solid hsl(var(--hsl-base02));
}

Instead of using LESS variables to let you customize component styles, the app now refers to these CSS variables to apply customizations on top of the default styles with CSS cascading layers.

This way, all customizations derive from these CSS variables. By extracting them as JSON, it finally becomes possible to port themes to the React Native app.

Extracting computed CSS variables as JSON using Puppeteer

It would be possible to convert CSS to JSON by processing files with PostCSS. But it can't resolve CSS variables to their computed values.

But you can inspect the computed CSS variables via Developer Tools on the browser. And, Puppeteer makes possible to do that by controlling Chrome via JavaScript APIs.

So, I've created a CLI tool that loads the theme CSS files on the headless browser and extract computed styles with Puppeteer.

For example, you can load HTML data with custom CSS files like so:

import puppeteer from 'puppeteer';

const themeVariableNames: string[] = ['--css-variable-names', ...]
const styleSheets = ['path-to-css-file', ...]

const browser = await puppeteer.launch();
const page = await browser.newPage();

const themeCSSFiles = (styleSheets.map((filePath: string) => (`<link rel="stylesheet" href="styles/${filePath}" />`)) || []).join('\n')
const baseUrl = pathToFileURL(process.cwd()).toString() + '/';
const content = `<!DOCTYPE html>
<html>
  <head>
    <base href="${baseUrl}" />
    <link rel="stylesheet" href="node_modules/@inkdropapp/css/reset.css" />
    <link rel="stylesheet" href="node_modules/@inkdropapp/css/tokens.css" />
    <link rel="stylesheet" href="node_modules/@inkdropapp/css/tags.css" />
    <link rel="stylesheet" href="node_modules/@inkdropapp/base-ui-theme/styles/theme.css" />
    ${themeCSSFiles}
  </head>
  <body>
    <h1>Hello</h1>
  </body>
</html>
`;

await page.goto(baseUrl);
await page.setContent(content);

Each element has a useful method called computedStyleMap, which returns a comupted stylesheet key-value map.

You can use page.$eval to get an instance of the body element, and call body.computedStyleMap() to get computed style values, like so:

// Extract CSS variables
const computedCSSVariables = await page.$eval('body', (body) => {
  const computedStyles = body.computedStyleMap();
  const variables: Record<string, string> = {};
  for (const [prop, val] of computedStyles) {
    if (prop.startsWith('--')) {
      variables[prop] = val.toString();
    }
  }
  return variables;
});

It's super easy!!
Now, you can export these values into a JSON file and load them in React Native.

You can check out the full script in my repository here:

This will yield a JSON file like the following:

{
  "--page-background": "hsl(192deg 100% 5%)",
  "--page-overflow-x": "hidden",
  "--line-height": "1.4285em",
  "--text-color": "hsl(186deg 8% 55%)",
  "--paragraph-margin": "0em 0em 1em",

  ...,
  
  "--note-list-bar-background": "hsl(192deg 100% 8%)",
  "--note-list-bar-border-right": "1px solid hsl(192deg 81% 14%)",
  "--note-list-bar-pinned-section-header-background": "hsl(0deg 0% 90%)",
  "--note-list-bar-pinned-section-header-border-bottom": "none",
  "--note-list-bar-pinned-section-footer-border-bottom": "4px solid #ccc",
  "--note-list-view-item-header-color": "hsl(186deg 8% 55%)",
  "--note-list-view-item-color": "hsl(44deg 87% 94% / 40%)",
  "--note-list-view-item-separator-border": "1px solid hsl(192deg 81% 14%)",
  "--note-list-view-item-date-color": "hsl(237deg 43% 60%)",
  "--note-list-view-item-selected-background": "hsl(205deg 69% 49% / 20%)",
  "--note-list-view-item-active-background": "hsl(205deg 69% 49% / 40%)",

  ...
}

Looks great!

I can't wait to support custom themes on the mobile version.
I hope it's helpful for building cross-platform apps with theming support 💪