One color system for web, mobile, and email: how we built @storyie/theme

Storyie Engineering Team
6 min read

How we consolidated scattered color definitions across Tailwind CSS, React Native StyleSheets, Lexical editor highlights, and email templates into a single internal package — and why splitting palette from semantic tokens was the most useful decision we made.

Storyie runs on Next.js for web and Expo for mobile, sharing a single Supabase database. Both apps need to look consistent — same brand colors, same dark mode behavior, same editor highlights. For a while they didn't. Tailwind CSS, React Native StyleSheets, Lexical's code highlighting, and React Email templates each had their own color definitions, and each used a slightly different shade of ochre. The question "which file has the real value for the primary button color?" didn't have a clean answer.

We fixed this by creating @storyie/theme, an internal monorepo package that owns every color decision. This post explains how it's structured and how web, mobile, and email each consume it.

TL;DR

  • Colors are organized in two layers: a palette of raw values and a token map of semantic roles.
  • Web reads tokens through generated CSS custom properties, which Tailwind references at build time.
  • React Native imports the token objects directly — no CSS involved.
  • Lexical editor colors (used by the "use dom" WebView on mobile and the DOM editor on web) come from a dedicated getLexicalColors() function.
  • Email has its own isolated color system, intentionally disconnected from the app tokens.

Layer

Entry point

Carries

Palette + tokens

@storyie/theme

Raw color values, semantic tokens for light and dark

CSS generation

@storyie/theme/css

generateFullCss() for Tailwind / Next.js build

Lexical colors

@storyie/theme/lexical

Code highlights, blockquote, heading, hashtag, link colors

Package layout

packages/theme/src/
├── index.ts        # re-exports palette, tokens, types
├── palette.ts      # primitive color values
├── tokens.ts       # semantic tokens (light / dark)
├── css.ts          # CSS custom property generation
├── lexical.ts      # Lexical editor colors
├── email.ts        # email-only color system
└── types.ts        # shared type definitions

Each file has a clear scope. palette.ts is the only place that knows what ochre[600] looks like as a hex string. Nothing else in the codebase holds that value.

The two-layer design: palette → tokens

The most useful structural decision we made was keeping two distinct layers.

The palette holds primitive values — ochre[50] through ochre[950], a gray scale, status colors. It carries no semantic meaning. ochre[600] does not know what it will be used for.

The token layer maps palette values to roles. It is where color gets meaning.

// tokens.ts (excerpt)
export const tokens = {
  light: {
    background: palette.ochre[50],
    primaryButton: palette.ochre[600],
    border: palette.ochre[200],
    // ...
  },
  dark: {
    background: palette.ochre[950],
    primaryButton: palette.ochre[300],
    border: palette.ochre[800],
    // ...
  },
} as const;

When we want to adjust the brand color, we change values in palette.ts. The token structure stays untouched, and the change propagates everywhere automatically. This has already saved us from a tedious find-and-replace during an early brand refresh.

Web: generated CSS custom properties

Web uses Tailwind CSS v4. The connection between @storyie/theme and Tailwind goes through CSS custom properties rather than a direct JavaScript import at runtime.

The @storyie/theme/css entry point exports a generateFullCss() function that reads the token map and writes :root and .dark selectors with custom properties. A build script runs this before the Next.js build and writes the output to app/theme-vars.css:

pnpm generate:theme
  → packages/theme tokens → CSS custom properties → app/theme-vars.css
  → Tailwind references var(--primary-button), var(--background), etc.

The advantage of going through CSS variables rather than importing tokens directly is that the same variables work in server components, client components, and any CSS-in-JS that might show up later. There is no distinction between rendering environments — the variables are just in the stylesheet.

Mobile: direct TypeScript import

React Native has no CSS runtime, so the approach is simpler. We import the token objects directly and use them as plain JavaScript values in StyleSheet calls.

// apps/expo/constants/Colors.ts
import { getLexicalColors, tokens } from "@storyie/theme";

const Colors = {
  light: {
    ...tokens.light,
    lexical: getLexicalColors("light"),
  },
  dark: {
    ...tokens.dark,
    lexical: getLexicalColors("dark"),
  },
};

Every component in the Expo app references Colors — never the palette directly. The rule is simple: if you need a color, look it up in Colors. The Lexical editor colors are also merged in here, so the "use dom" WebView that runs the editor shares its color vocabulary with the surrounding native UI. There is no visual seam between the native shell and the editor.

Lexical editor colors

Lexical's code highlighting requires more granularity than semantic tokens provide. A single "code color" token isn't enough when you need different values for keywords, strings, functions, comments, and operators — each with separate light and dark variants.

getLexicalColors() returns a scoped object for a given color scheme:

// caller
const colors = getLexicalColors("dark");
// colors.codeHighlight.function → "#ff9999"
// colors.link → palette.blue[400]

One detail worth noting: we don't simply drop the lightness of light-mode highlight colors when switching to dark. Reading code on a dark background requires different contrast relationships than reading it on a light background, so we adjusted the hue and saturation of several token values independently for each scheme.

Email: a separate system

Email templates in Storyie use React Email and render through Resend. They have their own color definitions, intentionally disconnected from the app token system.

// email.ts (excerpt)
export const emailTokens = {
  text: {
    heading: emailPalette.gray[800],
    body: emailPalette.gray[700],
  },
  button: {
    primary: {
      background: emailPalette.primary[600], // indigo, not ochre
      text: emailPalette.neutral.white,
    },
  },
};

Two reasons for the separation. First, email client dark mode behavior is unreliable — many clients invert colors aggressively or ignore dark-mode media queries. A fixed light theme is much safer to maintain than a responsive one that breaks in Outlook. Second, the app uses warm ochre tones that read well in an editorial UI, but email CTAs need to stand out immediately at a glance. Indigo works better there. Sharing the app token system would mean every brand color adjustment carries implicit risk of changing email appearance.

Package exports

The package.json exports field separates the three entry points:

{
  "exports": {
    ".": { "default": "./dist/index.js" },
    "./css": { "default": "./dist/css.js" },
    "./lexical": { "default": "./dist/lexical.js" }
  }
}

This prevents the Expo app from accidentally bundling the CSS generation code (which is only meaningful in a Node build script context), and keeps the Lexical-specific colors available for import without pulling in the full package in contexts where they aren't needed.

What we actually learned from this

A few things surprised us after shipping this structure.

The palette → token two-layer split felt like over-engineering when we first sketched it. It paid off the first time we made a brand color adjustment: we changed three values in palette.ts, ran pnpm generate:theme, and the entire product updated. The tokens layer was untouched.

Centralizing in an internal package also makes the exhaustive color list discoverable. Previously, finding all uses of a specific shade required grepping across three packages with slightly different variable names. Now palette.ts is the single source of truth.

Separating email colors was the right call even though it means two systems to maintain. Email is a different medium with different rendering constraints. Treating it as an extension of the app theme caused too much cognitive overhead — every token change required auditing email impact. The explicit separation removes that concern entirely.

And yes, this level of structure makes sense even for a small team. "Where is this color defined?" is a question that shows up constantly regardless of team size. Having a typed package means the editor answers it for you.

Related Posts

Try Storyie

The color system described here shows up on every screen of storyie.com and the iOS app. Write a diary entry on the web and open it on mobile — the editor colors, dark mode behavior, and overall palette are all coming from the same source.