This guide provides instructions for migrating from styled-components to plain CSS in the REX codebase using a hybrid approach.
- Overview
- Why Plain CSS?
- Hybrid Approach: Theme in JavaScript
- Component Migration Patterns
- Testing
- Common Pitfalls
We are migrating from styled-components to plain CSS to:
- Remove runtime CSS-in-JS overhead
- Improve build performance
- Simplify code review (CSS is more familiar than template literals)
- Reduce bundle size
- Use standard, familiar CSS without additional abstractions
Important: We use a hybrid approach where the theme remains in JavaScript (theme.ts), and components bind CSS custom properties (CSS variables) when they need theme values. This gives us the best of both worlds:
- JavaScript theme access for dynamic behavior (e.g., book color themes, dynamic computations)
- CSS performance benefits for static styles
- Type safety and centralized theme management
Plain CSS provides:
- No build config changes: Works out of the box with React Scripts
- Better performance: Static CSS with no runtime overhead
- Familiar syntax: Standard CSS that every developer knows
- Simple mental model: Direct CSS files imported for side effects
- Easy debugging: Standard browser DevTools work perfectly
The REX codebase has several critical use cases that require JavaScript theme access:
-
Book Banner Colors - Books have dynamic
themeproperties from CMS (blue, deep-green, gray, etc.) that require dynamic property access:theme.color.primary[bookTheme].base -
Highlight Colors - Generated by iterating over arrays and include runtime computations using the Color library
-
Programmatic Theme Access - React hooks like
useMatchMobileQuery()need to read breakpoint values forwindow.matchMedia() -
Type Safety - TypeScript ensures correct theme property access
Instead of converting theme to CSS variables globally, we keep theme in JavaScript and bind CSS variables on individual components that need styling:
theme.ts (stays in JavaScript):
export default {
color: {
primary: {
blue: { base: '#002468', foreground: '#fff' },
orange: { base: '#d4450c', foreground: '#fff' },
// ... other colors
},
},
zIndex: {
topbar: 30,
overlay: 40,
// ... other z-indices
},
};Component.tsx (binds CSS variables from theme):
import React from 'react';
import theme from '../theme';
import './Component.css';
export function Component({ bookTheme }) {
// Get theme values dynamically based on props
const colors = theme.color.primary[bookTheme];
return (
<div
className="component"
style={{
'--component-bg': colors.base,
'--component-fg': colors.foreground,
} as React.CSSProperties}
>
Content
</div>
);
}Component.css (uses bound CSS variables):
.component {
background: var(--component-bg);
color: var(--component-fg);
padding: 2rem;
}Use CSS variables (hybrid pattern):
- When you need styles that could be in CSS
- When theme values are determined at runtime (e.g., book colors, user preferences)
- For component-specific theming
Use theme directly in JS:
- When you need dynamic property access:
theme.color.primary[dynamicKey] - When you need to compute or transform theme values
- When passing theme values to libraries or utilities
- For media queries with React hooks (
useMatchMobileQuery())
For components with static styles that don't need theme values:
Before (styled-components):
import styled from 'styled-components/macro';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
padding: 2rem;
gap: 1rem;
`;
export function Component() {
return <Wrapper>Content</Wrapper>;
}After (Plain CSS):
// Component.tsx
import './Component.css';
export function Component() {
return <div className="component-wrapper">Content</div>;
}/* Component.css */
.component-wrapper {
display: flex;
flex-direction: column;
padding: 2rem;
gap: 1rem;
}For components that use theme but don't need dynamic property access:
Before (styled-components):
import styled from 'styled-components/macro';
import theme from '../theme';
const Button = styled.button`
background-color: ${theme.color.primary.orange.base};
color: ${theme.color.primary.orange.foreground};
padding: 1rem 2rem;
`;After (Plain CSS with CSS variables):
// Button.tsx
import theme from '../theme';
import './Button.css';
export function Button({ children, ...props }) {
return (
<button
className="button"
style={{
'--button-bg': theme.color.primary.orange.base,
'--button-fg': theme.color.primary.orange.foreground,
} as React.CSSProperties}
{...props}
>
{children}
</button>
);
}/* Button.css */
.button {
background-color: var(--button-bg);
color: var(--button-fg);
padding: 1rem 2rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.button:hover {
opacity: 0.9;
}For components that need runtime theme lookups (this is the key pattern!):
Before (styled-components):
import styled from 'styled-components/macro';
import theme from '../theme';
interface BannerProps {
colorSchema: 'blue' | 'orange' | 'green';
}
const Banner = styled.div<BannerProps>`
background: ${(props) => theme.color.primary[props.colorSchema].base};
color: ${(props) => theme.color.primary[props.colorSchema].foreground};
padding: 2rem;
`;
export function BookBanner({ bookTheme }) {
return <Banner colorSchema={bookTheme}>Book Banner</Banner>;
}After (Plain CSS with dynamic CSS variables):
// BookBanner.tsx
import theme from '../theme';
import './BookBanner.css';
interface BookBannerProps {
bookTheme: 'blue' | 'orange' | 'green';
}
export function BookBanner({ bookTheme }: BookBannerProps) {
// Dynamically look up theme colors based on bookTheme prop
const colors = theme.color.primary[bookTheme];
return (
<div
className="book-banner"
style={{
'--banner-bg': colors.base,
'--banner-fg': colors.foreground,
} as React.CSSProperties}
>
Book Banner
</div>
);
}/* BookBanner.css */
.book-banner {
background: var(--banner-bg);
color: var(--banner-fg);
padding: 2rem;
}Key Point: The CSS is static, but the CSS variable values are computed at runtime in JavaScript based on props!
For components with conditional styling:
Before (styled-components):
import styled, { css } from 'styled-components/macro';
interface ButtonProps {
isActive: boolean;
}
const Button = styled.button<ButtonProps>`
background: #eee;
color: #333;
${(props) => props.isActive && css`
background: #007bff;
color: white;
`}
`;After (Plain CSS with classNames):
// Button.tsx
import classNames from 'classnames';
import './Button.css';
interface ButtonProps {
isActive: boolean;
}
export function Button({ isActive, children }: ButtonProps) {
return (
<button className={classNames('button', { 'button--active': isActive })}>
{children}
</button>
);
}/* Button.css */
.button {
background: #eee;
color: #333;
}
.button--active {
background: #007bff;
color: white;
}Before (styled-components):
import styled, { css } from 'styled-components/macro';
import theme from '../theme';
const Container = styled.div`
padding: 3.2rem;
${theme.breakpoints.mobile(css`
padding: 1.6rem;
`)}
`;After (Plain CSS):
// Container.tsx
import './Container.css';
export function Container({ children }) {
return <div className="container">{children}</div>;
}/* Container.css */
.container {
padding: 3.2rem;
}
/* max-width: 75em (1200px) */
@media screen and (max-width: 75em) {
.container {
padding: 1.6rem;
}
}Note: For programmatic media query access (e.g., React hooks), continue using theme in JavaScript:
import { useMatchMedia } from '../hooks';
import theme from '../theme';
// This still needs theme.breakpoints.mobileQuery
const isMobile = useMatchMedia(theme.breakpoints.mobileQuery);When you need to compute styles based on theme values:
Before (styled-components):
import styled from 'styled-components/macro';
import Color from 'color';
import theme from '../theme';
const Highlight = styled.span`
background-color: ${theme.color.highlight.passive};
color: ${Color(theme.color.highlight.focused).isDark() ? theme.color.text.white : theme.color.text.black};
`;After (Compute in JS, bind as CSS variables):
// Highlight.tsx
import Color from 'color';
import theme from '../theme';
import './Highlight.css';
export function Highlight({ color, children }) {
const highlightColor = theme.highlights[color];
const textColor = Color(highlightColor.focused).isDark()
? theme.color.text.white
: theme.color.text.black;
return (
<span
className="highlight"
style={{
'--highlight-bg': highlightColor.passive,
'--highlight-fg': textColor,
} as React.CSSProperties}
>
{children}
</span>
);
}/* Highlight.css */
.highlight {
background-color: var(--highlight-bg);
color: var(--highlight-fg);
}Plain CSS imports need to be mocked for Jest tests. This is already configured in the project:
package.json:
{
"jest": {
"moduleNameMapper": {
"\\.css$": "<rootDir>/__mocks__/styleMock.js"
}
}
}mocks/styleMock.js:
module.exports = {};Tests should focus on functionality, not CSS:
import { render, screen } from '@testing-library/react';
import { Button } from './Button';
test('renders button with correct class', () => {
render(<Button isActive>Click me</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('button', 'button--active');
});❌ Wrong:
:root {
--color-orange: #d4450c; /* Duplicates theme.ts */
}✅ Right:
// Bind from theme.ts at component level
style={{ '--button-bg': theme.color.primary.orange.base }}❌ Wrong:
className={isActive ? 'button button--active' : 'button'}✅ Right:
import classNames from 'classnames';
className={classNames('button', { 'button--active': isActive })}When migrating, make sure to preserve all existing props:
❌ Wrong:
export function Button({ children }) {
return <button className="button">{children}</button>;
}✅ Right:
export function Button({ children, className, style, ...props }) {
return (
<button
{...props}
className={classNames('button', className)}
style={{ ...style, /* CSS variables */ }}
>
{children}
</button>
);
}TypeScript doesn't know about CSS variables in the style prop:
❌ Wrong:
style={{ '--my-var': '#fff' }} // TypeScript error✅ Right:
style={{ '--my-var': '#fff' } as React.CSSProperties}❌ Wrong (impossible in CSS):
.banner {
/* Can't do dynamic property access in CSS! */
background: var(--color-primary-[bookTheme]-base);
}✅ Right (compute in JS):
const colors = theme.color.primary[bookTheme];
style={{ '--banner-bg': colors.base }}- Don't hardcode theme values in CSS
- Always reference theme.ts when you need theme values
- The theme object is unchanged and fully accessible in JavaScript
When migrating a component:
- Create a
.cssfile next to the component - Import the CSS file in the component
- Determine if you need dynamic theme access
- If yes: bind CSS variables from theme values
- If no: use static CSS (but consider if hardcoding is appropriate)
- Replace styled-components with plain elements + className
- Use
classNameslibrary for conditional classes - Preserve all existing props (className, style, ...props)
- Add type casting for CSS variables:
as React.CSSProperties - Update tests if needed
- Remove styled-components imports
- Verify the component renders correctly
- Check that theme access works for dynamic values
The hybrid approach gives us:
- ✅ Theme in JavaScript: Type-safe, centralized, dynamically accessible
- ✅ Styles in CSS: Better performance, familiar syntax, static optimization
- ✅ CSS Variables Bridge: Connect JavaScript theme to CSS when needed
- ✅ Flexibility: Use the right tool for each situation
This approach is particularly important for REX because of:
- Book theme colors that vary by book metadata
- Highlight colors with dynamic computations
- Media query hooks that need programmatic access
- Existing patterns that rely on theme object access