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

→ For style objects that do not exist until the request runs — and the security posture that comes with them.

Runtime styles

Salty is a build-time library, and runtime styles don't change that. defineRuntime is a small, deliberate opening in the wall: a request-time helper that takes a style object and hands back a { className, css } pair you render into the page. The difference from everything else in Salty is that the input doesn't have to exist when the project compiles — it can come from a database row, a CMS field, or whatever a person saved in a settings panel three minutes ago.

Worth being straight about what this is not for, because the name invites the wrong idea: it isn't a way to move style generation off the build and into your requests. The static stylesheet stays the fast path, and anything you can express at build time should stay there. What runtime styles buy you is the ability to ship customization that genuinely depends on the request — that's a product feature you either can or can't offer, not a performance decision.

One warning up front, since it's the kind that gets skipped: styles that arrive as data are untrusted input, and CSS is less harmless than it looks. Salty does not sanitize what you hand the parser — that's a deliberate choice, covered in Security near the bottom. Read it before you wire this to a public text field.

When you reach for it

Most of the time you don't, and that's the intended outcome. Four rungs, and you should stop climbing at the first one that fits:

  1. The styles are known when the project compiles. styled or className. This is the everyday choice and it covers almost everything.
  2. The shape is known, one value isn't — a tint, a progress width, a stagger index. That's a prop token: {props.X} in the style, css-x at the call site, one static rule and a CSS variable carrying the value. See Dynamic Values.
  3. The style object itself doesn't exist until the request runs — a CMS block author dropped in a custom payload, a profile row carries a layout, a tenant config supplies brand overrides. There's nothing for the compiler to extract, because the rule doesn't exist yet. That's this page.
  4. It changes per frame in the browser — a cursor-following gradient, a drag offset. Use a plain style prop. defineRuntime is a request-time helper, not a per-frame one.

The two cases that actually drove this feature, so you can pattern-match against your own:

