Version 0.4.0 now released! See the release notes on GitHub Releases

→ For the stacks Salty doesn't ship wiring for — which is most of them, and mostly fine.

Framework agnostic APIs

Salty CSS ships wiring for a few setups out of the box — Next.js, Astro, and React on Vite or Webpack. This guide is for the case where yours isn't one of them: a Vue app, a Svelte project, Angular, or a stack nobody has pointed Salty at yet. The short version is that you're probably fine, and the part you lose is smaller than you'd expect.

The reason is that almost everything you write in a .css.ts file has no idea which framework you picked. Tokens, templates, media queries, fonts, keyframes, global styles, helpers, class names: all of it is TypeScript that runs in Node while your project builds, and its only output is text in a stylesheet. None of it renders anything, so none of it needs to know how your framework renders.

Yes, React is listed as a framework next to Next.js and Astro. I'm aware. Plain React on Vite works well enough that it needs a seat at the table, and the menu only has so many slots.

The one genuine exception is styled, and it's worth getting out of the way first: a component factory has to hand back a real component in your framework's own shape, and there's no universal JSX-shaped function that covers React, Astro, Vue, Svelte and Angular at once. So Salty publishes a styled per supported framework, and everywhere else you author with className instead.

Which makes the actual requirement much narrower than a list of frameworks: Salty needs Vite or Webpack. If your build runs on one of those, the answer is yes.

This guide is the authoring story — what you write, and what changes when styled isn't available. For the support matrix itself, which versions are tested and what's known to work, Compatibility is the page.

Where the line falls

The split isn't really "React things" and "other things" — it's render time versus build time.

styled is the only API that produces something your framework has to render. Everything else produces text: a custom property, a keyframes rule, a media query, a class name. Text doesn't care what put it on the page.

What you're doingAPIImport from
A class string with the full styling surfaceclassName@salty-css/core/class-name
Design tokens, themesdefineVariables@salty-css/core/factories
Reusable style bundlesdefineTemplates@salty-css/core/factories
Document-level styles and resetsdefineGlobalStyles@salty-css/core/factories
Named media queriesdefineMediaQuery@salty-css/core/factories
Fonts and external CSSdefineFont, defineImport@salty-css/core/factories
Project configurationdefineConfig@salty-css/core/config
Build-time color math, fluid sizingcolor, defineViewportClamp@salty-css/core/helpers
Animationskeyframes@salty-css/core/css
Styles that only exist at request timedefineRuntime@salty-css/core/runtime
A typed component with variant propsstyled@salty-css/react/styled, @salty-css/astro/styled

One note if you've been reading the React docs: those pages import several of these from @salty-css/react/*@salty-css/react/config for defineMediaQuery, @salty-css/react/keyframes, and so on. Those subpaths are straight re-exports of the core ones, unchanged, so either import resolves to the same function. Core is the one to reach for when React isn't in the picture, and it's the honest home for all of them.

The real requirement: Vite or Webpack

Salty's build plugin does two jobs, and both of them are bundler jobs rather than framework jobs.

The first runs once when the build starts: find every file matching a Salty suffix, evaluate it, and write the whole project's CSS to saltygen/index.css. The second hooks the module loader, so each .css.ts file gets rewritten on its way through the bundler — the style objects you authored are replaced by the class names the compiler just produced. In dev, a third piece watches those files and regenerates as you type.

Loader hook, watcher, build-start hook. That's a bundler's vocabulary, which is why the supported-framework list is really a list of bundlers wearing different hats: @salty-css/next wraps Webpack and Turbopack, @salty-css/astro is a Vite plugin plus the Astro integration, and @salty-css/vite and @salty-css/webpack are the plain versions of the same thing.

So the question to ask about your own stack isn't whether your framework is on a list. It's whether your dev server runs on Vite or Webpack — and most of the current generation of meta-frameworks build on Vite, while plenty of long-lived apps still build on Webpack. Astro was supported before it had its own package for exactly this reason: it's Vite underneath, so the Vite plugin already worked.

I've also had this running inside an Angular app, which meant writing CSS-in-JS in Angular — that's a sentence you don't hear often, even though Angular is all about type safety. It shipped on TypeScript from day one while the rest of the ecosystem took years to come around — and it's the reason I know TypeScript as well as I do. Shoutout to Angular.

Wiring the plugin

For Vite:

vite.config.ts
import { defineConfig } from "vite";
import { saltyPlugin } from "@salty-css/vite";

export default defineConfig({
  plugins: [saltyPlugin(__dirname)],
});

For Webpack — note that saltyPlugin mutates your config object in place rather than returning a new one:

webpack.config.js
const { saltyPlugin } = require("@salty-css/webpack");

const config = {
  // your existing webpack config
};

saltyPlugin(config, __dirname);

module.exports = config;

npx salty-css init can do the Vite edit for you. Webpack is a manual edit today.

The rest of the setup

Four things, none of them framework-shaped:

  1. Install the core package and your bundler's plugin: npm i @salty-css/core @salty-css/vite (or @salty-css/webpack).
  2. Create salty.config.ts in the same folder as your bundler config.
  3. Import the generated stylesheet once at your app entry, from whatever global CSS file you already load: @import "../saltygen/index.css";.
  4. Build once — run your dev server, or npx salty-css build — so saltygen/ exists before the first render.
salty.config.ts
import { defineConfig } from "@salty-css/core/config";

export const config = defineConfig({
  // variables, templates, modifiers go here as you grow
});

