Skip to content

Migration Guide

Migrate to typestyles from other CSS-in-JS libraries

Migration Guide

Switching to typestyles from other styling solutions is straightforward. This guide covers the most common migration paths.

If you are adopting the variant API, start with Components.

From Panda CSS

Panda and typestyles share many concepts (component variants, tokens, utilities), so migration is mostly API shape changes rather than a full styling rewrite.

css() to styles.class() or styles.component()

Before (Panda CSS):

tsx
import { css } from '../styled-system/css';

const className = css({
  display: 'flex',
  gap: '4',
  '&:hover': { opacity: 0.9 },
});

After (typestyles):

tsx
import { styles } from 'typestyles';

const className = styles.class('card', {
  display: 'flex',
  gap: '16px',
  '&:hover': { opacity: 0.9 },
});

For reusable variant families, prefer styles.component() instead of a single class.

cva() / defineRecipe() to styles.component()

Before (Panda CSS):

ts
import { cva } from '../styled-system/css';

const button = cva({
  base: { fontWeight: 'medium' },
  variants: {
    intent: {
      solid: { bg: 'blue.500', color: 'white' },
      ghost: { bg: 'transparent' },
    },
  },
  defaultVariants: { intent: 'solid' },
});

After (typestyles):

ts
import { styles } from 'typestyles';

const button = styles.component('button', {
  base: { fontWeight: 500 },
  variants: {
    intent: {
      solid: { backgroundColor: '#2563eb', color: 'white' },
      ghost: { backgroundColor: 'transparent' },
    },
  },
  defaultVariants: { intent: 'solid' },
});

// Call as function (base auto-applied):
button(); // base + solid
button({ intent: 'ghost' }); // base + ghost

// Or destructure:
const { base } = button;

theme.tokens / semantic tokens to tokens.create() + tokens.createTheme()

Before (Panda CSS):

ts
// panda.config.ts
theme: {
  tokens: {
    colors: {
      primary: { value: '#0FEE0F' }
    }
  },
  semanticTokens: {
    colors: {
      danger: { value: { base: '{colors.red.500}', _dark: '{colors.red.200}' } }
    }
  }
}

After (typestyles):

ts
import { tokens } from 'typestyles';

export const color = tokens.create('color', {
  primary: '#0FEE0F',
  danger: '#ef4444',
});

export const darkTheme = tokens.createTheme('dark', {
  base: {
    color: {
      danger: '#fca5a5',
    },
  },
});

Apply darkTheme.className on a parent container to scope dark values.

Panda utility props to @typestyles/props

ts
import { defineProperties, createProps } from '@typestyles/props';

const atoms = createProps(
  'atom',
  defineProperties({
    conditions: {
      sm: { '@media': '(min-width: 640px)' },
      dark: { selector: '[data-theme="dark"] &' },
    },
    properties: {
      display: ['flex', 'grid', 'block'],
      gap: { 1: '4px', 2: '8px', 3: '12px' },
    },
  }),
);

atoms({
  display: 'flex',
  gap: { _: 2, sm: 3 },
});

Slot components (Panda sva)

If you use Panda sva (multipart slot variants), use styles.component with a slots configuration so each part maps to a named slot. See Components.

From styled-components

Component structure

Before (styled-components):

tsx
import styled from 'styled-components';

const Button = styled.button`
  padding: 8px 16px;
  border-radius: 6px;
  background-color: ${(props) => (props.primary ? '#0066ff' : '#6b7280')};
  color: white;

  &:hover {
    opacity: 0.9;
  }

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;

<Button primary>Click me</Button>;

After (typestyles):

tsx
import { styles, tokens } from 'typestyles';

const color = tokens.use('color');

const button = styles.component('button', {
  base: {
    padding: '8px 16px',
    borderRadius: '6px',
    color: 'white',
    '&:hover': { opacity: 0.9 },
    '&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
  },
  variants: {
    intent: {
      primary: { backgroundColor: color.primary },
      secondary: { backgroundColor: color.secondary },
    },
  },
  defaultVariants: { intent: 'primary' },
});

function Button({ primary, children }) {
  return (
    <button className={button({ intent: primary ? 'primary' : 'secondary' })}>{children}</button>
  );
}

Key differences

  1. No component wrapper - typestyles returns class names, not React components
  2. Explicit props handling - Logic moves from template literals to regular JavaScript
  3. Static styles - Dynamic values become explicit variants or are passed via inline styles
  4. CSS nesting - Use & prefix for pseudo-classes like &:hover

Dynamic values

Before:

tsx
const Box = styled.div`
  width: ${(props) => props.width}px;
  height: ${(props) => props.height}px;
