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):
import { css } from '../styled-system/css';
const className = css({
display: 'flex',
gap: '4',
'&:hover': { opacity: 0.9 },
});
After (typestyles):
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):
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):
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):
// panda.config.ts
theme: {
tokens: {
colors: {
primary: { value: '#0FEE0F' }
}
},
semanticTokens: {
colors: {
danger: { value: { base: '{colors.red.500}', _dark: '{colors.red.200}' } }
}
}
}
After (typestyles):
import { tokens } from 'typestyles';
export const color = tokens.create('color', {
primary: '#0FEE0F',
danger: '#ef4444',
});
export const darkTheme = tokens.createTheme('dark', {
color: {
danger: '#fca5a5',
},
});
Apply darkTheme on a parent container to scope dark values.
Panda utility props to @typestyles/props
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):
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):
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
- No component wrapper - typestyles returns class names, not React components
- Explicit props handling - Logic moves from template literals to regular JavaScript
- Static styles - Dynamic values become explicit variants or are passed via inline styles
- CSS nesting - Use
&prefix for pseudo-classes like&:hover
Dynamic values
Before:
const Box = styled.div`
width: ${(props) => props.width}px;
height: ${(props) => props.height}px;
`;
After:
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):
/** @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):
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:
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:
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:
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:
variantscompoundVariantsdefaultVariants
Basic mapping
Before (CVA):
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):
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
- CVA returns composed class strings from existing class tokens; typestyles generates and injects CSS from style objects.
- CVA
classincompoundVariantsbecomes typestylesstyle. - You can keep readable deterministic class output (
button-intent-primary, etc.). - The return is both callable AND destructurable (CVA-style).
From Stitches variants
Variant migration
Before (Stitches):
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):
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):
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):
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):
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):
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):
module.exports = {
theme: {
extend: {
colors: {
primary: '#0066ff',
secondary: '#6b7280',
},
spacing: {
4: '16px',
6: '24px',
},
},
},
};
After (tokens.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:
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):
.button {
padding: 8px 16px;
border-radius: 6px;
}
.primary {
background-color: #0066ff;
color: white;
}
.secondary {
background-color: #6b7280;
color: white;
}
Before (Button.tsx):
import styles from './Button.module.css';
function Button({ variant, children }) {
return <button className={`${styles.button} ${styles[variant]}`}>{children}</button>;
}
After (button.styles.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):
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:
:global(.tooltip) {
position: absolute;
}
After:
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
Identify components - Start with your most reused components (buttons, inputs, cards)
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', });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, }, }, });Update components - Replace className strings with function calls
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:
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):
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):
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
styles.create()is removed; usestyles.component()instead.- Base styles are auto-applied when calling as a function -- no need to pass
'base'explicitly. - The return is both callable AND destructurable.
- Use
cx()(exported from'typestyles') for conditional class joining. - 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:
expect(screen.getByRole('button')).toHaveClass('button-primary');
After:
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(...), andcss\...`) intostyles.class(...)`. - Rewrites JSX usage for safely transformable styled components.
- Skips dynamic template interpolations and emits warnings instead of doing unsafe rewrites.
Usage
pnpm --filter @typestyles/migrate typestyles-migrate src
By default the command is dry-run and prints patch output. Use --write to apply changes:
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.