From here on, nothing on this page is specific to anything. It's the same API surface the framework docs describe, minus one function.

Authoring with className

className gives you the entire styling surface — variants, nesting, pseudo-classes, tokens, media queries, templates — and hands back a class string instead of a component. That's the trade: you keep your own markup, and you apply the class yourself.

How you define it

/styles/button.css.ts
import { className } from "@salty-css/core/class-name";

export const buttonClass = className({
  base: {
    display: "inline-flex",
    alignItems: "center",
    gap: "0.5em",
    padding: "0.6em 1.2em",
    borderRadius: "6px",
    border: "1px solid transparent",
    cursor: "pointer",
    "&:hover": { filter: "brightness(1.1)" },
    "&:disabled": { opacity: 0.5, pointerEvents: "none" },
  },
  variants: {
    tone: {
      solid: { background: "{colors.brand.main}", color: "{colors.paper}" },
      ghost: { background: "transparent", borderColor: "currentColor" },
    },
    size: {
      small: { fontSize: "0.85rem" },
      large: { fontSize: "1.15rem" },
    },
  },
});

How you use it

The export is a string. Put it wherever your framework puts class names — a class attribute, a binding, a setAttribute call, a template literal alongside classes from somewhere else.

Example
import { buttonClass } from "./styles/button.css";

// no variants — just the base class
buttonClass; // → "eyjPN"

// variants are chained, and each call returns a new instance
buttonClass.variant("tone", "solid").variant("size", "large");
// → "eyjPN tone-solid size-large"

Two things about that value worth knowing before they surprise you.

It's a String object rather than a string primitive, so it carries the .variant() method around with it. Almost everywhere that's invisible — it concatenates, interpolates and compares as text like anything else. But if something in your stack does a strict typeof x === "string" check, coerce it first with a template literal or String(...).

And defaultVariants is accepted in the options object, but className does not apply it for you. .variant() is the only thing that appends classes, so a call with no chain gets you the base class and nothing else. When you want defaults, wrap the class in a small function:

/styles/button.css.ts (continued)
export const button = ({
  tone = "solid",
  size = "small",
}: {
  tone?: "solid" | "ghost";
  size?: "small" | "large";
} = {}) => buttonClass.variant("tone", tone).variant("size", size);
Example
button();                  // → base + tone-solid + size-small
button({ tone: "ghost" }); // → base + tone-ghost + size-small

That wrapper is also the natural seam for a component in your framework: it already has the prop names, the defaults and the types. What it renders is up to you.

The rest of the system

Everything a design system needs comes out of core, and none of it changes shape here. Define once, reference from anywhere.

/styles/variables.css.ts
import { defineVariables } from "@salty-css/core/factories";

export default defineVariables({
  colors: {
    brand: { main: "#0070f3" },
    ink: "#101014",
    paper: "#fbfbfd",
  },
  conditional: {
    theme: {
      light: { bg: "{colors.paper}", text: "{colors.ink}" },
      dark: { bg: "{colors.ink}", text: "{colors.paper}" },
    },
  },
});
/styles/system.css.ts
import { defineMediaQuery, defineTemplates } from "@salty-css/core/factories";
import { keyframes } from "@salty-css/core/css";
import { defineViewportClamp } from "@salty-css/core/helpers";

export const tabletDown = defineMediaQuery((media) => media.maxWidth(900));

export const fhdClamp = defineViewportClamp({
  screenSize: 1920,
  minMultiplier: 0.6,
  maxMultiplier: 1.2,
});

export const fadeIn = keyframes({
  animationName: "fadeIn",
  params: { duration: "200ms", easing: "ease-out" },
  from: { opacity: 0 },
  to: { opacity: 1 },
});

export default defineTemplates({
  textStyle: {
    body: { fontSize: "1rem", lineHeight: 1.5 },
    heading: { fontSize: fhdClamp(48), fontWeight: 700, lineHeight: 1.1 },
  },
});

Then consume all of it from a class, exactly as a styled component would:

/styles/panel.css.ts
import { className } from "@salty-css/core/class-name";
import { fadeIn } from "./system.css";

export const panel = className({
  base: {
    textStyle: "body",
    background: "{theme.bg}",
    color: "{theme.text}",
    padding: fhdClamp(32),
    animation: fadeIn,
    "@tabletDown": { padding: "1rem" },
  },
});

Theming deserves a specific mention, because it's the part people assume must need a provider. It doesn't — anywhere. A conditional token group compiles to [data-theme="dark"] { --theme-bg: … } in the static stylesheet, so switching themes means setting one attribute on an ancestor element and letting the browser's own cascade repaint. No context, no re-render, no library code in the loop. Whatever sets that attribute in your framework is the entire integration.

What you give up without styled

Worth being straight about the size of the gap, because it's smaller than it sounds and it's all in one place.

styled gives you a typed component whose variants are JSX props, consumed before they reach the DOM; element and as for swapping the rendered tag; defaultProps and passProps for the HTML attribute plumbing; and the ability to extend an existing Salty component, which also bumps the extension into the next cascade layer so its rules win without a specificity fight.

Every item on that list is component ergonomics. None of it is CSS. The stylesheet you get from className is the same stylesheet — same hashing, same layers, same tokens, same output file. What you're missing is the wrapper that maps props to variants, and that's the small function from the section above plus however your framework declares a component.

If you want to go further than that, core does export the generator classes the framework packages are built on — that's how @salty-css/react/styled and @salty-css/astro/styled are put together. It's unsupported territory and the shape can move between releases, but the door isn't locked.