Agent Native

migrate-styled-components-to-vanilla-extract

Migrate a workspace's styling from styled-components to vanilla-extract (zero-runtime CSS).

by SanityOfficial publisherNot reviewed

Identity proven by GitHub's verified organisation badge (sanity-io)

Agents
Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode, claude.ai and API
License
MIT (permissive)
Pin
a80a997pinned Oct 9, 2026
Context cost
about 3,100 tokenswhen the skill loads
GitHub stars
20on sanity-io/plugins
Scripts
None found

What it does

“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.”
The author's description, from SKILL.md

Generated summary from the skill's own text

Migrate a workspace's styling from styled-components to vanilla-extract (zero-runtime CSS). This skill provides the step-by-step workflow for converting styled() components to .css.ts files, updating build configuration in tsdown.config.ts, adjusting dependencies, and registering the Vite plugin for testing.

When to use it

  • Converting styled-components usage to vanilla-extract in a Sanity workspace
  • Removing styled-components dependency and adding bundle.css export
  • Refactoring existing styled() components to .css.ts files with wrappers
  • Maintaining visual fidelity while switching CSS libraries

When not to

  • Redesigning component styles rather than refactoring existing ones
  • Workspaces without build tooling or tsdown configuration
  • When visual changes are acceptable during migration

Install

These commands install from the publisher's repository and need no account.

skills CLI

npx skills add sanity-io/plugins --skill migrate-styled-components-to-vanilla-extract

Installs from the repository's default branch, which may have changed since the commit we checked.

GitHub CLI 2.90 or later

gh skill install sanity-io/plugins migrate-styled-components-to-vanilla-extract@a80a997e3d11d188f9639ac3efab1d8f93a276ef

Installs the commit we checked (a80a997).

Copy by hand

Download the folder at a80a997 and put it in .claude/skills/migrate-styled-components-to-vanilla-extract for Claude Code and in .agents/skills/migrate-styled-components-to-vanilla-extract for Codex, Cursor, Gemini CLI, GitHub Copilot and OpenCode.

Skill folder on GitHub at a80a997

Claude Code plugin

/plugin install sanity@claude-plugins-official

The publisher's plugin in Claude's official marketplace. It may hold more than this skill, and we have not pinned it.

Copy for your agent

A prompt for any coding agent. It installs this skill from GitHub at the pinned commit, copies the files as they are, and runs nothing unless you ask.

Prompt

Install the agent skill "migrate-styled-components-to-vanilla-extract" by Sanity.

Source, pinned to commit a80a997e3d11d188f9639ac3efab1d8f93a276ef:
https://github.com/sanity-io/plugins/tree/a80a997e3d11d188f9639ac3efab1d8f93a276ef/.agents/skills/migrate-styled-components-to-vanilla-extract

1. Download that folder at exactly this commit. Do not use the default branch.
2. Copy every file unchanged, keeping the folder structure, into .claude/skills/migrate-styled-components-to-vanilla-extract/ for Claude Code and .agents/skills/migrate-styled-components-to-vanilla-extract/ for Codex, Cursor, Gemini CLI, GitHub Copilot and OpenCode. Create the folders if they do not exist.
3. Do not edit the files, and do not run any script in the folder unless I ask you to.
4. When you are done, list the files you copied and where you put them.

Listing and checks: https://www.agentnative.dev/skills/sanity/migrate-styled-components-to-vanilla-extract

SKILL.md at the pin

---
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](https://vanilla-extract.style).

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](../sanity-plugin-best-practices/references/styling.md) — 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:

```bash
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](../sanity-plugin-best-practices/references/styling.md).

## 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:

```ts
// FloatingCard.css.ts
import {style} from '@vanilla-extract/css'

export const floatingCard = style({
  position: 'fixed',
  bottom: 0,
  left: 0,
  zIndex: 1000,
})
```

```tsx
// FloatingCard.tsx — before: const StyledFloatingCard = styled(Card)`position: fixed; ...`
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](../sanity-plugin-best-practices/references/styling.md#keep-the-component-layer-encapsulation).

**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:

```tsx
// Before: <MapDiffImage><img ... /></MapDiffImage> where MapDiffImage = styled.div`& img {...}`
<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:

```ts
// Checkboard.css.ts
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,
})
```

```tsx
// Checkboard.tsx — before: <div style={{borderRadius, position: 'absolute', inset: 0, background}} />
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](../sanity-plugin-best-practices/references/styling.md). 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`:

```ts
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:

```jsonc
"./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`:

