Skip to content

Latest commit

 

History

History
593 lines (474 loc) · 13.9 KB

File metadata and controls

593 lines (474 loc) · 13.9 KB

Plain CSS Migration Guide

This guide provides instructions for migrating from styled-components to plain CSS in the REX codebase using a hybrid approach.

Table of Contents

  1. Overview
  2. Why Plain CSS?
  3. Hybrid Approach: Theme in JavaScript
  4. Component Migration Patterns
  5. Testing
  6. Common Pitfalls

Overview

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

Why Plain CSS?

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

Hybrid Approach: Theme in JavaScript

Why Keep Theme in JavaScript?

The REX codebase has several critical use cases that require JavaScript theme access:

  1. Book Banner Colors - Books have dynamic theme properties from CMS (blue, deep-green, gray, etc.) that require dynamic property access: theme.color.primary[bookTheme].base

  2. Highlight Colors - Generated by iterating over arrays and include runtime computations using the Color library

  3. Programmatic Theme Access - React hooks like useMatchMobileQuery() need to read breakpoint values for window.matchMedia()

  4. Type Safety - TypeScript ensures correct theme property access

The Pattern: Bind CSS Variables from JavaScript

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;
}

When to Use CSS Variables vs Direct Theme Access

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())

Component Migration Patterns

Pattern 1: Simple Static Component

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;
}

Pattern 2: Component Using Static Theme Values

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;
}

Pattern 3: Component with Dynamic Theme Access

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!

Pattern 4: Conditional Classes

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;
}

Pattern 5: Media Queries

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);

Pattern 6: Computed Styles from Theme

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);
}

Testing

Jest Configuration

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 = {};

Testing Components

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');
});

Common Pitfalls

1. Don't Duplicate Theme in CSS

❌ Wrong:

:root {
  --color-orange: #d4450c; /* Duplicates theme.ts */
}

✅ Right:

// Bind from theme.ts at component level
style={{ '--button-bg': theme.color.primary.orange.base }}

2. Use classNames Library for Conditional Classes

❌ Wrong:

className={isActive ? 'button button--active' : 'button'}

✅ Right:

import classNames from 'classnames';

className={classNames('button', { 'button--active': isActive })}

3. Preserve Existing Props

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>
  );
}

4. CSS Variable Type Casting

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}

5. Don't Try to Access Theme Dynamically in CSS

❌ 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 }}

6. Remember That theme.ts is Still the Source of Truth

  • 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

Migration Checklist

When migrating a component:

  • Create a .css file 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 classNames library 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

Summary

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:

  1. Book theme colors that vary by book metadata
  2. Highlight colors with dynamic computations
  3. Media query hooks that need programmatic access
  4. Existing patterns that rely on theme object access