14 min read

Truss v2: Emotion to StyleX to Vite

Table of Contents

This post covers migrating our CSS-in-JS library, Truss, from Emotion to a custom build-time solution, inspired by StyleX, implemented as a custom Vite plugin that dropped our app’s LCP/JS init by 50%. If you want to jump straight to “what makes Truss great?”, you can jump to that section.

Truss Overview

Truss is a niche CSS-in-JS library that we built at Homebound, when originally setting up our stack circa 2019/2020.

Despite (or maybe “because of” 😅) being “just for us”, Truss has served us well over the years, powering our frontend codebases with a succinct, “just works” CSS-in-JS styling solution, historically powered by Emotion under the hood.

Here’s a brief overview of the syntax, an inline, atomic/utility, TypeScript DSL:

function MyComponent(props) {
  const { someBoolean, xss } = props;
  return (
    <div css={{
      ...Css.df.gap1.$,
      ...(someBoolean ? Css.bgRed.$ : Css.bgGreen.$),
      // Spread in "allowed overrides" from the caller
      ...xss,
    }}>
      <div css={Css.p1.ba.bcBlack.br2.cursorPointer.onHover.bcBlue.bgLightGray.$}>
        Border box with padding and radius
      </div>
      <div css={Css.bgBlue.white.p1.br2.cursorPointer.onHover.bgBlack.$}>
        Blue background with white text
      </div>
    </div>
  );
}

The goal, back in the day, was to be a “type-safe Tachyons”.

Of course, the correct reaction so far is “…why not Tailwinds?” 🤔, and the brief answer is that Tailwinds had not yet won the space when we started, and Truss being “just a small codegen DSL on top of Emotion” was sufficiently powerful and flexible for our needs at the time, that it was a good fit.

In particular, the fact the Truss’s Css.df.$ expressions are “just POJOs that you can spread/compose” became an integral part of our Beam component library, and we leverage that capability so much that it’s been hard to imagine migrating to anything else—not just in terms of effort, but less flexibility and ergonomics.

(See the Truss readme for more details.)

…but Emotion in 2026?

Unfortunately, while using Emotion was state-of-the-art at the time, that has definitely changed, and runtime CSS-in-JS libraries are generally shunned by the frontend community these days.

Build-time CSS is the new standard—Vanilla Extract, Linaria, Panda CSS, Pigment CSS, StyleX, and of course Tailwinds—are all build-time solutions that improve performance by going back to statically-served/generated stylesheets. No more “generate your style tags at runtime”. 👎️

Even more than just trendiness, although we personally are fans of the “boring SPA” approach to React, the React team’s push into Server Components / SSR has led them to actively discourage runtime CSS-in-JS solutions, such that I’ve been worried that some future React release would either hard-break or soft-break Emotion’s approach (arguably concurrent rendering already has). And we’d be stuck.

So we’ve known for awhile that we needed to do “something else” for Truss, i.e. swapping out Emotion for some other backend.

(A great aspect of Truss’s API is how declarative it is—after writing the Css expressions of Css.blue.$, Css.ifSm.black.$, etc., the user just doesn’t have to know/care how the CSS is actually applied under the hood.)

Attempt 1: Facebook’s StyleX

Of all the build-time solutions, Facebook’s StyleX seemed the best fit for our “next backend”.

In particular, StyleX’s approach of “cross-file/cross-library style composition via spreads & conditionals” (see the array spread on this page of their docs) was much closer to the Truss mental model (i.e. almost identical) than any of the previous build-time solutions we’d seen.

StyleX is also atomic/utility CSS under-the-hood 🎉, although only as a compilation implementation detail in the generated *.css files; for the actual authoring of styles, the team is intentionally “anti inline” (citing improved readability of “same file locality, but not in the markup”), and use a styled-components like API:

import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
  // Pick meaningful names here
  root: {
    width: '100%',
    maxWidth: 800,
    minHeight: 40,
  },
  child: {
    backgroundColor: 'black',
    marginBlock: '1rem',
  },
});

Which is fine, but not what we want for Truss.

Even though we didn’t like StyleX-the-API, we did really like StyleX-the-compiler, so we prototyped a version of Truss that, instead of “a tiny codegen DSL on top of Emotion”, was a “a tiny codegen DSL on top of StyleX”.

