→ Six changes for everyone, then a mapping table per source — with the hard part named each time.
Migration
Moving an existing codebase onto Salty CSS is two questions, and it's worth answering them in order. First: what has to change structurally, regardless of what you're leaving behind. Second: what does each API you use today actually turn into.
The first half of this page applies to everyone — the same six changes land whether you're coming from a runtime CSS-in-JS library, an atomic framework, or a folder of SCSS partials. The second half is per-source: the common API mappings, one worked translation each, and the specific part that tends to be hardest.
I won't pretend migration is painless. It isn't, and I can't market it as easy. What I can do is tell you exactly what you're signing up for before you start. (AI assistants help a lot with the mechanical translation these days, for what it's worth — and most of this work is mechanical.)
Versions this page assumes
APIs move. Everything below was written against these:
| Coming from | Version assumed | Note |
|---|---|---|
| styled-components | 6.4 | createTheme, RSC support, transient props |
Stitches (@stitches/react) | 1.2.8 | Last published April 2022 |
| Panda CSS | 1.11 | 2.0 is in beta at the time of writing |
| Linaria | 8.x | wyw-in-js 2, Node ≥ 22.12, @wyw-in-js/babel-preset |
| Tailwind CSS | 4.3 | CSS-first @theme config |
| Sass (dart-sass) | 1.102 | Module system (@use / @forward) |
| Salty CSS | 0.4.x | Pre-1.0 — see Compatibility |
If you're on an older major of any of these, the mapping tables still hold; the surrounding APIs may not.
What changes no matter where you're coming from
Six things. None of them depend on which library you're leaving.
1. Styles move into their own files. Salty only compiles files ending in .css.ts, .css.tsx, .salty.ts, .styled.ts or .styles.ts. If you're coming from styled-components, Stitches, Linaria or Panda's JSX factory, you've been co-locating styles inside .tsx — and you give that up. This is a real tradeoff, not a free win. What the boundary buys is a compilation firewall: the compiler only evaluates files that contain CSS, so build cost scales with how much styling you have rather than with the size of your app.
2. CSS text becomes style objects. Tagged templates and .scss blocks both become objects with camelCase keys. background-color is backgroundColor, &:hover { … } is "&:hover": { … }. This is the largest chunk of the work by volume and the least interesting — nesting, pseudo-selectors, media blocks and & all survive the trip intact.
3. Values resolve at build time. This is the conceptual change, and it's the one that generates actual decisions rather than typing. Anything your styles currently compute during render has to move onto a ladder, and you stop at the first rung that fits:
- The browser already tracks it (hover, focus, checked, valid) → a pseudo-selector in
base - It's a closed set of outcomes → a variant, typed as a prop
- It's an open-ended value the consumer picks → a prop token,
{props.x}in the style andcss-xat the call site - It genuinely doesn't exist until the request runs (CMS payload, tenant config) →
defineRuntime - It changes per frame → a plain
styleprop, same as always
4. The cascade is @layer, not specificity. Salty declares imports, reset, global, templates, fonts, l0…l8 and resolves conflicts by layer order. Extending a styled component bumps it into the next layer automatically, so an override wins without a more specific selector. priority is the manual control. If your current codebase leans on selector weight or !important to settle fights, that machinery doesn't come with you — and !important inverts layer order, which is native CSS behaviour rather than a Salty quirk.
5. Theming stops being a JavaScript object. No provider, no context, no re-render. Conditional tokens compile to [data-theme="dark"] { --theme-bg: … } in the static stylesheet, and switching means setting one attribute on an ancestor. Whatever sets that attribute is the whole integration.
6. The requirement is a bundler, not a framework. Salty needs Vite or Webpack. styled ships per framework (React and Astro today); everything else — tokens, templates, media queries, fonts, keyframes, global styles, className — is framework-agnostic. See Framework agnostic APIs.
What doesn't have to change
Nothing about this is a flag day, and the migration is designed to be boring for that reason.
Salty's output is regular CSS in its own cascade layers, so it coexists with whatever you already have — CSS modules, hand-written stylesheets, a utility framework, a runtime CSS-in-JS library still rendering half your app. Add the plugin, write one *.css.ts file, ship it, and keep going. The two stylesheets don't fight; they sort by layer.
Two config options are worth setting during the migration specifically:
strict: 'warn'— mistyped token paths surface as warnings instead of failing the build while you're mid-port. Flip it totruewhen you're through.importStrategy— leave it at'root'unless you already have route-level CSS splitting you want to preserve.
The four categories
Where you're coming from predicts the shape of the work more than the library name does.
| Category | Libraries | The change | The hard part |
|---|---|---|---|
| Runtime CSS-in-JS | styled-components, Stitches, Emotion | Execution model: render time → build time | Every function interpolation needs a decision |
| Build-time CSS-in-JS | Panda CSS, Linaria, Vanilla Extract | Mostly syntax; the model already matches | Output shape (atomic vs. component-hashed) |
| Atomic / utility-first | Tailwind, UnoCSS | The unit of styling itself | It's a re-architecture, not a translation |
| Preprocessors & plain CSS | Sass, Less, CSS Modules | Syntax, plus real scoping | Giving up cross-component selectors |
Read that table as effort estimation. The build-time CSS-in-JS row is the cheapest migration on the page because the mental model transfers wholesale — you already write styles in TypeScript, you already accept a build step, you already know not to touch window in a style file. The atomic row is the most expensive, and it's the one where I'd stop and check the fit before doing any work at all: if you're building lean, repetitive, utilitarian UI where the smallest possible stylesheet is the priority, atomic CSS is a genuinely good architecture for that and this migration may not be worth doing.
Runtime CSS-in-JS
styled-components 6.4
styled-components is the OG styled API. The component-first mental model carries over almost completely: as, wrapping third-party components, extending a styled component, keyframes and global styles all have direct equivalents. What doesn't carry is the tagged-template syntax, the function interpolations, and the runtime itself.
Worth being precise about how close these two are in 2026, because the gap narrowed from both sides. styled-components 6.4's createTheme already compiles tokens to CSS custom properties (var(--sc-colors-fg, …)) and it has a React Server Components story, so "theming needs a provider" and "it can't run on the server" are both out of date. The remaining difference is where the work happens: styled-components resolves styles at render and ships a styling engine to do it (their README puts the package under 13kB gzipped); Salty resolves at build and ships class names.
| styled-components 6.4 | Salty CSS |
|---|---|
styled.div`…` | styled("div", { base: { … } }) |
styled(Component)`…` | styled(Component, { base: { … } }) |
${p => p.$primary ? a : b} | variants — a closed set |
${p => p.width}px | {props.width} token + css-width prop |
Transient props ($primary) | Not needed — variant props are consumed by default |
ThemeProvider + props.theme | defineVariables conditional.theme + data-theme |
createTheme | defineVariables |
createGlobalStyle | defineGlobalStyles |
keyframes`…` | keyframes({ … }) |
css`…` shared block | defineTemplates, or a plain exported object |
.attrs({ type: "password" }) | defaultProps |
as prop | as prop |
shouldForwardProp | passProps — inverted: opt-in rather than opt-out |
import styled from "styled-components";
const Button = styled.button<{ $primary?: boolean }>`
padding: 0.6em 1.2em;
border-radius: 6px;
border: 2px solid palevioletred;
background: ${(p) => (p.$primary ? "palevioletred" : "white")};
color: ${(p) => (p.$primary ? "white" : "palevioletred")};
&:hover {
filter: brightness(1.05);
}
`;
// <Button $primary>Save</Button>import { styled } from "@salty-css/react/styled";
export const Button = styled("button", {
base: {
padding: "0.6em 1.2em",
borderRadius: "6px",
border: "2px solid palevioletred",
background: "white",
color: "palevioletred",
"&:hover": { filter: "brightness(1.05)" },
},
variants: {
primary: {
true: { background: "palevioletred", color: "white" },
},
},
});
// <Button primary>Save</Button>Two things happened there that are typical of the whole migration. The $ prefix disappeared, because Salty consumes variant props before they reach the DOM by default — passProps is how you opt back out for the cases that need it. And two separate ternaries collapsed into one variant, which is the shape most props => interpolations take once you look at them squarely.
The hard part is the interpolations, not the CSS. The declaration blocks translate themselves. Every ${props => …} is a decision on the ladder from the section above, and the count of those in your codebase is a far better estimate of migration effort than the line count is.
Stitches 1.2.8
This is the closest API in the list, and that isn't a coincidence — Salty's variant model is deliberately modelled on Stitches', which the design philosophy page covers. variants, compoundVariants and defaultVariants are the same names with the same semantics, compound variants use the same css key, and named media queries work the same way.
Two syntax deltas do most of the work. Base styles nest under base rather than sitting at the top level of the config object, and token references change from $colors$brand to {colors.brand}.
| Stitches 1.2.8 | Salty CSS |
|---|---|
styled("button", { …base, variants }) | styled("button", { base: { … }, variants }) |
$colors$brand / $brand | "{colors.brand}" |
createStitches({ theme }) | defineVariables |
createStitches({ media }) | defineMediaQuery |
createTheme() → class name | conditional.theme → data-theme attribute |
css({ … }) | className({ base: { … } }) |
utils | Function templates — see below |
compoundVariants / defaultVariants | Same |
as prop | as prop |
import { styled } from "./stitches.config";
export const Button = styled("button", {
padding: "0.6em 1.2em",
borderRadius: "$radii$md",
variants: {
tone: {
solid: { background: "$colors$brand", color: "white" },
ghost: { background: "transparent", color: "$colors$brand" },
},
size: { small: { fontSize: "0.85rem" }, large: { fontSize: "1.15rem" } },
},
compoundVariants: [{ tone: "solid", size: "large", css: { fontWeight: 700 } }],
defaultVariants: { tone: "solid", size: "small" },
});import { styled } from "@salty-css/react/styled";
export const Button = styled("button", {
base: {
padding: "0.6em 1.2em",
borderRadius: "{radii.md}",
},
variants: {
tone: {
solid: { background: "{colors.brand}", color: "white" },
ghost: { background: "transparent", color: "{colors.brand}" },
},
size: { small: { fontSize: "0.85rem" }, large: { fontSize: "1.15rem" } },
},
compoundVariants: [{ tone: "solid", size: "large", css: { fontWeight: 700 } }],
defaultVariants: { tone: "solid", size: "small" },
});Everything below variants is byte-identical. That's the whole Stitches migration for a typical component.
utils is the one that needs thought. A Stitches util maps an invented property name to several declarations, taking an arbitrary value — mx: (value) => ({ marginLeft: value, marginRight: value }). The equivalent is a function template: same idea, defined once in defineTemplates, applied as a key inside base, with the argument typed.
export default defineTemplates({
mx: (value: string) => ({ marginLeft: value, marginRight: value }),
});
// base: { mx: "1rem" }If your utils do property renaming with no logic, that's a template. If they rewrite values by pattern — a px → rem shorthand, a spacing scale that does arithmetic — that's a modifier instead.
Build-time CSS-in-JS
Panda CSS 1.11
There's shared vocabulary here on purpose: base and treating text styles as templates are both conventions Salty took from Panda because they're good ideas. So a fair amount of a Panda codebase reads as valid Salty with the imports swapped. Four things genuinely differ, and two of them are architectural.
Extraction model. Panda statically analyses your source to find style calls. Salty executes the style file. The consequences run both ways: Salty can run arbitrary TypeScript at build time — including await fetch() — because it's really running your code, while Panda can find styles anywhere in a .tsx file because it never has to run anything. Salty's price for that is the filename suffix.
Output model. Panda emits atomic utilities; Salty emits one content-hashed class per distinct style object. Identical style objects deduplicate to a single rule, and importStrategy: 'component' splits the stylesheet per route, but the scaling curve is genuinely different: Panda's stylesheet grows with the number of distinct declarations, Salty's with the number of distinct components. That's the tradeoff to make a decision about before you start, not after.
No codegen step. There's no panda codegen and no styled-system/ directory to import from. Types come from your own source files. saltygen/ is CSS output only — a build artifact you gitignore, never a module surface.
No patterns, no style props. hstack, vstack, grid and friends have no equivalent; build them as styled components or templates once. And <styled.div bg="red"> has no equivalent either — Salty's css-* props are for prop tokens you declared, not for arbitrary CSS properties at the call site.
| Panda CSS 1.11 | Salty CSS |
|---|---|
css({ … }) | className({ base: { … } }) |
cva({ base, variants, … }) | styled(el, { base, variants, … }) |
styled.div from styled-system/jsx | styled("div", …) from @salty-css/react/styled |
<styled.div bg="red"> style props | No equivalent — declare a variant or a {props.x} token |
theme.tokens | defineVariables |
theme.semanticTokens | defineVariables → conditional |
color: "brand.500" | color: "{colors.brand.500}" |
_hover, _focus | "&:hover", "&:focus" |
_dark | conditional.theme + data-theme, or a dark media query |
defineRecipe / defineSlotRecipe | styled variants / defineTemplates |
Patterns (hstack, vstack, …) | No equivalent — compose your own |
panda.config.ts | salty.config.ts |
panda codegen → styled-system/ | No codegen; saltygen/ is CSS output |
| PostCSS plugin | Vite / Webpack / Next plugin |
@layer reset, base, tokens, recipes, utilities | @layer imports, reset, global, templates, fonts, l0…l8 |
import { cva } from "../styled-system/css";
export const button = cva({
base: { padding: "0.6em 1.2em", borderRadius: "md" },
variants: {
tone: {
solid: { bg: "brand.500", color: "white" },
ghost: { bg: "transparent", color: "brand.500" },
},
},
defaultVariants: { tone: "solid" },
});
// <button className={button({ tone: "ghost" })} />import { styled } from "@salty-css/react/styled";
export const Button = styled("button", {
base: { padding: "0.6em 1.2em", borderRadius: "{radii.md}" },
variants: {
tone: {
solid: { background: "{colors.brand.500}", color: "white" },
ghost: { background: "transparent", color: "{colors.brand.500}" },
},
},
defaultVariants: { tone: "solid" },
});
// <Button tone="ghost" />The recipe object barely moved. What moved is that cva hands back a class-name function and Salty hands back a component — if you'd rather keep the function shape, className is the direct analogue and takes the same options.
Linaria 8
Mechanically the closest relative on this page. Both libraries evaluate modules at build time to extract CSS, and both carry dynamic prop values into the browser on CSS custom properties rather than by generating new rules. If your Linaria codebase already respects "no side effects in modules imported into styles," your .css.ts files will be well-behaved for exactly the same reason.
Note the version specifics: Linaria 8 runs on wyw-in-js 2, requires Node ≥ 22.12, and uses @wyw-in-js/babel-preset rather than the old @linaria/babel-preset. Salty needs Vite or Webpack, which is a narrower bundler surface than wyw-in-js supports — worth checking first if you're on esbuild or Rollup directly.
| Linaria 8 | Salty CSS |
|---|---|
styled.h1`…` | styled("h1", { base: { … } }) |
css`…` from @linaria/core | className({ base: { … } }) |
${props => props.color} | "{props.color}" + css-color prop |
Composition via cx / extra classes | variants as typed props |
| Imported JS constants for tokens | defineVariables with validated {token.path} refs |
| Hand-rolled CSS variables for theming | conditional.theme + data-theme |
| Any file the transform touches | Files matching a Salty suffix |
@linaria/atomic | No equivalent |
import { styled } from "@linaria/react";
const Title = styled.h1`
font-size: 2rem;
color: ${(props) => props.color};
&:hover {
text-decoration: underline;
}
`;import { styled } from "@salty-css/react/styled";
export const Title = styled("h1", {
base: {
fontSize: "2rem",
color: "{props.color}",
"&:hover": { textDecoration: "underline" },
},
});
// <Title css-color="palevioletred">…</Title>Both of those compile to var(--…) and a single static rule. The difference is who declares the contract: Linaria infers the variable from the interpolation, Salty asks you to name it in the style and hands you a typed css-color prop at the call site in exchange.
The other difference is scope. Linaria stops at the styling primitive; the system layer above it — variants, tokens with validated paths, templates, named media queries, themes — is left to plain JavaScript. Salty ships that layer. So if you've already built one yourself, budget time for porting it rather than for porting the styles.
Atomic / utility-first CSS
Tailwind CSS 4.3
This is the largest conceptual distance on the page, because the unit of styling changes. You stop describing an element as a list of utilities at the call site and start describing a component as a style object with typed variants. That's a re-architecture, and no mapping table makes it a find-and-replace.
Read the honest fit check first: if you're building lean, repetitive, utilitarian UI — dashboards, admin panels, internal tools — where the absolute smallest stylesheet is the priority, atomic CSS is a genuinely good architecture for that and I'd point you toward it without hesitation. Salty is aimed at design-led, dynamic sites, where forty utility classes per element becomes a real cognitive tax and deeply nested, context-dependent styling is the everyday case.
One thing converged, though: Tailwind v4's CSS-first @theme block already compiles design tokens to CSS custom properties. defineVariables does the same thing from TypeScript. Token migration is close to a rename.
| Tailwind 4.3 | Salty CSS |
|---|---|
Utility classes in className | Declarations in base |
@theme { --color-brand-500: … } | defineVariables → the same custom properties |
hover:, focus:, disabled: | "&:hover", "&:focus", "&:disabled" |
md:, lg: | Named defineMediaQuery → "@tabletUp" |
dark: | conditional.theme + data-theme, or a dark media query |
@apply | defineTemplates |
@utility | defineTemplates, or a modifier for value rewrites |
Arbitrary values — w-[37px] | width: 37 (with defaultUnit) or "37px" |
clsx / tailwind-merge | variants as typed props |
@source scanning | Filename suffix + the module graph |
<button
className={clsx(
"rounded-md px-5 py-2 text-sm font-medium transition-colors",
primary
? "bg-brand-500 text-white hover:bg-brand-600"
: "bg-transparent text-brand-500 hover:bg-brand-50",
)}
>import { styled } from "@salty-css/react/styled";
export const Button = styled("button", {
base: {
borderRadius: "6px",
padding: "0.5rem 1.25rem",
fontSize: "0.875rem",
fontWeight: 500,
transition: "background-color 150ms",
},
variants: {
primary: {
true: {
background: "{colors.brand.500}",
color: "white",
"&:hover": { background: "{colors.brand.600}" },
},
false: {
background: "transparent",
color: "{colors.brand.500}",
"&:hover": { background: "{colors.brand.50}" },
},
},
},
});
// <Button primary>Save</Button>The conditional string-building disappears, and with it the merge utility whose job was deciding which of two conflicting bg- classes wins. primary is a typed boolean prop and the conflict is resolved at build time instead. The cost is the one named above: a stylesheet that grows with the number of distinct components rather than the number of distinct declarations.
On running both at once. You can, and for a migration of this size you probably should. Both put their output in cascade layers, so they sort rather than fight. Be deliberate about the order, though — layers are ordered by first appearance in the document, so whichever stylesheet the browser encounters last has its layers sorted last, and later layers win ties at equal specificity. Which stylesheet you import first is a real decision, not a formatting one. Use priority when a specific Salty rule needs to beat a utility.
Preprocessors and plain CSS
Sass 1.102 (and CSS Modules)
The most mechanical migration on the page and the lowest-risk one. Most of it is a syntax transformation you can do file by file, and nothing in a Sass codebase depends on a runtime you have to unwind.
One thing genuinely changes: scoping. Sass gives you organisation, not isolation — nothing stops you writing .card .title { … } and reaching into another component's markup. Salty hashes every component, so cross-component selectors stop being available, and composition happens through components, tokens and templates instead. That's the part that takes rethinking; the rest is typing.
Worth naming the upgrade too, because it's easy to read tokens as a lateral move. Sass $variables are inlined at compile time and gone from the output. Salty tokens compile to real CSS custom properties, which means they stay live in the cascade — and that's precisely what makes attribute-driven theming possible at all.
| Sass 1.102 | Salty CSS |
|---|---|
.scss partials | *.css.ts files |
$brand: #0070f3 | defineVariables → {colors.brand}, a real custom property |
@mixin / @include, no args | defineTemplates, applied with true |
@mixin with args | A function template, or a plain TS function returning a style object |
@use / @forward | import / export |
& nesting | & nesting, in objects |
@media (min-width: 768px) | defineMediaQuery → "@tabletUp" |
color.adjust / color.scale | The color() helper |
@each / @for | Plain TypeScript |
kebab-case properties | camelCase keys |
| Global by default | Hashed per component |
CSS Modules Foo.module.css | foo.css.ts with styled() or className() |
@use "./tokens" as *;
@mixin interactive {
cursor: pointer;
transition: background 150ms ease;
&:focus-visible {
outline: 2px solid $focus-ring;
outline-offset: 2px;
}
}
.button {
@include interactive;
padding: 0.6em 1.2em;
background: $brand;
color: $paper;
}import { defineTemplates } from "@salty-css/core/factories";
export default defineTemplates({
interactive: {
base: {
cursor: "pointer",
transition: "background 150ms ease",
"&:focus-visible": {
outline: "2px solid {colors.focusRing}",
outlineOffset: "2px",
},
},
},
});import { styled } from "@salty-css/react/styled";
export const Button = styled("button", {
base: {
interactive: true,
padding: "0.6em 1.2em",
background: "{colors.brand}",
color: "{colors.paper}",
},
});A mixin that takes arguments becomes a function template — same idea, but the parameter is typed, so a bad call is a compile error at the call site rather than Sass quietly emitting something odd.
CSS Modules specifically is the least eventful version of this. Rename Foo.module.css to foo.css.ts, rewrite each rule as a styled() or className() call, update the import. You already had scoping, so nothing about the mental model changes; you're trading a generated class-name object for typed components.
A sequence that works
Roughly the order I'd do it in, for any of the above:
- Install alongside. Plugin,
salty.config.ts, importsaltygen/index.cssat the root. Delete nothing. - Port tokens first. Colours, spacing, type scale into
defineVariables. Everything downstream references them, so this is the file the rest of the migration leans on. - Set
strict: 'warn'while you work, so a mistyped token path is a warning rather than a failed build mid-port. Flip it totrueat the end. - Port leaf components before containers. A button, a badge, an input. Leaves have the fewest inbound style dependencies and give you the fastest read on whether your token names hold up.
- Do globals and the reset deliberately. It's the one place the old and new stylesheets genuinely compete, and it's easier to reason about once the components are settled.
- Remove the old library when its last import is gone — not before.
Every step there ships. That's the point of the ordering.