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 dedicatedgetLexicalColors()function. - Email has its own isolated color system, intentionally disconnected from the app tokens.
Layer | Entry point | Carries |
|---|---|---|
Palette + tokens |
| Raw color values, semantic tokens for light and dark |
CSS generation |
|
|
Lexical colors |
| 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 definitionsEach 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
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — how the Lexical editor that consumes these theme colors is structured across web and mobile - Building a Monorepo with pnpm and TypeScript — workspace conventions and the cross-platform package rules that apply to
@storyie/theme
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.