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:
dffordisplay: flexgap1forgap: 8pxbgRedforbackground-color: redcolor_varforcolor: 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.)