Skip to content

Latest commit

 

History

History
672 lines (537 loc) · 17 KB

File metadata and controls

672 lines (537 loc) · 17 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 2.5: Using Root-Level CSS Variables for Static Theme Colors

For static theme colors that don't require dynamic property access (like theme.color.text.*, theme.color.neutral.*, theme.color.disabled.*), use root-level CSS variables defined in src/index.css instead of binding them at the component level.

Benefits:

  • Reduces code duplication across components
  • Improves maintainability by centralizing static color definitions
  • Establishes clear patterns for future migrations
  • Keeps a documented mapping from src/app/theme.ts into shared CSS variables

Important: The root-level variables in src/index.css are copied from src/app/theme.ts; they are not automatically generated or verified by the process described in this guide. When updating these values, keep src/index.css and src/app/theme.ts in sync manually. When to use root-level variables:

  • Static colors that never change based on props
  • Colors used across multiple components
  • Colors from theme.color.text.*, theme.color.neutral.*, theme.color.disabled.*, and theme.color.primary.gray.*

When to use component-level bindings:

  • Book-specific theme colors requiring dynamic property access (theme.color.primary[bookTheme])
  • Colors with runtime computations (highlight colors using Color library)
  • Any color that changes based on props or state

Example:

// ❌ Before: Component-level binding for static color
import theme from '../theme';

export function Card({ className, style, ...props }) {
  return (
    <div
      className="modal-card"
      style={{
        '--text-color': theme.color.text.default,  // Static, repeated across components
        ...style,
      } as React.CSSProperties}
    />
  );
}
// ✅ After: Use root-level CSS variable
export function Card({ className, style, ...props }) {
  return (
    <div
      className="modal-card"
      style={style}  // No need to bind static colors
    />
  );
}
/* Component.css */
.modal-card {
  color: var(--color-text-default);  /* References root-level variable */
  background: var(--color-neutral-base);
}

Naming Convention:

Root-level CSS variables follow the pattern: --color-{category}-{property}

  • theme.color.text.default--color-text-default
  • theme.color.neutral.pageBackground--color-neutral-page-background
  • theme.color.primary.gray.base--color-primary-gray-base
  • theme.color.disabled.foreground--color-disabled-foreground

Note: camelCase properties in theme.ts become kebab-case in CSS variable names.

Available Root-Level Variables:

See src/index.css for the complete list of available root-level CSS variables. These include:

  • Text colors: --color-text-black, --color-text-default, --color-text-label, --color-text-white
  • Neutral colors: --color-neutral-base, --color-neutral-darker, --color-neutral-darkest, --color-neutral-page-background, etc.
  • Disabled colors: --color-disabled-base, --color-disabled-foreground
  • Primary gray colors: --color-primary-gray-base, --color-primary-gray-darker, etc.

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