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.