```ts
import {vanillaExtractPlugin} from '@sanity/vanilla-extract-vite-plugin'
import {defineConfig} from 'vitest/config'

export default defineConfig({
  plugins: [vanillaExtractPlugin()],
  // ...existing test config
})
```

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](../sanity-plugin-best-practices/references/styling.md#disabling-runtime-styles-in-tests)
> in the styling reference.

## Step 6: Build and update the exports snapshot

```bash
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:

```bash
pnpm test -u --project <workspace-name>
```

## Step 7: Verify

Run the full pre-PR suite (all must pass):

```bash
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:

```markdown
---
'<workspace-name>': patch
---

Migrate styling from styled-components to vanilla-extract (zero-runtime CSS)
```

## Checklist

- [ ] Every `styled-components` import in `src/` removed; `.styles.tsx` modules deleted
- [ ] `.css.ts` files colocated with their components; component layer preserved where call sites
      need it
- [ ] `tsdown.config.ts`: `styledComponents` removed, `vanillaExtract: true` added
- [ ] `package.json`: vanilla-extract devDeps added; `styled-components` peer removed; `sanity` /
      `@sanity/ui` peer variants verified aligned in `pnpm-lock.yaml`
- [ ] `vitest.config.ts` registers `vanillaExtractPlugin()`
- [ ] `dist/bundle.css` emitted with all rules; package-exports snapshot updated
- [ ] `pnpm format` / `pnpm lint` / `pnpm knip` / `pnpm build` / `pnpm test run` all pass
- [ ] Visual fidelity verified against the pre-migration rendering
- [ ] Patch changeset added

From sanity-io/plugins at a80a997, shown as plain text under its license: MIT (permissive). The license text is in LICENSE.

View SKILL.md on GitHub at a80a997

Files

1 file, 13 KB in total. Each file has a sha256 in the pin's manifest; the first 12 characters are shown.

Skill folder on GitHub at a80a997
All 24 skills from Sanity

More from Sanity

  • sanity-live-cache-components

    Integrates Sanity Live with Next.js Cache Components in next-sanity v13+ apps, setting up shared cached data fetching through sanityFetch, live content…

    Claude Code, Codex, Cursor +4 · about 4K tokens · pinned 6 days ago

  • content-experimentation-best-practices

    This skill provides guidance for designing and running content experiments using A/B and multivariate testing.

    Claude Code, Codex, Cursor +4 · about 360 tokens · pinned 6 days ago

  • content-modeling-best-practices

    Guides agents through structured content modeling principles for headless CMSes like Sanity, covering schema design, content reuse strategies, and field…

    Claude Code, Codex, Cursor +4 · about 340 tokens · pinned 6 days ago

  • portable-text-conversion

    Converts HTML and Markdown content into Portable Text blocks for Sanity using markdownToPortableText, htmlToBlocks, or manual construction.

    Claude Code, Codex, Cursor +4 · about 510 tokens · pinned 6 days ago

  • portable-text-serialization

    This skill enables rendering Portable Text content across multiple frameworks and output formats using the @portabletext library family.

    Claude Code, Codex, Cursor +4 · about 940 tokens · pinned 6 days ago

  • sanity-best-practices

    This skill provides best practices for Sanity headless CMS development across schema design, GROQ queries, framework integrations (Next.js, Nuxt, Astro…

    Claude Code, Codex, Cursor +4 · about 1.1K tokens · pinned 6 days ago

  • next-bundle-optimizer

    Audits Next.js applications to identify and reduce browser bundle size and initial-load work by analyzing routes, dependencies and client outputs.

    by VercelOfficial publisher

    Claude Code, Codex, Cursor +4 · about 2.3K tokens · pinned 6 days ago

  • next-cache-components-optimizer

    Optimize Next.js routes to display meaningful UI instantly on initial load or client navigation by encoding the optimization goal as a failing…

    by VercelOfficial publisher

    Claude Code, Codex, Cursor +4 · about 5.6K tokens · pinned 6 days ago

  • next-partial-prefetching-adoption

    Enables Partial Prefetching in Next.js 16.3+ apps and guides adoption through auditing Link prefetch behavior, preserving UI in tests or manually, and…

    by VercelOfficial publisher

    Claude Code, Codex, Cursor +4 · about 6.7K tokens · pinned 6 days ago

  • remotion-interactivity

    This skill teaches how to structure Remotion code so the Studio IDE recognizes and makes it interactive.

    by RemotionOfficial publisher

    Claude Code, Codex, Cursor +3 · about 2.4K tokens · pinned 6 days ago

  • remotion-interactivity

    This skill teaches how to structure Remotion video components to unlock Studio interactivity features like click selection, drag-and-drop, resizing, rotation…

    by RemotionOfficial publisher

    Claude Code, Codex, Cursor +3 · about 4.8K tokens · pinned 6 days ago

  • remotion-maps

    This skill enables creating map-based animations with Remotion using four different mapping libraries: Mapbox, MapLibre, MapTiler, or CesiumJS.

    by RemotionOfficial publisher

    Contains scripts

    Claude Code, Codex, Cursor +3 · about 240 tokens · pinned 6 days ago

The skills listed here are third-party software, collected from public GitHub repositories as a catalog of recent skills and provided as is. Agent Native does not write or control them and is not responsible for any harm, loss or damage caused by installing or using them. Labels and checks describe what we saw at one commit and are not a guarantee. Read a skill's files before you run it. Terms of use