`;

After:

tsx
const box = styles.component('box', {
  base: {
    display: 'inline-block',
  },
});

function Box({ width, height, children }) {
  return (
    <div className={box()} style={{ width, height }}>
      {children}
    </div>
  );
}

Dynamic values that change frequently should use inline styles. Static styles should use typestyles variants.

From Emotion

Emotion's API is similar to styled-components, so the migration path is nearly identical.

css prop

Before (Emotion):

tsx
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

function Button({ children }) {
  return (
    <button
      css={css`
        padding: 8px 16px;
        background-color: #0066ff;
        color: white;
        &:hover {
          background-color: #0052cc;
        }
      `}
    >
      {children}
    </button>
  );
}

After (typestyles):

tsx
import { styles } from 'typestyles';

const button = styles.component('button', {
  base: {
    padding: '8px 16px',
    backgroundColor: '#0066ff',
    color: 'white',
    '&:hover': {
      backgroundColor: '#0052cc',
    },
  },
});

function Button({ children }) {
  return <button className={button()}>{children}</button>;
}

cx utility

Before:

tsx
import { css, cx } from '@emotion/css';

const base = css`padding: 8px;`;
const primary = css`background: blue;`;
const large = css`font-size: 18px;`;

className={cx(base, isPrimary && primary, isLarge && large)}

After:

When all classes come from one styles.create call, the selector function handles conditional class names directly:

tsx
import { styles, cx } from 'typestyles';

const button = styles.component('button', {
  base: { padding: '8px' },
  variants: {
    intent: {
      primary: { backgroundColor: 'blue' },
    },
    size: {
      large: { fontSize: '18px' },
    },
  },
});

// Option 1: Call with variant overrides
className={button({ intent: isPrimary ? 'primary' : undefined, size: isLarge ? 'large' : undefined })}

// Option 2: Destructure and use cx()
const { base, ...variants } = button;
className={cx(base, isPrimary && 'button-intent-primary', isLarge && 'button-size-large')}

When you need to combine classes from different sources (multiple style groups, external class strings, or conditional expressions), use the built-in cx utility:

tsx
import { cx, styles } from 'typestyles';

const card = styles.class('card', { padding: '16px' });
const button = styles.create('button', {
  base: { padding: '8px' },
  primary: { backgroundColor: 'blue' },
});

className={cx(card, button('base', 'primary'), isActive && 'active', externalClassName)}

From CVA (Class Variance Authority)

styles.component maps closely to CVA's mental model:

  • variants
  • compoundVariants
  • defaultVariants

Basic mapping

Before (CVA):

ts
import { cva } from 'class-variance-authority';

export const button = cva('inline-flex rounded font-medium', {
  variants: {
    intent: {
      primary: 'bg-blue-600 text-white',
      ghost: 'bg-transparent text-gray-900',
    },
    size: {
      sm: 'px-2 py-1 text-sm',
      lg: 'px-4 py-2 text-base',
    },
  },
  compoundVariants: [
    {
      intent: ['primary', 'ghost'],
      size: 'lg',
      class: 'uppercase',
    },
  ],
  defaultVariants: {
    intent: 'primary',
    size: 'sm',
  },
});

After (typestyles):

ts
import { styles } from 'typestyles';

export const button = styles.component('button', {
  base: {
    display: 'inline-flex',
    borderRadius: '8px',
    fontWeight: 500,
  },
  variants: {
    intent: {
      primary: { backgroundColor: '#2563eb', color: 'white' },
      ghost: { backgroundColor: 'transparent', color: '#111827' },
    },
    size: {
      sm: { padding: '4px 8px', fontSize: '14px' },
      lg: { padding: '8px 16px', fontSize: '16px' },
    },
  },
  compoundVariants: [
    {
      variants: {
        intent: ['primary', 'ghost'],
        size: 'lg',
      },
      style: {
        textTransform: 'uppercase',
      },
    },
  ],
  defaultVariants: {
    intent: 'primary',
    size: 'sm',
  },
});

// Usage -- base is auto-applied, defaults kick in:
button(); // base + primary + sm
button({ intent: 'ghost' }); // base + ghost + sm
button({ size: 'lg' }); // base + primary + lg

Key differences

  1. CVA returns composed class strings from existing class tokens; typestyles generates and injects CSS from style objects.
  2. CVA class in compoundVariants becomes typestyles style.
  3. You can keep readable deterministic class output (button-intent-primary, etc.).
  4. The return is both callable AND destructurable (CVA-style).

From Stitches variants

Variant migration

Before (Stitches):

ts
import { styled } from '@stitches/react';

const Button = styled('button', {
  padding: '8px 12px',
  variants: {
    intent: {
      primary: { backgroundColor: 'dodgerblue', color: 'white' },
      ghost: { backgroundColor: 'transparent' },
    },
    outlined: {
      true: { border: '1px solid currentColor' },
    },
  },
  compoundVariants: [
    {
      intent: 'primary',
      outlined: true,
      css: { borderColor: 'blue' },
    },
  ],
  defaultVariants: {
    intent: 'primary',
  },
});

After (typestyles):

ts
import { styles } from 'typestyles';

const button = styles.component('button', {
  base: {
    padding: '8px 12px',
  },
  variants: {
    intent: {
      primary: { backgroundColor: 'dodgerblue', color: 'white' },
      ghost: { backgroundColor: 'transparent' },
    },
    outlined: {
      true: { border: '1px solid currentColor' },
      false: { border: 'none' },
    },
  },
  compoundVariants: [
    {
      variants: {
        intent: 'primary',
        outlined: true,
      },
      style: {
        borderColor: 'blue',
      },
    },
  ],
  defaultVariants: {
    intent: 'primary',
    outlined: false,
  },
});

From vanilla-extract recipes

recipe() migration

Before (vanilla-extract recipe):

ts
import { recipe } from '@vanilla-extract/recipes';

export const button = recipe({
  base: {
    borderRadius: 6,
  },
  variants: {
    tone: {
      neutral: { background: 'white' },
      brand: { background: 'blue', color: 'white' },
    },
  },
  defaultVariants: {
    tone: 'neutral',
  },
});

After (typestyles):

ts
import { styles } from 'typestyles';

export const button = styles.component('button', {
  base: {
    borderRadius: '6px',
  },
  variants: {
    tone: {
      neutral: { backgroundColor: 'white' },
      brand: { backgroundColor: 'blue', color: 'white' },
    },
  },
  defaultVariants: {
    tone: 'neutral',
  },
});

Main trade-off:

  • vanilla-extract is build-time only
  • typestyles supports runtime + SSR today, with build-mode work in progress

From Tailwind CSS

Class-based to object-based

Before (Tailwind):

tsx
function Button({ primary, children }) {
  return (
    <button
      className={`
        px-4 py-2 rounded
        font-medium transition-colors
        ${
          primary
            ? 'bg-blue-600 text-white hover:bg-blue-700'
            : 'bg-gray-200 text-gray-800 hover:bg-gray-300'
        }
      `}
    >
      {children}
    </button>
  );
}

After (typestyles):

tsx
import { styles, tokens } from 'typestyles';

const color = tokens.create('color', {
  primary: '#0066ff',
  primaryHover: '#0052cc',
  secondary: '#6b7280',
  secondaryHover: '#4b5563',
});

const button = styles.component('button', {
  base: {
    padding: '8px 16px',
    borderRadius: '6px',
    fontWeight: 500,
    transition: 'background-color 150ms ease',
  },
  variants: {
    intent: {
      primary: {
        backgroundColor: color.primary,
        color: '#fff',
        '&:hover': { backgroundColor: color.primaryHover },
      },
      secondary: {
        backgroundColor: color.secondary,
        color: '#fff',
        '&:hover': { backgroundColor: color.secondaryHover },
      },
    },
  },
  defaultVariants: { intent: 'primary' },
});

function Button({ primary, children }) {
  return (
    <button className={button({ intent: primary ? 'primary' : 'secondary' })}>{children}</button>
  );
}

Design tokens

Tailwind's configuration becomes typestyles tokens:

Before (tailwind.config.js):

js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: '#0066ff',
        secondary: '#6b7280',
      },
      spacing: {
        4: '16px',
        6: '24px',
      },
    },
  },
};

After (tokens.ts):

ts
import { tokens } from 'typestyles';

export const color = tokens.create('color', {
  primary: '#0066ff',
  secondary: '#6b7280',
});

export const space = tokens.create('space', {
  4: '16px',
  6: '24px',
});

Gradual migration

You can use Tailwind and typestyles together during migration:

tsx
import { styles, cx } from 'typestyles';

const card = styles.component('card', {
  base: {
    // New styles with typestyles
    boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
  },
});

function Card({ children }) {
  return (
    <div className={cx(card(), 'p-4 bg-white rounded')}>
      {/*                       ^ Tailwind classes still work */}
      {children}
    </div>
  );
}

From CSS Modules

File organization

Before (Button.module.css):

css
.button {
  padding: 8px 16px;
  border-radius: 6px;
}

.primary {
  background-color: #0066ff;
  color: white;
}

.secondary {
  background-color: #6b7280;
  color: white;
}

Before (Button.tsx):

tsx
import styles from './Button.module.css';

function Button({ variant, children }) {
  return <button className={`${styles.button} ${styles[variant]}`}>{children}</button>;
}

After (button.styles.ts):

ts
import { styles } from 'typestyles';

export const button = styles.component('button', {
  base: {
    padding: '8px 16px',
    borderRadius: '6px',
  },
  variants: {
    intent: {
      primary: { backgroundColor: '#0066ff', color: 'white' },
      secondary: { backgroundColor: '#6b7280', color: 'white' },
    },
  },
  defaultVariants: { intent: 'primary' },
});

After (Button.tsx):

tsx
import { button } from './button.styles';

function Button({ variant, children }) {
  return <button className={button({ intent: variant })}>{children}</button>;
}

Global styles

CSS Modules :global becomes typestyles without nesting:

Before:

css
:global(.tooltip) {
  position: absolute;
}

After:

ts
const tooltip = styles.component('tooltip', {
  base: {
    position: 'absolute',
  },
});

// Use: tooltip()

From plain CSS

Migrating from plain CSS gives you type safety and better organization.

Step-by-step

  1. Identify components - Start with your most reused components (buttons, inputs, cards)

  2. Extract tokens - Move hardcoded values to tokens:

    ts
    // Before: colors scattered in CSS files
    // After:
    export const color = tokens.create('color', {
      primary: '#0066ff',
      secondary: '#6b7280',
    });
    
  3. Create style definitions - Convert CSS rules to typestyles:

    css
    /* Before */
    .btn {
      padding: 8px 16px;
      background: #0066ff;
    }
    .btn:hover {
      background: #0052cc;
    }
    
    ts
    // After
    const button = styles.component('button', {
      base: {
        padding: '8px 16px',
        backgroundColor: color.primary,
        '&:hover': {
          backgroundColor: color.primaryHover,
        },
      },
    });
    
  4. Update components - Replace className strings with function calls

  5. Remove old CSS - Once fully migrated, delete the CSS files

From styles.create (previous typestyles API)

If you are migrating from the previous styles.create() API:

Basic migration

Before:

ts
const button = styles.create('button', {
  base: { padding: '8px 16px' },
  primary: { backgroundColor: '#0066ff' },
  large: { fontSize: '18px' },
});

// Usage:
button('base', 'primary', 'large');
button('base', isPrimary && 'primary');

After (flat config):

ts
const button = styles.component('button', {
  base: { padding: '8px 16px' },
  primary: { backgroundColor: '#0066ff' },
  large: { fontSize: '18px' },
});

// Base is auto-applied. Destructure for direct access:
const { base, primary, large } = button;
cx(base, primary, large);

// Or use cx() with conditions:
cx(button(), isPrimary && primary);

After (dimensioned config -- recommended for variants like intent/size):

ts
const button = styles.component('button', {
  base: { padding: '8px 16px' },
  variants: {
    intent: {
      primary: { backgroundColor: '#0066ff' },
    },
    size: {
      large: { fontSize: '18px' },
    },
  },
  defaultVariants: { intent: 'primary', size: 'large' },
});

// Base auto-applied, defaults kick in:
button();
button({ intent: 'primary', size: 'large' });

Key changes

  1. styles.create() is removed; use styles.component() instead.
  2. Base styles are auto-applied when calling as a function -- no need to pass 'base' explicitly.
  3. The return is both callable AND destructurable.
  4. Use cx() (exported from 'typestyles') for conditional class joining.
  5. Varargs calling patterns like button('base', 'primary') are replaced by either the function call with variant object or destructuring.

General migration tips

1. Start small

Don't migrate everything at once. Pick one component or one page and convert it. typestyles works alongside your existing styles during the transition.

2. Keep the same names

If you have .button-primary in CSS, create a button component with a primary variant. This makes the migration easier to follow.

3. Use tokens early

Define your design tokens before converting components. This ensures consistency and makes the component migration smoother.

4. Test class names

In your tests, you may need to update selectors:

Before:

ts
expect(screen.getByRole('button')).toHaveClass('button-primary');

After:

ts
expect(screen.getByRole('button')).toHaveClass('button-base', 'button-intent-primary');

5. DevTools familiarity

Your generated class names will be human-readable (button-intent-primary), so DevTools inspection stays familiar -- actually more readable than hashed class names from other CSS-in-JS libraries.

6. Bundle size check

After migration, your JavaScript bundle may be slightly smaller (no CSS parsing runtime) but you'll have a small runtime addition from typestyles itself. Overall size should be similar or smaller.

Common patterns comparison

Pattern styled-components Emotion Tailwind typestyles
Basic styling styled.div...` css...` className="p-4" styles.component()
Variants Props + template literals Props + template literals Conditional strings Variant object or destructure
Pseudo-classes &:hover in template &:hover in template hover: prefix &:hover in object
Media queries @media in template @media in template Responsive prefixes @media in object
Theme values ${props => props.theme...} ${theme...} Config-based Token references
Dynamic values Template literals Template literals Arbitrary values Inline styles
Class joining className props cx() from emotion clsx() cx() from typestyles

Migration CLI (MVP)

The @typestyles/migrate package includes an early CLI to help with static migrations from styled-components and Emotion.

Scope in this first version

  • Converts static tagged templates (styled.*, styled(...), and css\...`) into styles.class(...)`.
  • Rewrites JSX usage for safely transformable styled components.
  • Skips dynamic template interpolations and emits warnings instead of doing unsafe rewrites.

Usage

bash
pnpm --filter @typestyles/migrate typestyles-migrate src

By default the command is dry-run and prints patch output. Use --write to apply changes:

bash
pnpm --filter @typestyles/migrate typestyles-migrate src --write

Useful options:

  • --include <glob>: only process matching files (repeatable)
  • --exclude <glob>: ignore matching files (repeatable)
  • --extensions .ts,.tsx: customize scanned extensions
  • --report migration-report.json: write a JSON summary and warning report

Current limitations

  • Dynamic interpolations (for example ${(props) => ...}) are intentionally not auto-migrated.
  • Exported styled components are skipped to avoid accidental API-shape changes.
  • Complex non-JSX references to styled component variables are skipped.

Troubleshooting migration issues

Styles not applying

  • Check that the namespace in styles.component() is unique
  • Verify the component is being rendered (lazy injection means CSS only appears when used)
  • Use DevTools to confirm class names are being applied

Type errors

  • Ensure you're importing from 'typestyles'
  • Check that TypeScript knows about the CSS property types (should work out of the box)
  • For custom properties, use type assertions: { ['--custom' as string]: 'value' }

Performance concerns

  • Don't create styles inside components (define them at module level)
  • Use tokens instead of recreating values
  • Static styles only -- dynamic values should use inline styles

If you hit any issues during migration, check the troubleshooting guide or open an issue.