| name | migrate-styled-components-to-vanilla-extract |
| description | Step-by-step procedure for migrating a workspace off styled-components to vanilla-extract (zero-runtime CSS). Use when converting styled() components to .css.ts files, removing a styled-components dependency, adding a ./bundle.css export, or when asked to migrate styling to vanilla-extract. Pair with the sanity-plugin-best-practices styling reference for the underlying patterns. |
| metadata | {"author":"Sanity.io","version":"1.0.0"} |
Migrate styled-components to vanilla-extract
The repeatable procedure for converting a workspace's styling from styled-components (the Studio's
legacy styling library) to vanilla-extract.
This skill covers the workflow: what to change, in what order, and how to verify it. The styling
patterns themselves (dynamic theming, variants, keyframes, the && specificity trick,
encapsulation) live in the sanity-plugin-best-practices skill's
styling reference — read it first and defer
to it for pattern details. For monorepo-specific policy (one plugin per PR, never during a transfer,
which plugins are reference implementations), see AGENTS.md →
Migrating plugin styling off styled-components.
Ground rules
- Identical visual output is the goal. This is a refactor, not a redesign — every rule, theme
token, and specificity outcome must be preserved. Verify the result against the original rendering
before opening the PR.
Step 1: Inventory
Find every styled-components usage in the workspace and classify it before touching code:
rg "styled-components" <workspace>/src
| Usage | Migration target |
|---|
Static styled(Primitive) / styled.div | style() in a colocated .css.ts + thin wrapper component (or plain className) |
Theme reads (({theme}) => theme.sanity...) | createVar() + assignInlineVars() + useTheme_v2() |
Prop-driven variants ($isInvalid, css branches) | styleVariants() or conditional class composition |
keyframes animations | vanilla-extract keyframes() |
Descendant selectors (& img, third-party classes) | class on the child directly, or globalStyle() scoped under a local wrapper class |
createGlobalStyle | globalStyle() (scope it — never leak outside the workspace) |
*.styles.tsx modules | replaced by .css.ts (+ a component layer where call sites need it) |
Computed inline style={{}} objects | static parts into style(), changing values via createVar() (Shape C below) |
Each row's pattern is documented with examples in the
styling reference.
Step 2: Migrate the code
Work component by component. Create a .css.ts next to each component and move its rules over. Two
real shapes cover most cases:
Shape A — keep the component layer. When the styled element is used like a component (composed,
given props), replace it with a style() rule plus a thin wrapper that keeps the same name and API,
so call sites don't change:
import {style} from '@vanilla-extract/css'
export const floatingCard = style({
position: 'fixed',
bottom: 0,
left: 0,
zIndex: 1000,
})
import {Card} from '@sanity/ui'
import {clsx} from 'clsx/lite'
import type {ComponentProps} from 'react'
import {floatingCard} from './FloatingCard.css'
function StyledFloatingCard({className, ...props}: ComponentProps<typeof Card>) {
return <Card {...props} className={clsx(floatingCard, className)} />
}
Type the wrapper with ComponentProps<typeof Primitive> (or ComponentProps<'div'>) and never use
forwardRef — ref is a regular prop on React 19. Merge, don't clobber: a fixed
className={floatingCard} after {...props} silently drops a className a caller passes in. Pull
className out of props and merge it with clsx(floatingCard, className) — prefer
import {clsx} from 'clsx/lite' when only joining strings (the usual case); reach for full clsx
only if you need the object/array API. A `${floatingCard} ${className ?? ''}` template literal
also works. See
Keep the component layer.
Shape B — flatten single-use wrappers. When the styled element was an internal, single-use
styled.div with no meaningful API, put the class directly on the element. Descendant selectors
like & img { ... } usually migrate best by styling the child directly when you render it yourself:
<img className={mapDiffImage} alt="" src={url} height={280} width={500} />
Shape C — dynamic values through CSS variables. When a value varies per instance, with
props/state, or reads the theme, keep the static parts in style() and bridge only the changing
values through createVar() + assignInlineVars() (from @vanilla-extract/dynamic). This also
migrates computed inline style={{}} objects — hoist the static properties into the .css.ts and
keep only the variables inline:
import {createVar, style} from '@vanilla-extract/css'
export const borderRadiusVar = createVar()
export const backgroundImageVar = createVar()
export const checkboard = style({
borderRadius: borderRadiusVar,
position: 'absolute',
inset: 0,
background: backgroundImageVar,
})
import {assignInlineVars} from '@vanilla-extract/dynamic'
import {backgroundImageVar, borderRadiusVar, checkboard} from './Checkboard.css'
function Checkboard({borderRadius, background}: {borderRadius?: string; background?: string}) {
return (
<div
className={checkboard}
style={assignInlineVars({
[borderRadiusVar]: borderRadius,
[backgroundImageVar]: background ? `url(${background}) center left` : undefined,
})}
/>
)
}
assignInlineVars omits undefined values, so one class serves every instance. Theme tokens flow
the same way: read them with useTheme_v2() in a small wrapper and assign them to variables.
For everything else — variants (styleVariants), keyframes, theming third-party widgets'
classes, and overriding a @sanity/ui primitive's own styles (the selectors: {'&&': {...}}
trick) — follow the corresponding section of the
styling reference. Where styled-components
silently won specificity battles via CSSOM insertion order, you may need && to match the original
rendering.
Delete the *.styles.tsx modules (and their styled-components imports) as they empty out.
Step 3: Switch the build config
In the workspace's tsdown.config.ts, drop styledComponents: true and add vanillaExtract: true:
import {defineConfig} from '@sanity/tsdown-config'
import type {UserConfig} from 'tsdown'
export default defineConfig({
reactCompiler: true,
vanillaExtract: true,
}) satisfies Promise<UserConfig | UserConfig[]>
The integration extracts all .css.ts rules into dist/bundle.css at build time, auto-imports it
from dist/index.js, and writes/syncs the ./bundle.css entry in package.json exports and
publishConfig.exports on every build — nothing to hand-edit there. After the first build the
export block looks like:
"./bundle.css": {
"types": "./dist/bundle-css.d.ts",
"browser": "./dist/bundle.css",
"style": "./dist/bundle.css",
"node": "./dist/bundle-css.js",
"default": "./dist/bundle-css.js"
}
Step 4: Update dependencies
In the workspace's package.json:
- Add dev dependencies
"@sanity/vanilla-extract-vite-plugin": "catalog:" and
"@vanilla-extract/css": "catalog:". Both are build-time only — never runtime dependencies.
(If the workspace uses assignInlineVars, add "@vanilla-extract/dynamic": "catalog:" under
dependencies — that one is a small runtime helper.)
- Remove the
styled-components entry from peerDependencies, and any
babel-plugin-styled-components devDependency.
- Peer alignment: when the workspace depends on
@sanity/ui (which peers on styled-components),
removing the "styled-components": "catalog:" devDependency can make pnpm resolve a separate
styled-components copy, forking the workspace's sanity peer variant away from the rest of the
monorepo and breaking type-aware lint. Dropping it can be fine, but you must verify: after
pnpm install, the workspace's sanity / @sanity/ui resolution strings in pnpm-lock.yaml must
match other workspaces that depend on @sanity/ui. If they fork, keep the catalog:
devDependency.
No knip.jsonc or catalog changes are needed — @vanilla-extract/css is globally ignored and all
the catalog entries already exist.
Step 5: Register the Vite plugin for Vitest
The package-exports test resolves the workspace exports map, whose . entry points at
./src/index.ts — so it imports real .css.ts source and Vitest must compile it. Add the plugin to
the workspace's vitest.config.ts:
import {vanillaExtractPlugin} from '@sanity/vanilla-extract-vite-plugin'
import {defineConfig} from 'vitest/config'
export default defineConfig({
plugins: [vanillaExtractPlugin()],
})
Use @sanity/vanilla-extract-vite-plugin (faster drop-in for the upstream
@vanilla-extract/vite-plugin). If a host app or studio already registers the Vite plugin
globally, you don't need to touch that file for the workspace under migration.
Optional, jsdom-only: if the workspace has jsdom/happy-dom suites that don't assert on real
CSS (layout, getComputedStyle), you can skip runtime style injection with
setupFiles: ['@vanilla-extract/css/disableRuntimeStyles']. This does not replace the Vite
plugin, is irrelevant under the default node environment, and is opted into per workspace — never
monorepo-wide. See
Disabling runtime styles in tests
in the styling reference.
Step 6: Build and update the exports snapshot
pnpm install
pnpm build --filter=<workspace-name>
Check the output: dist/bundle.css must contain every migrated rule, and dist/index.js must
import it. Then update the package-exports inline snapshot, which gains a "./bundle.css" entry:
pnpm test -u --project <workspace-name>
Step 7: Verify
Run the full pre-PR suite (all must pass):
pnpm format
pnpm lint
pnpm knip
pnpm build
pnpm test run
Then verify visual fidelity manually: exercise the workspace's UI and compare against the
pre-migration rendering — theme tokens, spacing, stacking, hover/sticky/fixed behavior. If an
override no longer takes effect, reach for the && trick rather than !important.
Optionally lock the migration in: once the workspace no longer imports styled-components, a
no-restricted-imports ban in a workspace-level lint override keeps it from creeping back.
Step 8: Add a changeset
One patch changeset for the migrated workspace:
---
'<workspace-name>': patch
---
Migrate styling from styled-components to vanilla-extract (zero-runtime CSS)
Checklist