Truss is a TypeScript DSL for writing utility CSS (think Tailwinds or Tachyons) in React/JSX.
Writing Truss code looks like:
import { Css } from "src/Css.ts";
function App() {
return (
<h1 css={Css.f24.black.$}>Truss v2</h1>
<p css={Css.bodyText.$}>This demo uses the Truss DSL.</p>
<div css={Css.df.gap1.$}>
<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>
);
}Which our Vite/esbuild plugins compile to production HTML output:
<h1 class="f24 black">Truss v2</h1>
<p class="f14 black">This demo uses the Truss DSL.</p>
<div class="df gap1">
<div class="pt1 pb1 pr1 pl1 bss bw1 bcBlack h_bcBlue br2 cursorPointer h_bgLightGray">
Border box with padding and radius
</div>
<div class="bgBlue h_bgBlack white pt1 pb1 pr1 pl1 br2 cursorPointer">Blue background with white text</div>
</div>And a static, build-time generated CSS file:
.df {
display: flex;
}
.black {
color: black;
}
.pt1 {
padding-top: 8px;
}
.bcBlack {
background-color: black;
}
.h_bcBlue:hover {
background-color: blue;
}-
Inline CSS-in-JS that is build-time compiled to a single static CSS stylesheet:
<div css={Css.mt1.black.$}>-><div class="mt1 black">- Zero runtime overhead for static styles 🚀
- Vite plugin emits a single
truss-(contenthash).css, for optimal caching and performance - Homebound's main 400k LOC React SPA has a 100kb uncompressed
truss.cssfile
-
Naturally use dynamic values:
Css.mt(someValue).$orCss.bgColor(maybe ? Palette.Black : Palette.Blue).$orCss.mt0.if(someCondition).mt4.$.- Still compiled to static/atomic CSS, with a lightweight runtime helper to apply dynamic values
- Basically we do what StyleX does 😇
-
Pseudo-selectors and breakpoints:
Css.white.onHover.black.$orCss.ifSm.mx1.$- Intentionally limited to "only styling yourself"--discourages "styling at a distance" that breaks encapsulation
- See the "Pseudo-Selectors" section below for rationale & escape hatches
-
Cssexpressions are "just POJOs" that natural compose<div css={{ ...Css.mt2.$, ...(someCondition ? Css.bgRed.$ : Css.bgGreen.$) }} />- The last-set value per property wins, i.e. not "the last class name"
- Extremely natural to build up complex styles with conditionals, view logic, etc.
-
Tachyons-inspired abbreviations for superior inline readability
- No long class names that compound into a "wall of text"
- No IDE plugins needed to make your JSX readable again 😅
- Consistent
FooBar->fbabbreviation pattern:justify-content: flex-startisjcfs- Easier to memorize/read
- See Why Tachyons
-
Configure your design system in Truss's configuration 🧑🎨
- Color palette, fonts, increments, and breakpoint 🎨s
- See example config and the "Customization" section below
- Use design tokens for scoped CSS variables, see Design Tokens.
@propertyand@keyframessupport for build time type-checking & pruning of unused keyframes, see Registering a token with@propertyand Keyframes.
-
Escape hatch to arbitrary/runtime selectors
- Truss is pragmatic about "sometimes you just need raw CSS" and supports it ergonomically
- Use our
useRuntimeStyle({ body: RuntimeCss.blue.$ })hook for runtimestyleinjection - Only applied when the component is mounted
-
Type-checking built in 💪
- No editor support or IDE extensions required for great DX
- Just regular TypeScript (...with code-generation & build-time Vite plugins)
And the elephant 🐘 in the room:
-
Why not Tailwinds?
- Our abbreviations are shorter 🩳
- Composing styles with POJO spreads instead of class name strings is more ergonomic
- Easier escape hatches to dynamic values & dynamic selectors
- "Modifier chains" of
ifSm.blue.p2.m2is more succinct than repeating class name modifiers - We started with Tachyons in 2019, missed the TW craze, and now AI is smart enough it doesn't matter 🧠
- We just like Truss better 🤷 😀
-
Why not StyleX?
- StyleX uses arrays/array spreads for runtime composition instead of objects, which did not work for our legacy Truss v1 codebases
- Too strict with no escape hatches for dynamic/transient CSS/selectors, which are rare but still occur
- ...but we heavily crib StyleX's atomic CSS priority system 🙏
Also see the "Why This Approach?" section for more rationale.
Truss uses your project's truss-config.ts to generate a src/Css.ts file with the configured abbreviations/design system in a TypeScript DSL.
This file exports a Css symbol that you use like:
import { Css } from "src/Css";
const css = Css.mx2.black.$;Where Css. signals "the start of your CSS expression", and .$ signals "the end of your CSS expression".
In between, you can chain as many abbreviations/methods as you want, and they will all be statically typed and compiled into atomic CSS classes at build time.
These expressions are rewritten to be "just plain objects":
// Input
const css = Css.mx2.black.$;
// Output
const css = { marginLeft: "ml2", marginRight: "mr2", color: "black" };When every value in the expression is static (no runtime variables or conditionals), the plugin resolves the class names at build time with zero runtime overhead:
// Input
return <div css={Css.df.aic.black.$}>content</div>;
// Build-time output — no runtime call, just a plain className
return <div className="df aic black">content</div>;When the expression contains dynamic values, a lightweight trussProps runtime helper is used:
// Input
return <div css={Css.mt(someValue).black.$}>content</div>;
// Build-time output
return (
<div {...trussProps({ marginTop: ["mt_var", { "--marginTop": __maybeInc(someValue) }], color: "black" })}>
content
</div>
);For web usage, use the truss command to generate the Css.ts (and Css.json metadata file) from your truss-config.ts:
npm i --save-dev @homebound/truss- Add a
trusscommand to yourpackage.json:{ "scripts": { "truss": "truss" } } - Copy/paste an initial truss-config.ts into your project
wget https://raw.githubusercontent.com/homebound-team/truss/main/packages/template-tachyons/truss-config.ts
- Run
npm run truss- Re-run
npm run trussanytime you changetruss-config.ts
- Re-run
- Start using
Css.mt1.etc.$in your project, wiretrussPlugin(...)in Vite, and addimport "virtual:truss.css";to your entry module (see setup below)
We recommend checking the src/Css.ts file into your repository, with the rationale:
- Your design system be pretty stable, so the
Css.tsoutput should rarely change. - When it does change, it can be nice to see the diff-d output in the PR for others to review.
- It's the simplest "just works" setup for new contributors.
If you're building a component library with Truss that will be consumed by downstream applications, the recommended approach is to compile the library's Css.*.$ expressions into a pre-built truss.css file that the consuming application's Vite plugin can merge with its own Truss-generated CSS, into a single, unified/deduped truss.css output file.
Within the library build, Truss will generate a both Css.ts and Css.json:
Css.tsis the typedCss.*.$DSL to use in your component code,Css.jsonis a metadata file consumed by the Truss Vite plugin at build time.
The component library then ships both the Css.ts DSL and the Css.json metadata file for downstream applications to use for a) styling the application's own components, and then b) creating a unified design system + application code truss.css file for production usage.
Within the component library, install the build dependency:
npm install --save-dev @homebound/truss-
In the library package (i.e. your shared, company-wide component library) that defines your Truss styles/design system tokens, run codegen and build with the Truss plugin.
// truss-config.ts export default defineConfig({ outputPath: "./src/Css.ts", target: "web", // optional: defaults to ./src/Css.json based on outputPath mappingOutputPath: "./src/Css.json", // ...any palette/fonts/increment/etc configuration... });
If the library builds with Vite, use the Vite plugin:
// vite.config.ts (library package) import { defineConfig } from "vite"; import { trussPlugin } from "@homebound/truss/plugin"; export default defineConfig({ plugins: [trussPlugin({ mapping: "./src/Css.json" })], build: { lib: {/* your library entry */}, }, });
- In production builds with
vite build, unsupported patterns (including unknown abbreviations) fail the build with a source location. Override withunsupportedPattern: "warn". - In dev builds with
vite dev, unsupported patterns show a terminal warning and error overlay without stopping compilation. Override withunsupportedPattern: "error".
If the library builds with tsup (or esbuild), use the esbuild plugin:
// tsup.config.ts (library package) import { defineConfig } from "tsup"; import { trussEsbuildPlugin } from "@homebound/truss/plugin"; export default defineConfig({ entry: ["src/index.ts"], esbuildPlugins: [trussEsbuildPlugin({ mapping: "./src/Css.json" })], });
Both plugins transform
Css.*.$expressions to plain objects and emit atruss.cssfile with annotations that enable correct merging..css.tsarbitrary-selector rules are also emitted intotruss.cssand preserved as opaque blocks during app-level merges. The esbuild plugin also rejects unsupported patterns.For Vitest, use the Vite plugin:
// vitest.config.ts (library package) import { defineConfig } from "vitest/config"; import { trussPlugin } from "@homebound/truss/plugin"; export default defineConfig({ plugins: [trussPlugin({ mapping: "./src/Css.json" })], test: { environment: "jsdom", }, });
To assert Truss-generated styles in tests, Truss also exports a
toHaveStylematcher:// testSetup.ts import { expect } from "vitest"; import "@testing-library/jest-dom/vitest"; import { toHaveStyle } from "@homebound/truss/vitest"; expect.extend({ toHaveStyle });
This gives you both the matcher implementation and Vitest type augmentation. You can then write assertions like:
expect(element).toHaveStyle({ display: "flex", color: "#353535" });
- In production builds with
-
Publish the library's compiled JS,
Css.json, andtruss.css(for example indist/). Then:-
Application code can import the design system styles directly, e.g.
import { Css } from "@company/library". -
The application does not need to run its own Truss codegen step
-
In the application's Vite config, point
mappingto the library'sCss.jsonandlibrariesto the library'struss.css:import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { trussPlugin } from "@homebound/truss/plugin"; export default defineConfig({ plugins: [ trussPlugin({ // If you don't have a design library, just pass ./src/Css.json mapping: "./node_modules/@company/library/dist/Css.json", // Pre-compiled CSS from libraries to merge with app CSS libraries: ["./node_modules/@company/library/dist/truss.css"], }), react(), ], });
-
Notes:
- Keep
trussPlugin(...)beforereact(). mappingis required and should point to the singleCss.jsonyou want to compile against.librarieslists paths to pre-compiledtruss.cssfiles that will be merged with the app's own generated CSS. Rules are deduplicated by class name and sorted by priority to produce a correct unified stylesheet.
Import Truss's stylesheet once, in your entry module:
// src/main.tsx, or app/root.tsx for a server-rendered app
import "virtual:truss.css";Vite then treats Truss's CSS like any other stylesheet: it serves it in dev, bundles and hashes it in production, and a framework such as React Router lists it in the route manifest. This is the same one line for a Vite SPA and for an app that renders its own document (React Router, Remix, TanStack Start) -- there is no <link> tag to write and no dev-only script.
- In dev the import serves the rules collected so far, and Truss pushes later ones over Vite's own CSS HMR.
- In production the import is filled in after the last module is transformed, and the stylesheet is renamed so its hash covers the rules it ends up with.
- A library build (Vite's
build.lib) has no entry module of its own, so it keeps emitting a standaloneassets/truss-<hash>.cssfor consuming apps to merge throughlibraries.
Truss used to write the <link> tag into index.html itself. If your index.html still has that tag, delete it and add the import above -- the plugin fails the build while the stale tag is there, since it now points at nothing:
[truss] index.html still links Truss's stylesheet. Delete that <link> tag and add
`import "virtual:truss.css";` to your entry module instead.
If you delete the tag but forget the import, the build warns rather than silently shipping an unstyled app:
[truss] No module imported "virtual:truss.css", so this build ships no Truss stylesheet.
Add `import "virtual:truss.css";` to your entry module.
Truss metadata comments in emitted CSS, such as /* @truss p:3000 c:accent */, let consuming applications sort and deduplicate rules from library stylesheets.
- Vite: Dev stylesheets and library builds retain annotations. Use Vite's
build.libsetting when building a library. Application builds omit annotations, regardless ofmodeorbuild.cssMinify. - esbuild / tsup: The plugin produces library CSS and always retains annotations.
Application builds omit generated annotations after merging library CSS. User-authored comments are preserved.
Unannotated CSS cannot be used as library input through libraries. Publish annotated truss.css for downstream merging, and omit annotations only from the final application stylesheet.
Truss ships two build plugins. Both transform Css.*.$ expressions into plain objects and emit a truss.css file, but they target different build tools and have different feature sets.
| Feature | Vite plugin (trussPlugin) |
esbuild plugin (trussEsbuildPlugin) |
|---|---|---|
| Build tool | Vite | esbuild / tsup |
| Use case | Applications and Vitest test suites | Library packages compiled with tsup or plain esbuild |
| Dev server HMR | Yes -- serves CSS through virtual:truss.css and updates it over Vite's CSS HMR |
No -- esbuild has no dev server |
| Content-hashed output | Yes -- the bundled stylesheet is renamed to hash the rules it carries | No -- writes a fixed truss.css to the output directory |
| Library CSS merging | Yes -- libraries option merges pre-compiled library CSS into the app stylesheet |
No -- libraries are merged by the consuming app's Vite plugin |
| Test CSS injection | Yes -- auto-injects CSS into jsdom for Vitest | No |
| Stylesheet linking | Yes -- import "virtual:truss.css" in the entry module, for SPAs and SSR alike |
No -- not applicable for library builds |
When to use which:
trussPlugin-- Use for any Vite-based application or when running tests with Vitest. This is the primary plugin for most projects.trussEsbuildPlugin-- Use when building a shared component library with tsup or esbuild. The library's emittedtruss.css(with priority annotations) is then consumed by the application's Vite plugin via thelibrariesoption.
For web usage, target: "web" is the default. If you are targeting React Native instead, set target: "react-native" in your truss-config.ts (and typically defaultMethods: "tachyons-rn").
Truss intentionally limits the selectors you can use in Css.*.$ chains to:
-
- Keep atomic class output deterministic,
-
- Discourage selectors that "reach into other components" to manipulate their styles
Such that, in canonical Truss usage, you can only use selectors that directly modify the element you're styling, i.e.:
Css.onHover-> when I'm hovered, modify my stylesCss.when(marker, "ancestor", ":hover")-> when my ancestor is hovered, modify my styles
Unlike Tachyons and Tailwinds, Truss does not create duplicate/repetitive abbreviations for every pseudo-selector and breakpoint variant (e.g. md-blue or lg-red).
Instead, Truss provides chain commands like onHover, ifSm, and ifMd that then "modify" the commands that come after them:
function MyReactComponent(props: {}) {
// Default is mx2/black.
// ...unless hovered, then blue
// ...unless hovered & small screen, then blue & mx1
return <div css={Css.mx2.black.onHover.blue.ifSm.mx1.$}>...</div>;
}Where sm resolves from the breakpoints you define in truss-config.ts.
The available pseudo-class modifiers are:
| Modifier | CSS Pseudo-Class |
|---|---|
onHover |
:hover |
onFocus |
:focus |
onFocusVisible |
:focus-visible |
onFocusWithin |
:focus-within |
onActive |
:active |
onDisabled |
:disabled |
ifFirstOfType |
:first-of-type |
ifLastOfType |
:last-of-type |
For arbitrary pseudo-selectors not covered above, use when:
// Simple pseudo-selector
<div css={Css.when(":hover:not(:disabled)").black.$} />;
// Group multiple same-element selectors together
const buttonCss = Css.bgBlack.white.when({
":hover": Css.bgBlue.$,
":focus": Css.bcWhite.$,
"[aria-disabled=true]": Css.bgLightGray.$,
}).$;
// Marker-based relationship (react to an ancestor's hover)
const row = Css.newMarker();
<tr css={Css.markerOf(row).$}>
<td css={Css.when(row, "ancestor", ":hover").blue.$}>...</td>
</tr>;The object form of when is useful when you want to compare multiple pseudo-selectors side-by-side. Each value is its own Css.*.$ expression, but the result has the same merge semantics as if you had written one longer chain.
Importantly, the object keys do not stack/AND together with each other. Each key is resolved independently.
Css.when({ ":hover": Css.blue.$, ":focus": Css.white.$ }).$means:hoverblue and:focuswhite- it does not mean
:hover:focus - if you want AND behavior, put the full combined selector in the key, i.e.
":hover:focus"or":hover:not(:disabled)"
Truss reads Css...$ chains left-to-right.
Conditions accumulate by "axis", and only the latest modifier on the same axis replaces the previous one.
The available axes are:
| Modifier | Description |
|---|---|
if(cond) / else |
Starts a runtime boolean branch |
ifSm, ifMd, if("@media ...") |
Sets the active media-query |
onHover, onFocus, when(":hover") |
Sets the "this element" selector |
when(marker, "ancestor", ":hover") |
Sets the "related element" selector |
element("::placeholder") |
Sets the pseudo-element |
If you want to stop accumulating modifier context and start a new condition branch in the same chain, use Css.end.
endresets the active media query, pseudo-class, pseudo-element, andwhen(...)relationship context- this is especially useful for expressing an "or" across conditions in one chain
enddoes not terminate a runtimeif(cond)/elsebranch
Examples:
Css.ifSm.onHover.blue.$;
// small screens && hover => blue
Css.ifSm.if(selected).blue.$;
// small screens && hovered => blue
Css.ifSm.black.else.white.$;
// small screens => black, others => white
Css.ifSm.when(row, "ancestor", ":hover").blue.$;
// small screens && ancestor hovered => blue
Css.when(row, "ancestor", ":hover").onFocus.blue.$;
// ancestor hovered && element focused => blue
Css.onHover.onFocus.blue.$;
// last same-element pseudo wins (:focus)
Css.ifSm.onHover.blue.end.onFocus.white.$;
// (small screens && hover => blue) OR (focus => white)
Css.when(row, "ancestor", ":hover").ifSm.blue.end.bgWhite.$;
// small screens && ancestor hovered => blue, plus unconditional bgWhiteFor selectors not supported by Css.*.$, i.e. descendant selectors, :nth-child(...), or library-driven markup hooks, but where the selectors themselves are still:
- Globally applicable, and
- Statically known at build-time
You can put the selectors in a .css.ts file and then attach the exported class name through Css.className(...).
// DataGrid.css.ts
import { Css } from "./Css";
export const zebraRows = "zebraRows";
// These styles will be appended to the `truss.css` output
export const css = {
// Write whatever selectors you want, using `zebraRows`
[`.${zebraRows} tbody tr:nth-child(even) td`]: Css.bgLightGray.$,
[`.${zebraRows} tbody tr:hover td`]: Css.bgBlue.white.$,
// Or do global selectors if you want, but be careful with specificity and conflicts
body: Css.bgWhite.$,
};// DataGrid.tsx
import { Css } from "./Css";
import { zebraRows } from "./DataGrid.css.ts";
export function DataGrid() {
return (
<table css={Css.w100.className(zebraRows).$}>
<tbody>{/* rows */}</tbody>
</table>
);
}This keeps the base element styling in Truss, i.e. Css.w100, while using the .css.ts class as the anchor for arbitrary selectors.
At build time, Truss merges both into the final className prop.
If you need arbitrary CSS that the Css.*.$ DSL does not support (e.g. !important, custom properties, or vendor-specific values), you can use a raw string literal or the Css.raw tagged template as a property value:
export const css = {
body: Css.raw`
margin: 16px;
background-color: rgba(255, 255, 255, 1);
color: rgba(53, 53, 53, 1);
font-size: 14px !important;
line-height: 20px !important;
`,
};The Css.raw tag is a pass-through (it returns the string as-is at runtime) but signals to IDEs that the template content is CSS, enabling syntax highlighting and autocomplete (similar to styled-components markup).
Raw strings are emitted as-is, so property names must use standard CSS kebab-case (e.g. font-size, not fontSize).
You can also use a plain string literal or untagged template literal:
export const css = {
body: `
margin: 16px;
font-size: 14px !important;
`,
};Limitations:
- Only static and literal-argument chains are supported (e.g.
Css.df.$,Css.mt(2).$,Css.mtPx(12).$) - Runtime/variable arguments (
Css.mt(x).$), conditionals (Css.if(cond).df.$), pseudo-class modifiers (Css.onHover.blue.$), and media query modifiers (Css.ifSm.blue.$) are not supported
When selector rules are either:
-
- Fundamentally driven by runtime values that change the selector itself, or
-
- Should be only transiently injected/applied while a component is mounted
Truss provides a useRuntimeStyle hook paired with a dedicated RuntimeCss entry point:
import { RuntimeCss, useRuntimeStyle } from "./Css";
function Preview(props: { accent: string }) {
useRuntimeStyle({
".preview [data-selected='true']": RuntimeCss.bc(props.accent).bgWhite.$,
});
return <div className="preview">...</div>;
}RuntimeCss is the runtime counterpart to Css. Both share the same abbreviations and chaining API, but they produce different types:
Css.blue.$returnsBuildtimeStyles— for use incss=props (transformed at build time by the Vite plugin)RuntimeCss.blue.$returnsRuntimeStyles— for use inuseRuntimeStyle(evaluated at runtime)
TypeScript enforces this separation: passing Css.blue.$ to useRuntimeStyle or RuntimeCss.blue.$ to a css= prop will produce a compile error.
useRuntimeStyle evaluates its RuntimeCss expressions at runtime, injects a <style> tag into the DOM, and removes that tag when the component unmounts. Use it for ephemeral selectors or selector rules that depend on runtime values; use .css.ts when the rule is static/global and should be baked into the build output.
import { RuntimeCss, useRuntimeStyle } from "./Css";
function Preview(props: { bottomMargin: number }) {
useRuntimeStyle({ body: RuntimeCss.mbPx(props.bottomMargin).$ });
return <div>...</div>;
}For the occasional case where a Css.*.$ chain needs to attach a raw className or inline style, Truss provides two build-time passthrough helpers:
Css.className(value)appends one or more raw classes to the final elementCss.style(value)appends raw inline styles to the final element'sstyleprop
Example:
const iconVars = {
"--icon-primary": color,
"--icon-secondary": secondaryColor,
"--icon-stroke": color,
};
return <div css={Css.blue.mt(getMargin()).className("my-icon").style(iconVars).$} />;Which compiles to output equivalent to:
return (
<div
className="my-icon blue mt_var"
style={{
"--marginTop": getMargin(),
...iconVars,
}}
/>
);Typical use cases are:
Css.className(...)for attaching a class exported from a.css.tsfile or a third-party class hookCss.style(...)for custom CSS variables or a small amount of inline style data that should travel with the element
These are intentionally escape hatches:
- they only work in normal build-time
css={...}expressions - they are not supported inside
useRuntimeStyle - they are not supported in
.css.tsarbitrary-selector rules - they cannot be used inside modifier contexts like
ifSm,onHover,when(...), orelement(...)
The truss command accepts an optional second argument which is the path to your
configuration file. If omitted, it will look for ./truss-config.ts.
{
"scripts": {
"truss": "truss path/to/configuration/file.ts"
}
}Truss's configuration is done via a truss-config.ts file installed into your local project.
See the comments in that file for the available config options. For example setting up your custom font abbreviations is set via a FontConfig hash:
// Defines the typeface abbreviations, the keys can be whatever you want
const fonts: FontConfig = {
f10: "10px",
f12: "12px",
f14: "14px",
f24: "24px",
// Besides the "24px" shorthand, you can define weight+size+lineHeight tuples
tiny: { fontWeight: 400, fontSize: "10px", lineHeight: "14px" },
};
Also see the Customization section for more advanced configuration options.
For selectors that do not fit naturally into a Css.*.$ chain, i.e. descendant selectors, :nth-child(...), or library-driven markup hooks, put that selector logic in a .css.ts file and then attach the exported class name through Css.className(...).
// DataGrid.css.ts
import { Css } from "./Css";
export const zebraRows = "zebraRows";
export const css = {
[`.${zebraRows} tbody tr:nth-child(even) td`]: Css.bgLightGray.$,
[`.${zebraRows} tbody tr:hover td`]: Css.bgBlue.white.$,
};// DataGrid.tsx
import { Css } from "./Css";
import { zebraRows } from "./DataGrid.css.ts";
export function DataGrid() {
return (
<table css={Css.w100.className(zebraRows).$}>
<tbody>{/* rows */}</tbody>
</table>
);
}This keeps the base element styling in Truss, i.e. Css.w100, while using the .css.ts class as the anchor for arbitrary selectors. At build time, Truss merges both into the final className prop.
Css.animationName(...) (and the animation shorthand) reference a keyframes name that you define elsewhere — either in a plain .css file you already import, or in a .css.ts file using the at-rule as the key:
// Spinner.css.ts
import { Css } from "./Css";
export const css = {
"@keyframes spin": Css.raw`
from { rotate: 0deg; }
to { rotate: 360deg; }
`,
};// Spinner.tsx
const spinner = Css.animationName("spin")
.animationDuration("1s")
.animationIterationCount("infinite")
.animationTimingFunction("linear").$;Keyframe names are global, so namespace them if collisions are a risk.
Truss liberally borrows the idea of type-checked "extension" CSS from the currently-unreleased Facebook XStyles library (at least in theory; I've only seen one or two slides for this feature of XStyles, but I'm pretty sure Truss is faithful re-implementation of it).
As context, when developing components, you often end up with "properties that are okay for the caller to set" (i.e. that you as the component developer support the caller setting) and "properties that are not okay for the caller to set" (i.e. because the component controls them).
Basically, you want to allow the caller to customize some styles of the component, typically things like color or margin or font size, but not give them blanket control of "here is a className prop, do whatever you want to my root element", which risks "radical"/open-ended customization that then you, as the component developer, don't know if you will/will not unintentionally break going forward.
(I.e. see Layout isolated components for a great write up of "parents control margin, components control padding".)
With Truss, you can explicitly declare a contract of styles allowed to be set on your component, i.e.:
import { Css, Only, Xss } from "src/Css";
// Declare the allowed/supported styles
export type DatePickerXss = Xss<"marginLeft" | "marginRight">;
// Update the props to accept an `xss` prop to accept the customizations
export interface DatePickerProps<X> {
date: Date;
xss?: X;
}
// Use the `Only` type to ensure `xss` prop is a subset of DatePickerXss
export function DatePicker<X extends Only<DatePickerXss, X>>(props: DatePickerProps<X>) {
const { date, xss } = props;
// The component controls marginTop/marginBottom, and defers to the caller for marginLeft/marginRight
return <div css={{ ...Css.my2.$, ...xss }}>{date}</div>;
}Here we're allowing callers to set marginLeft or marginRight, i.e. this line will compile because mx2 is statically typed as { marginLeft: number; marginRight: number }, and so is a valid xss value:
<DatePicker xss={Css.mx2.$} date={...} />However, this line will not compile because mt2 is statically typed as { marginTop: number }, and marginTop is not allowed by DatePickerXss:
<DatePicker date={...} xss={Css.mt2.$} />The Css DSL also iteratively types itself, i.e. Css.ml1.mr2.$ is still statically typed as { marginLeft: number; marginRight: number }, instead of being based just on the last-used abbreviation.
Use add() to create Css expressions from raw CSS property names and values. This is for when you need a CSS property that doesn't have a Truss abbreviation:
// Single property
const s = Css.df.add("boxShadow", "0 0 0 1px blue").$;
// Multiple properties via object literal
const s = Css.df.add({ wordBreak: "break-word", boxShadow: "0 0 0 1px blue" }).$;The arguments to add() are real CSS property names and values--not Css expressions. If you pass a Css expression (from Css.*.$) to add(), you'll get an error directing you to use with() instead.
Use with() to compose an existing Css expression into a chain. Where add() takes style names/values, with() takes the result of other Css.*.$ expressions:
const base = Css.df.black.$;
const s = Css.mt2.with(base).$;The most common way to compose Css expressions is with object spreads ({ ...Css.black.$, ...other }), but spreads have a limitation: if both sides set the same CSS property, the later spread replaces the earlier one at the object level. This means { ...Css.black.$, ...colorOnHover } would clobber black even if colorOnHover targets a different selector like :hover.
This spread behavior is on purpose, b/c it makes "the last definition win", and not result in a hodge-podge of behavior.
That said, if/when you specifically need to preserve both defintions, you can use Css.with:
const s = Css.black.with(colorOnHover).mt2.$;Here colorOnHover might be Css.onHover.blue.$--the black base color and the hover color target different selectors, so both are preserved.
with() also accepts partial style hashes and automatically skips undefined values, which is useful for destructured XSS overrides:
import { Css, type Only, type Xss } from "src/Css";
type PanelXss = Xss<"color" | "height">;
function Panel<X extends Only<PanelXss, X>>(props: { xss?: X }) {
const xss = props.xss as Partial<PanelXss> | undefined;
const { height } = xss ?? {};
return <div css={Css.h(1).black.with({ height }).$}>panel</div>;
}Here Css.h(1) provides the default height, and with({ height }) only overrides it when the caller actually passed a height value. Unlike a raw spread ...{ height }, an undefined height won't clobber the earlier h(1) default.
Truss conventionally uses the xss prop name for "the component's allowed extension styles" as a play on the css prop name, with the x representing the "extension" concept, but otherwise there is nothing special about the name of the xss prop.
Also note that the XStyles/Xss feature is completely opt-in; you can use it if you want, or you can use Truss solely for the Css.m2.black.$ abbreviations.
Truss does not have a dedicated variants API, because we don't need one: Css.*.$ expressions are POJOs, so the
variant pattern can be expressed as just an object literal keyed by the variant name:
export type ChipType = "caution" | "warning" | "success" | "neutral";
export function Chip(props: { text: string; type?: ChipType }) {
const { text, type = "neutral" } = props;
return <span css={{ ...chipBaseStyles, ...typeStyles[type] }}>{text}</span>;
}
const chipBaseStyles = Css.xsSb.dif.aic.br16.px1.gray900.$;
const typeStyles: Record<ChipType, Properties> = {
caution: Css.bgYellow200.$,
warning: Css.bgRed100.$,
success: Css.bgGreen100.$,
neutral: Css.bgGray200.$,
};The Record<ChipType, Properties> annotation is the point of the convention: TypeScript fails the
build when a new variant is added to the union but not to the map, and when a map key is not a real
variant. A switch with a default case quietly loses both checks.
When a variant also drives other behavior, such as an icon or a label, we can simply change the
typeStyles value to itself be a record of key/value pairs:
type TypeStyle = {
icon: IconKey;
tagType: TagType;
label: string;
};
const typeStyles: Record<BannerType, TypeStyle> = {
error: { icon: "xCircle", tagType: "error", label: "Error" },
warning: { icon: "error", tagType: "warning", label: "Warning" },
};If we need "per variant, per state", we can have our variantStyles become a two-level
map of variant -> state -> styles:
const variantStyles: Record<
ButtonVariant,
{
baseStyles: Properties;
hoverStyles: Properties;
pressedStyles: Properties;
disabledStyles: Properties;
}
> = {
primary: {
baseStyles: Css.bgColor(Tokens.Primary).color(Tokens.OnPrimary).$,
hoverStyles: Css.bgColor(Tokens.PrimaryHover).$,
pressedStyles: Css.bgColor(Tokens.PrimaryPressed).$,
disabledStyles: Css.bgColor(Tokens.ButtonDisabledBg).color(Tokens.ButtonDisabledFg).$,
},
// ...secondary, danger, text, etc.
};
function Button(props: ButtonProps) {
// ...
const { baseStyles, hoverStyles, pressedStyles, disabledStyles } = variantStyles[variant];
return (
<button
css={{
...Css.buttonBase.$,
...baseStyles,
...(isHovered && !isPressed ? hoverStyles : {}),
...(isPressed ? pressedStyles : {}),
...(isDisabled ? disabledStyles : {}),
}}
/>
);
}Because Truss ... style spreads are "last property wins", each state only needs to list what
it changes, and the ordering of the spreads is the state precedence--there is no specificity
to reason about.
If your component has multiple inputs that drive its styles, i.e. a variant prop (what it looks like)
and a size (how big it is) prop, we can just use "two record consts", one per prop, and
combine them at render time:
const sizeStyles: Record<ButtonSize, Properties> = {
sm: Css.hPx(32).pxPx(12).$,
md: Css.hPx(40).px2.$,
lg: Css.hPx(48).px3.$,
};
const baseStyles = Css.with(variantStyles[variant].baseStyles).with(sizeStyles[size]).$;Use with(), not a spread, when either side may carry pseudo-selector or breakpoint styles that the
other side would otherwise clobber. For plain property hashes a spread is fine.
Given how ubiquitous the variant pattern is in UX design, most CSS-in-JS libraries also ship support for it, but usually via a dedicated variants API.
(I.e. Stitches's and Panda's variants / compoundVariants, vanilla-extract's recipes,
class-variance-authority and tailwind-variants on top of Tailwind, and the variants slot
in MUI/Chakra theme files.)
These all solve the same problem, but have to invent a library-specific, potentially esoteric, config format to do it.
In Truss the "config format" is just TypeScript. 🚀
Truss supports several levels of customization:
- Per-project fonts/colors/etc. in
truss-config.ts - Per-project rule additions or changes in
truss-config.ts - Forking
Each project that uses Truss gets a local index.ts, checked into its repo essentially as a config file, that defines in TypeScript the core settings, i.e.:
const increment = 8;
const numberOfIncrements = 4;
const palette = {
Black: "#353535",
MidGray: "#888888",
LightGray: "#cecece",
White: "#fcfcfa",
Blue: "#526675",
};
const fonts = {
f24: "24px",
f18: "18px",
f16: "16px",
f14: "14px",
f12: "12px",
// Can also set multiple properties if necessary
f10: { fontSize: "10px", fontWeight: 500 },
};
const breakpoints = { sm: 0, md: 600, lg: 960 };
/** Optional: names → CSS custom properties. Codegen emits a `Tokens` enum in Css.ts. */
const tokens = {
ThemePrimary: "--theme-primary",
LayoutSidebarWidth: "--layout-sidebar-width",
};
// ...rest of the config file (pass `tokens` into defineConfig)...Projects should heavily customize these settings to match their project-specific design system, then run npm run truss to get an updated Css.ts, i.e. after adding Green: "green" as a color in palette, the Css.ts file will automatically have utility methods added like:
get green() { return this.add("color", "green"); }
get bgGreen() { return this.add("backgroundColor", "green"); }
get bGreen() { return this.add("borderColor", "green"); }You can declare a tokens map in truss-config.ts's defineConfig: each key is a PascalCase name, each value is a CSS variable string ("--my-token").
Truss codegen will then add export enum Tokens { … } (similar to Palette) plus types used by Css.setVar.
Use those names in palette, fonts, or sections with normal var(…) values—for example Primary: "var(--theme-primary, #2563eb)" once --theme-primary matches a tokens entry.
Css.setVar({ … }) applies custom properties via atomic classes the Vite plugin emits—not Css.style() / inline styles—so rules can sit under the same @media / @container machinery as the rest of Truss. Keys are either [Tokens.SomeName] or ad-hoc "--local-var" string keys. Values must be compile-time literals (or an object with optional default, media keyed by your generated Breakpoint names, and container rows with the same gt / lt / name shape as ifContainer). You can omit default if you only need conditional branches, as long as at least one branch is present.
React Native: setVar is not supported (no web atomic pipeline).
<div
css={
Css.setVar({
[Tokens.ThemePrimary]: "#2563eb",
"--one-off": "12px",
}).df.$
}
/>Naming a token and registering it are two different things in truss-config.ts:
-
Defining a token as just a
stringvalue only gives the variable a TypeScript name; it emits no CSS at all. -
Defining a token as an
objectalso emits an@propertyblock.This tells the browser about the variable: how to parse and type-check its value, to fall back to
initialValue, and to interpolate it when used in animations.
tokens: {
ThemePrimary: "--theme-primary", // named only
Angle: { var: "--angle", syntax: "<angle>", inherits: false, initialValue: "0deg" },
},Whether you define a token as a string or object does not change how you use the token:
Css.setVar({ [Tokens.Angle]: "45deg" })to write it,Tokens.Angleas a whole value to read it (Truss wraps it asvar(--angle)), or interpolated into a larger value, i.e.Css.add("backgroundImage", `conic-gradient(from ${Tokens.Angle}, red, blue)`)
When using the object form:
inheritsdefaults tofalse,initialValueis required for everysyntaxexcept"*"
@property blocks are emitted unconditionally rather than pruned based on usage. Static usage like Css.bc(Tokens.Angle) is knowable at build time, but dynamic usage like const token = ...; Css.bc(token) is not, and would force keeping everything anyway, so pruning is likely not really worth it.
(Unlike keyframes, below, which we do prune based on usage, b/c they are sufficiently larger than tokens, and also easier to detect in terms of dynamic usage b/c their names must always be used in an animation property.)
Truss supports declaring keyframes for animation in truss-config.ts, in addition to just raw CSS blocks in a .css.ts.
Using truss-config.ts declarations is preferable as then Truss knows about the name: it can check the animations that use it, and write the declaration only while some rule still does.
keyframes: {
spin: { to: { transform: "rotate(360deg)" } },
pulse: { "0%, 100%": { opacity: 1 }, "50%": { opacity: 0.45 } },
sweep: { to: { "--angle": "360deg" } }, // animating a registered property
shimmer: "from { background-position: 200% 0; }", // raw body escape hatch
aiStarLoader: null, // defined by another stylesheet
},Each entry is a keyframe selector (from, to, 50%, 0%, 100%), which csstype validates like any other declaration — plus custom properties, since animating a registered property is written as a keyframe that sets it. A string is a raw body, for a timeline the typed form cannot express. null declares a name some other stylesheet owns (a global CSS file, a third-party package): Truss accepts the name and writes nothing.
Each keyframe also gets its own utility method, which puts the name in front of the rest of the animation shorthand:
<div css={Css.spin("0.8s linear infinite").$} />The method is named after the keyframe, camelCased, i.e. fade-in becomes Css.fadeIn(...). When that name is already an abbreviation, an alias, or a CssBuilder method, the method is named animate plus the keyframe, i.e. a keyframe named float becomes Css.animateFloat(...), so the Css.float(...) property method keeps its name. If both names are taken, generation fails and asks you to rename the keyframe.
The generated Css.ts file also has an export enum Keyframes { … } with a PascalCase member per name, for the animations one method cannot write, i.e. two animations on one element:
<div css={Css.animationName(Keyframes.Spin).animationDuration("0.8s").$} />
<div css={Css.animation(`${Keyframes.Spin} 0.8s linear infinite`).$} />
<div css={Css.animation(`${Keyframes.Spin} 0.8s linear infinite, ${Keyframes.Pulse} 2s ease-in-out infinite`).$} />Css.spin("0.8s linear infinite") and Css.animation(`${Keyframes.Spin} 0.8s linear infinite`) resolve to the same declaration, and so share one atomic class.
Once keyframes are configured, Truss checks every animation / animation-name usage at build-time and fails the build on a name you have not declared, the same way a mistyped abbreviation does — Unknown keyframes "spinn" - add it to config.keyframes. Did you mean "spin"?.
A keyframe declaration is written only when used, so unused animations are pruned — the same effect as declaring them in a .css.ts file that is never actually imported.
The disclaimer is that values built at runtime, i.e. Css.animation(motion), leave no name in the CSS, so a) they are not checked, and b) they trigger every configured keyframe to emit, just in case the browser ends up asking for one. This holds for Css.spin(timing) too: the name is written into the runtime value, so the animation still runs, but the CSS only says animation: var(--animation), so every keyframe is kept.
In the same index.ts, projects can add their own new abbreviations/utility methods:
const sections = {
ourSection: () => [newMethod("someAbbreviation", { color: "#000000" })],
};Will result in Css.ts having a line that looks like:
// ourSection
get someAbbreviation() { return this.add("color", "#000000"); }Which can then be used as Css.m2.someAbbreviation.$.
Besides adding one-off additional methods, if your project wants to replace a whole section of Truss's out-of-the-box methods, you can do this via:
const sections = {
// Prefer app-specific border radiuses
borderRadius: () =>
newMethodsForProp("borderRadius", {
br4: "4px",
br8: "8px",
br16: "16px",
}),
};Where borderRadius matches the name of the section in Truss's sections directory (which generally matches Tachyon's organization).
At the end of the day, Truss is small and hackable such that forking it to make the abbreviations "strict Tachyons" or "strict Tailwinds" or "whatever best fits your project/conventions/styles" should be easy and is kosher/encouraged.
The core Truss feature of "make a TypeScript DSL with a bunch of abbreviations" is also basically done, so it's unlikely you will miss out on some future/forthcoming amazing features by forking.
And, even if so, the coupling between Truss and your application code is limited to the Css.abbreviations.$ lines that should be extremely stable even if/as the core of Truss evolves.
Truss's approach is "Tachyons-ish" (or Tailwinds-ish), insofar as having short/cute utility class definitions.
On web, those abbreviations compile through the Truss Vite plugin into atomic CSS classes. On mobile, they resolve to plain React Native style objects.
The benefits of this approach are:
-
We get the brevity + "inline-ness" of Tachyons/Tailwinds.
-
It delivers critical CSS, i.e. we don't need the large static TW/Tachyons CSS files.
(My reading of projects like tachyons-styled-react, from the creator of Tachyons, is that critical-ness is still important goal/improvement even for static-utility-class approaches like Tachyons.)
-
Pseudo-selectors/breakpoints go through Truss's typed DSL (
onHover,ifSm, etc.), which keeps usage concise and reduces method/abbreviation bloat.I.e. we don't need to suffix
-nlfor "not large" onto every single abbreviation. -
You can still mix in regular CSS for the places where utility abbreviations are not the best fit (see
.css.tsfiles). -
Projects can easily tweak their preferred styles/abbreviations.
Granted, this is very similar in spirit to Tailwinds customization, but for Truss, the config process is "just change some TypeScript code and run
generate", and doesn't involve any changes to your build/webpack/PostCSS/etc. setup.
tldr: Tachyon's abbreviations are shorter. :-)
For example, the CSS justify-content: flex-start in Tailwinds is justify-start, and in Truss is jcfs (i.e. the Justify Content Flex Start).
This is admittedly preference, but Truss's assertion is that readability goes up when code sprawl goes down, because you can visually fit more code into view at once.
And so Tachyons-style abbreviations, even if each abbreviation in isolation is more complex, when taken as a whole (looking at 10-20 lines of non-trivially-styled JSX) is arguably more readable.
Granted, the more-succinct code is still doing "the same work" (setting the same CSS properties) as the longer code, but you are likely only paying attention to a small subset of code at any given time, so the currently-unimportant code/abbreviations will "fade into the background" more easily when they're shorter.
(This is also not to say all names in a codebase should be meaningless chicken-scratch like a, b, c, etc., but when there are very strong/consistent conventions (like loop variables being called i, j, k), then leaning into succinctness can be appropriate.)
All this said, it's very possible to teach Truss how to generate Tailwinds-based abbreviations, we just haven't done it yet; see this issue if you're interested in helping contribute.
The word "theme" can mean either "static themes" (i.e. using the same consistent colors/fonts throughout your app, but the values themselves never really change) or "dynamic themes" (i.e. the user changing from light mode to dark mode).
For static themes, Truss's index.ts/palette.ts are specifically setup to centrally define your application's fonts, colors, etc. (see the "Configuration" section), so that they are consistently applied through your application.
For dynamic themes, Truss doesn't have any features dedicated explicitly to support them, but you can easily use CSS variables in your methods, i.e.:
const palette = {
Primary: "var(--primary)",
Secondary: "var(--secondary)",
};And then have your application handle setting the --primary / --secondary values as appropriate (i.e. by importing a dark-mode.css or light-mode.css which define the respective CSS variable values).
Several libraries influenced Truss, specifically:
-
Typed Tailwinds and babel-plugin-tailwind-components are both "type-safe TypeScript utility-css DSLs".
In particular, the babel-plugin-tailwind-components insight of "if you just make
csstype-compliant object literals, you can build a typed utility DSL on top" was a very useful/inspirational insight. -
Facebook's XStyles for the "typed extension" idea
-
Facebook's StyleX heavily influenced Truss's 2.x build-time approach--i.e. we copied nearly everything about it. 😅
StyleX solved the hard problems of build-time atomic CSS:
- property-level last-write-wins semantics,
- specificity tiers via doubled selectors for media queries,
- CSS custom properties for runtime values, and
- deterministic class generation.
The only reasons we don't use StyleX directly are:
-
The
stylex.createvalues are "arrays of tuple data", instead of object hashes, and so didn't work with Truss's extremely common object literal spreads ofcss={{ ...Css.mt2.$, ...someOtherStyles }}. -
Given we already have "basically unique" abbreviations, we can make class names that aren't esoteric hashes.
We probably give up some small-percentage of output size/performance, that matters at Facebook scale, but for Truss we prioritize readability and debuggability of the emitted CSS classes.
The Truss repository is set up as a Yarn workspace, although the core package is just packages/truss; the other packages are examples/tests projects.
A basic development flow is:
- In the root directory, run
yarn - In the root directory, run
yarn build -w - Iterate as you want
- In the root directory, run
yarn testto run all tests- Running individual tests in your IDE/each package should work as well
- In the root directory run
yarn codegento generate the testingCss.tsfiles
npx -p @homebound/truss inittype experience for setup - inspired by Storybook- Support
number[]increments as config