And it worked surprisingly well!

Basically our Css.ts file (which is code generated based on your project’s design system names/settings) ended up with a huge stylex.create, that defined all our abbreviations:

const css = stylex.create({
  df: { display: "flex" },
  dib: { display: "inline-block" },
  blue: { color: "blue" },
});

And then Our Css.db.blue.$ methods would turn into [css.df, css.blue] expressions that we would hand off to the stylex.props API (I’m handwaving a bit, for purposes of brevity—we had actually pivoted to per-file stylex.create generation to handle adhoc Css.onHover styling, but the approach was still basically the same).

We were initially committed to this “Truss on StyleX” approach, until we had a surprisingly seems-trivial-but-was-not blocker: StyleX uses arrays, instead of POJOs.

While both StyleX and Truss use “just JavaScript” to compose styles, Truss, with our Emotion history, uses object literals, like:

const style1 = Css.df.$;
const style2 = Css.blue.$;
// Object spread
const combined = {...style1, ...style2 };
return <div css={combined} />;

And StyleX uses arrays, like:

const css = stylex.create({
  df: { display: "flex" },
  blue: { color: "blue" },
});
// Array spread
const combined = [...css.style1, ...css.style2];
return <div { ...stylex.props(combined) } />;

This seems like a small/trivial difference, but we had a ton of object literal spreads across our codebase, and migrating them (either with a build-time plugin, or one-time codemod), without mistakes, ended up being more troublesome than we expected.

We spent several days having LLMs try to “rewrite object spreads ==> array spreads correctly”, without any false positives or negatives, and got annoying closely, but ultimately decided we were fighting against StyleX more than we were leveraging it.

And so we abandoned the “StyleX as a backend” approach.

Attempt 2: Bespoke Vite Plugin

While prototyping the StyleX backend, we had tried “rewrite the object literals to array literals” with a Vite build-time plugin.

It is surprising how easy it is to write Vite plugins with an LLM these days—it seems like ASTs are ripe for LLMs to “just know” what to do, assuming you have sufficient input/output test coverage (which are also easy test cases to create, no complex harness engineering required 😅).

And so a random idea of “…what if, instead of our Vite plugin rewriting Truss css={...} to StyleX creates, we just generate our css={...} directly to CSS…” became an LLM prompt.

And ~a few LLM prompts 🎲 later, here we are: Truss v2 does it’s very own CSS generation, very openly as a “mini-StyleX” / StyleX-clone. 🎉

Given input of:

function MyComponent(props) {
  return <div css={Css.df.gap1.$}>foo</div>;
}

We get output of:

function MyComponent(props) {
  return <div className="df gap1">foo</div>;
}
.df { display: flex; }
.gap1 { gap: 8px; }

The Truss Css API for normal, day-to-day styling is unchanged from Truss v1 (everything is still “just POJOs”), meaning the majority of our component library & application codebases “just work”. 🤯

In particular, our Beam component library migrated from Truss v1 (runtime Emotion CSS-in-JS) to Truss v2 (build-time CSS-in-JS) with zero pixel changes in our Chromatic/Storybook diffs. 🚀

Tbf, this did take some blood, sweat, & tears to accomplish, as several of our components used/abused Emotion’s “write whatever child selectors you want” capability in their implementations, which had to be re-thought with the build-time approach, but is still pretty amazing.

Truss v2 Overview

Let’s finally lay out with Truss v2 looks like…which is exactly what Truss v1 was before (that was our whole goal 😅) in terms of what we write as input:

function MyComponent(props) {
  const { someBoolean, xss } = props;
  return (
    <div css={{
      ...Css.df.gap1.$,
      ...(someBoolean ? Css.bgRed.$ : Css.bgGreen.$),
      // Spread in "allowed overrides" from the caller
      ...xss,
    }}/>
  );
}

But now the output is build-time generated CSS with class names:

.df { display: flex }
.bgRed { background-color: red }

And the DOM ends up being:

<div class="df gap1 bgRed" />

What else makes Truss unique?

Abbreviations => Class Names

