12 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. 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 } = props;
  return (
    <div css={{
      ...Css.df.gap1.$,
      ...(someBoolean ? Css.bgRed.$ : Css.bgGreen.$),
    }}>
      <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 “codegen DSL on top of Emotion”, was a “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 (given that was our whole goal 😅) in terms of what we write as input:

function MyComponent(props) {
  const { someBoolean } = props;
  return (
    <div css={{
      ...Css.df.gap1.$,
      ...(someBoolean ? Css.bgRed.$ : Css.bgGreen.$),
    }}/>
  );
}

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" />

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 any additional hashing or suffixing.

I.e. while StyleX turns the display: flex in stylex.create({ df: { display: 'flex' } }) call into a generated 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. 🎉

So we end up with class names like:

  • df for display: flex
  • gap1 for gap: 8px
  • bgRed for background-color: red
  • color_var for color: var(--color) (when dynamic values are used)

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

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 a 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 is more pragmatic; we want you do the safest/best thing, but we also have escape hatches for when you need them.

For the safest paved road, Truss provides the same “marker” approach as StyleX via our Css.when API:

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; }

A little less safe, are arbitrary selectors that are still static & global:

// In MyApp.css, while arbitrary selector is "whatever you need"
// and will be appended to the end of `truss.css`
export const css = {
  // Hover only the current child, not the other children
  ".parentMarker:hover:not(:has(.childMarker:hover)) .childMarker": Css.ba.bcBlue300.$,
}

And finally the “least safe”/whatever you want, are runtime selectors that are dynamic & transient:

// Inject a `style` tag an runtime with 100% dynamic selector
useRuntimeStyle({
  [`@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/reducing our stack’s tech debt, of relying on older techniques.

But, buildtime CSS-in-JS is popular for a reason 😅, and we did see some nice 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.

(Besides the LLM shout-out, again huge thanks to the StyleX team for their innovative approach; as we’ve mentioned multiple times above, Truss v2 is basically a StyleX clone that better fits our goals/ergnomics.)