Profile customization. You want to give people real control over their own corner of your product — a profile, a portfolio page, a small site inside your site. Named looks you designed are one thing (that's theming), but "pick your own colors and spacing and let it be yours" isn't enumerable at build time. Runtime styles are how the module components on that page get styled per person.

One-off overrides in a systematic layout. You've built a page out of six block components, everything is consistent, and then the client or the designer wants this one section to be different. Not a new variant anybody will use twice — one deliberate deviation, in one place. Runtime styles let you make it without polluting the component with a variant that exists for a single URL.

Why not just an inline style

Because an inline style is a flat list of declarations, and that's all it will ever be. It can't hover. It has one job, in one place, and no chance to hit that Sacred Flame to help the party (sorry Shadowheart). No breakpoints, no reaching a child element, no reacting to a data- attribute.

defineRuntime runs the same parser as the build-time compiler, so what you hand it is a real Salty style object: nested selectors, &:hover, media queries, tokens, templates. And it produces a real scoped rule with a real class, not a style attribute — so it participates in the cascade like CSS instead of sitting on top of it. That's the entire argument for this API over style=undefined, and it's most of what makes the profile case workable.

Example 1 — The loop

Set up a runtime once, resolve a style object per request, render the class and the CSS together.

How you define it

/lib/runtime.ts
// a regular .ts file, not .css.ts
import { defineRuntime } from "@salty-css/react/runtime";
import config from "../salty.config";

export const runtime = defineRuntime(config);

Two things are load-bearing here.

It's a plain .ts file. defineRuntime isn't a define* factory the compiler collects — there's nothing to extract, since the CSS is generated when your server code runs. Calling it from a .css.ts file doesn't error; it just quietly does nothing useful, which is worse. Keep it out of there.

Passing config is what connects this to your design system. The runtime reads variables, templates, mediaQueries and modifiers from whatever you hand it, so an incoming {colors.brand.main} resolves to the exact same CSS variable your styled components already reference, and @tabletUp resolves to the same breakpoint they use — same names, same values, one source of truth. Skip the config and {colors.brand.main} still compiles to var(--colors-brand-main); it just points at a variable nothing declared, which renders as nothing at all. defineRuntime() with no argument is fine when the incoming styles reference nothing from your system.

How you use it

resolve gives you the pair. Render the CSS in a <style> tag next to the element and put the class on it:

Example
// /app/blocks/promo.tsx — a React Server Component
import { runtime } from "@/lib/runtime";

export async function Promo({ styles, children }: { styles: object; children: React.ReactNode }) {
  const { className, css } = await runtime.resolve(styles);

  return (
    <section className={className}>
      <style>{css}</style>
      {children}
    </section>
  );
}

In Astro it's the same call in the frontmatter, then <style set:html={css} /> on a wrapping element — with one caveat about set:html in the security section.

What comes out

Hand it a style object and you get back a hash and a scoped rule:

Example
await runtime.resolve({
  background: "{colors.brand.main}",
  padding: "2rem",
  "&:hover": { filter: "brightness(1.1)" },
});
Example
/* illustrative — the real hash is generated */
.dLwKs { background: var(--colors-brand-main); padding: 2rem; }
.dLwKs:hover { filter: brightness(1.1); }

resolve defaults the scope to .${className}, which is what you want nearly every time. The class is a deterministic hash of the style object, from the same hashing the compiler uses — so two blocks with structurally identical styles produce the same class, and you can collapse them to one rule. More on that in Example 2.

The other two members are there for the times resolve is too opinionated. runtime.css(styles, scope) takes an explicit scope when you need to target something that already exists — runtime.css({ color: "{colors.brand.main}" }, "#hero") emits a rule for #hero — and with no scope at all it returns unwrapped declarations you can stitch into a rule you're building yourself. runtime.className(styles) is the synchronous hash on its own, for when the CSS for that shape is already on the page and you only need the class to attach.

Example 2 — Profile customization

The flagship case. Someone picks their colors in a settings panel, you store the result, and their modules render in their look.

How you define it

The important move is that you build the style object, from fields you decided to expose. The person's data fills in slots; it doesn't arrive as a style object:

/components/profile-module.tsx
import { runtime } from "@/lib/runtime";

type ProfileStyle = {
  bg: string;
  text: string;
  accent: string;
  radius: string;
};

export async function ProfileModule({
  style,
  children,
}: {
  style: ProfileStyle;
  children: React.ReactNode;
}) {
  const { className, css } = await runtime.resolve({
    background: style.bg,
    color: style.text,
    borderRadius: style.radius,
    padding: "1.5rem",
    border: "1px solid {colors.grey.light}",

    // nested selectors, exactly as in a styled component
    "& a": { color: style.accent, textDecoration: "underline" },
    "& h2": { color: style.accent },

    // states — the thing an inline style can't do
    "&:hover": { boxShadow: "0 2px 12px rgb(0 0 0 / 0.12)" },

    // and your named breakpoints, by the same names your components use
    "@tabletUp": { padding: "2.5rem" },
  });

  return (
    <section className={className}>
      <style>{css}</style>
      {children}
    </section>
  );
}

Notice what's mixed in: {colors.grey.light} is your token, sitting in the same object as the person's chosen accent color. The parts of the design you still own stay owned.

How you use it

Example
const profile = await getProfile(handle); // your data layer

<ProfileModule style={profile.style}>
  <h2>{profile.title}</h2>
  <p>{profile.bio}</p>
</ProfileModule>;

The habit worth copying

Don't pass a raw object from your database straight to resolve. Map it, field by field, into a shape you wrote. It costs you a type and a few lines, and it buys three things: a much smaller security surface (see below), the freedom to redesign later without every saved profile turning into a broken layout, and an actual answer when someone asks what a "profile style" can contain.

Dedupe when a page renders many of these. Because the class is a hash of the object, a hundred modules sharing one look are a hundred identical <style> tags unless you say otherwise. Collect the pairs as you build the page, key them by className, and emit each unique rule once — runtime.className(styles) is the cheap synchronous way to get the key when you already know the CSS is on the page.

And if the looks are a fixed set you designed, stop — this isn't your API. Five named profile themes are a conditional variable group and one attribute in the markup, with zero per-request work and no security story at all. Theming covers that, and it's the better tool whenever the answer is enumerable. Runtime styles are for the values you genuinely cannot list ahead of time.

Example 3 — A one-off override in a systematic layout

Forty sections, six block components, everything uniform — and then section twelve needs a dark background and tighter spacing, once, forever. The usual escapes are a variant nobody will reuse, or a global stylesheet with an nth-child selector in it that will outlive everyone involved. Runtime styles give you a third option: resolve the deviation for that one block instance.

How you define it

Let the block carry an optional override, and resolve it only when there is one:

/app/blocks/section-block.tsx
import { runtime } from "@/lib/runtime";
import { Section } from "./section.css"; // your normal styled component

export async function SectionBlock({ block }) {
  const override = block.styleOverride
    ? await runtime.resolve(block.styleOverride)
    : null;

  return (
    <Section className={override?.className}>
      {override && <style>{override.css}</style>}
      {/* …block content, rendered by the same components as every other section */}
    </Section>
  );
}

How you use it

The override lives with the content, in whatever shape your CMS or config gives you:

Example
{
  type: "section",
  styleOverride: {
    background: "{colors.black}",
    color: "{colors.grey.light}",
    paddingBlock: "3rem",
    "@desktopUp": { paddingBlock: "4rem" },
  },
  // …
}

Note the @desktopUp in there. Named breakpoints work in a runtime payload exactly as they do in a styled component — define them once with defineMediaQuery, reference them by @name — which means the people or systems writing overrides get your breakpoint vocabulary instead of guessing pixel values. Same failure mode as at build time, too: if a definition never reaches the build, the name silently emits an at-rule that never matches.

The block component is untouched. Every other section still renders the plain Section, and the deviation is data — visible where the content lives, deletable in one edit, and not a permanent new axis in your component API.

Two notes on how this lands in the cascade. First, styled components accept a className at the call site like any React element, which is how the runtime class rides along next to the generated hash. Second — and this is the part that makes overrides actually work — runtime CSS is emitted without a cascade layer. Salty's own rules all live in layers, and in native CSS an unlayered rule outranks every layered one regardless of specificity. So the override wins over the component's own styles with no specificity fight, no priority bump, and no !important.

That's exactly what you want for a deliberate one-off, and exactly why you shouldn't reach for this casually: a runtime rule sits above your entire layer hierarchy by construction. It's a trump card, not a tiebreaker.

If you'd rather target something that already has a selector — a global id, an element you don't wrap — use runtime.css(styles, "#hero") and skip the class entirely.

What comes along, and what doesn't

Because it's the same parser, most of Salty's authoring surface works unchanged. The exceptions are the things that need either a component wrapper or a place in the static stylesheet — defineRuntime returns strings, not components, and its output lives in a per-request <style> tag.

FeatureWorksNotes
Tokens ({colors.brand})yesPass the matching variables in config.
Media queries, raw and named (@media (…), @tabletUp)yesScoped under the resolved class. Named queries resolve through the config you pass.
Modifiers and pseudo-classes (&:hover, &[data-state])yesAmpersand expansion is identical to the build-time parser.
Nested selectors ("& > svg")yesCombinators are appended to the scope class.
Templates (textStyle: "caption")yesPass the matching templates in config.
variants / compoundVariants / anyOfVariantspartialThe selectors are emitted, but there's no prop-to-class mapping here. Pick the active branch yourself and pass a flat object.
defaultVariants, defaultProps, passProps, element, asnostyled() component concepts. There's no component.
keyframes, defineGlobalStyles, defineFontnoBuild-time only — they belong in the generated stylesheet, not a per-request tag.

The variants row is the one that surprises people: hand the parser a full variants object and it faithfully emits every branch, because it has no idea which one you meant. resolve(myVariants[active]) is the fix.

Security — a note before you ship it

Salty does not sanitize what you pass to defineRuntime. Property values go through to the browser as text. That's deliberate rather than an oversight: a sanitizer strict enough to be genuinely safe would also block a good share of what people legitimately want this API for, and one loose enough to stay out of the way would mostly sell you false confidence. So the validation boundary is yours — which is the right place for it, because you're the one who knows whether a payload came from a colleague or from an anonymous sign-up form.

The thing worth internalizing is that a style object is more powerful than it looks. CSS can reach the network, cover your interface, and — if a payload escapes the <style> element it was meant to live in — stop being a styling problem at all. The specific tricks come and go; the posture doesn't:

  • Build the style object yourself from validated fields, as in Example 2, and allowlist what you support. This one habit removes most of the problem.
  • Validate where data enters your system — the CMS, the settings form, the API — rather than at render time.
  • Watch the paths around the CSS. Raw-HTML injection (Astro's set:html) and untrusted text interpolated into a scope string are how a styling bug turns into something worse.
  • Know which threat model you're in. Trusted editors on an internal CMS and public user profiles are wildly different risks behind the same API.

The Security page is where this gets covered properly. If you're building the profile-customization case, treat it as required reading rather than an appendix.