The key insight of Truss v2, that most differentiates us from StyleX, is that our Tachyons-inspired abbreviations, i.e. df for display: flex are already unique and so can form the basis for our CSS class names, without hashing.

I.e. while StyleX turns the display: flex in stylex.create({ df: { display: 'flex' } }) into a hashed class name like x123abc to accommodate the global nature of CSS (and scale of Facebook), in Truss we already know that df is unique and unambiguous, so we can just use that as-is. 🎉

<div css={Css.df.gap1.bgRed.$} />
// .df { display: flex; }
// .gap1 { gap: 8px; }
// .bgRed1 { background-color: red; }  
// DOM: <div className="df gap1 bgREd" />

Of course this “abbreviation is the class name” is exactly what Tailwinds does 😅, so we don’t claim true novelty—but it is a key differentiator from StyleX.

And the DX of reading the DOM is just fantastic—much better than Truss v1, which used Emotion’s similarly-hashed class names.

First Class Dynamic Values and Composition

Truss’s output of Css.df.$ becoming … className="df" in the DOM can seem underwhelming at first; why not just write className directly?

Where Truss shines is at scale—it stays ergonomic, even as we do “more than just static classes”, and are adding dynamic values and composition.

Dynamic values are “just method calls”:

<div css={Css.color(getValue()).$} />
// css: .color_var { color: var(--color); }
// DOM: <div className="color_var" />
// Element style at runtime: { --color: getValue() }

Composing styles is “just object spreads”:

<div css={{
  ...baseStyles,
  ...getVariantStyles(variant),
  ...overrideStyles
}} />

Dynamically creating/combining styles is a first class notion—just spread—and not an after-thought that requires external libraries to munge class names.

And hovers, selectors, and media queries are all “chained modifiers” instead of entirely new abbreviations:

<div css={{
  // On hover change the text color
  ...Css.red.onHover.blue.$,
  // The sm breakpoint gets smaller margin/padding/font size
  ...Css.m2.p2.f16.ifSm.m1.p1.f12.$,
}} />
// css:
// .red { color: red; }
// .h_blue:hover { color: blue; }
// .m2 { margin: 16px; }
// .p2 { padding: 16px; }
// .ifSm_m1 { @media (min-width: 640px) { margin: 8px; } }
// .ifSm_p1 { @media (min-width: 640px) { padding: 8px; } }
// .ifSm_f12 { @media (min-width: 640px) { font-size: 12px; } }
// DOM: <div className="red h_blue m2 ifSm_m1 ifSm_p2 ifSm_f12" />

Truss’s “chained modifiers” (onHover, ifSm) handle the tedium of creating the “unique variation” of each atomic class name to trigger the behavior you need—without having to remember a new abbreviation for each one, and without having to repeat yourself.

Style Application Order is Solved

CSS application can be finicky with “which style/class wins”; based on either deterministic precedence rules, or non-deterministic “last one wins” source order rules, leading to debugging heartaches and !important hacks.

Truss v2 solves these by using StyleX’s priority system—shorthand (margin) and longhand (margin-top) properties are sorted to different buckets/priorities (i.e. margin-top is higher priority than margin), and then outputting the singular CSS file in this controlled over—deterministic results every time.

No React Wrapper Components

Because Truss v2 uses build-time JSX rewriting, we no longer use React’s jsxImportSource runtime setting, which Emotion used to “intercept” the css prop and generate style tags at runtime.

Besides just better client-side performance, this means no more EmotionWrapper React components cluttering the React component tree, which is another huge win for DX. 🎉

(Tangentially, React’s inability to provide the Emotions of the world an API that let them “intercept css prop and generate style tags” without littering wrapper components everywhere, which were required to access contexts/hooks, is imo indicative of how little the React team cared about runtime CSS-in-JS solutions.)

Multiple “Escape Hatches” for real-world CSS

StyleX is appropriately dogmatic for a library that must serve Facebook’s stringent performance goals/SLAs: they strictly limit what selectors/styling applications are allowed to do.

Truss v2 is more pragmatic; we want you do the safest/best thing, but we also have escape hatches for when you need them.

Hatch 0: Paved Road Selectors

As the safest way of “cross-element styling” (child styling based on parents, parents styling based on children), Truss uses the same “marker” approach as StyleX, where components still “own their own styles”, but can “react” to the pseudo-selector of another component.

In Truss, this is done via our Css.when API:

// Create a unique `._mrk_row` classname
const row = Css.newMarker();

// In the parent, denote 'I'm the marker'
return <div css={Css.marker(row).$} />;

// In the child, react to the parent being focused/hovered/etc.,
// i.e. turn blue when the parent is hovered
return <div css={Css.when(row, "ancestor", ":hover").blue.$} />;

// Class name ends up:
// - In the parent: `<div className="_mrk_row" />`
// - In the child: `<div className="wh_row_anc_h_blue" />`
// And the css rule is
// ._mrk_row:hover .wh_row_anc_h_blue { color: blue; }

The limitation here is that you’re not actually allowed to write the selector; Css.when gives you one of a few "ancestor" | "descendant" | "any-sibling" targets, and the ":hover" | ":focus" | ... pseudo-selector you want to react to, and then creates the selector for you.

This is a straight port/crib of the StyleX marker/when API, and the safest in terms of build-time output & not violating encapsulation (i.e. no “styling at a distance” where selectors “reach in” to other components & muck with their styles).

Hatch 1. Global, Static selectors

When the guardrails of Css.when are too strict, and you really want to “just write your own selector”, as long as that selector is static (known at build-time), Truss provides .css.ts files.

These are TypeScript files, but with a special export const css that is a map of “arbitrary selector” to “bag of styles”, i.e. much like a regular CSS file, but you can use Css expressions to create the “bag of styles”, given easy succinct to your normal design system tokens:

// In MyApp.css.ts, write arbitrary selectors as the keys, and each
// key/value of `selectorKey { ...valueStyles...}` will be appened to the
// truss.css output file.
export const css = {
  // Hover only the current child, not the other children
  ".parentMarker:hover:not(:has(.childMarker:hover)) .childMarker": Css.ba.bcBlue300.$,
  // Could also do CSS reset type declarations:
  body: Css.raw`
    margin: 0;
    padding: 0;
  `
}

Hatch 2: Transient, Dynamic Selectors

And finally the least performant, but most flexible escape, hatch is the useRuntimeStyle hook, which allows completely dynamic selectors:

// Injected to a `style` tag at runtime
useRuntimeStyle({
  // A selector key that is unknowable at build-time
  [`@container (min-width: ${minWidth}px) and (max-width: ${maxWidth}px) .marker`]: `grid-column: span ${span};`,
});

Ironically, this last useRuntimeStyle approach is exactly how Truss v1 worked with Emotion—we runtime inject a style tag, since we cannot know at build-time what the selectors are going to be.

Use it only as a last resort.

Performance Impact

It’s slightly embarrassing to admit, but performance was not actually the primary motivation for our migration—it was derisking our stack’s tech debt, by no longer relying on older/out-dated techniques.

But build-time CSS-in-JS is popular for a reason 😅, and we did see some great performance improvements. 🚀

Here is Chrome’s Performance tab results for simple dashboard page of our app, before the LCP was 1.2s and 1,500ms spent in JavaScript:

And after the LCP is 0.7 seconds, and 700ms in JavaScript:

Basically 50% less time in JavaScript on the initial page load. 🚀

Conclusion/Kudos to the LLMs

Wrapping up, that is the Truss v2 announcement/backstory.

What’s surprising is how quickly Truss v2 came together—we’ve been mulling “the next Truss backend” for years now, without being in rush b/c Emotion has been performing very well for us, and waiting for the right solution to emerge.

But the recent combination of:

  • a) StyleX coming on the scene, and specifically their PR exploring inline styles piquing our interest, and
  • b) The ability of LLMs to drive code from prototypes to spikes to full implementations,

Meant we went from “an itch to scratch” to “our codebases are running on an entirely new styling backend” in a few weeks of part-time, albeit slightly OCD, LLM jockeying.

And besides the LLM shout-out, even more thanks to the StyleX team for their rock-solid design & implementation decisions; without their inspiration, no amount of us slinging prompts at LLMs would have resulted in a successful outcome—Truss v2 really is just a StyleX clone that better fits our goals and ergonomics.