Skip to content

homebound-team/truss

Repository files navigation


Truss is a TypeScript DSL for writing utility CSS (think Tailwinds or Tachyons) in React/JSX.

Quick Example

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

Quick Features

  • 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.css file
  • Naturally use dynamic values:

    • Css.mt(someValue).$ or
    • Css.bgColor(maybe ? Palette.Black : Palette.Blue).$ or
    • Css.mt0.if(someCondition).mt4.$.
    • Still compiled to static/atomic CSS, with a lightweight runtime helper to apply dynamic values
  • Pseudo-selectors and breakpoints:

    • Css.white.onHover.black.$ or
    • Css.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
  • Css expressions 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 -> fb abbreviation pattern:
      • justify-content: flex-start is jcfs
      • 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
  • Escape hatch to arbitrary/runtime selectors

    • useRuntimeStyle({ body: RuntimeCss.blue.$ })
    • 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.m2 is more succinct than repeating class name modifiers
    • 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.

Quick How It Works

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

Installation

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 truss command to your package.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 truss anytime you change truss-config.ts
  • Start using Css.mt1.etc.$ in your project and wire trussPlugin(...) in Vite (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.ts output 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.

Vite Plugin Setup (pre-compiled libraries)

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.ts is the typed Css.*.$ DSL to use in your component code,
  • Css.json is 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
  1. 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 */
        },
      },
    });

    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 a truss.css file with annotations that enable correct merging. .css.ts arbitrary-selector rules are also emitted into truss.css and preserved as opaque blocks during app-level merges.

    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 toHaveStyle matcher:

    // 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" });
  2. Publish the library's compiled JS, Css.json, and truss.css (for example in dist/). 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 mapping to the library's Css.json and libraries to the library's truss.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(...) before react().
  • mapping is required and should point to the single Css.json you want to compile against.
  • libraries lists paths to pre-compiled truss.css files 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.

Plugin Comparison

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 via a virtual endpoint and pushes updates over WebSocket No -- esbuild has no dev server
Content-hashed output Yes -- production builds emit assets/truss-<hash>.css for long-term caching 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
HTML injection Yes -- injects <link> / <script> tags into index.html 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 emitted truss.css (with priority annotations) is then consumed by the application's Vite plugin via the libraries option.

React Native (experimental/mobile) Usage

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").

Pseudo-Selectors

Truss intentionally limits the selectors you can use in Css.*.$ chains to:

    1. Keep atomic class output deterministic,
    1. 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 styles
  • Css.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 :hover blue and :focus white
  • 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)"

Chaining Modifiers

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.

  • end resets the active media query, pseudo-class, pseudo-element, and when(...) relationship context
  • this is especially useful for expressing an "or" across conditions in one chain
  • end does not terminate a runtime if(cond) / else branch

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 bgWhite

Arbitrary Build-time Selectors

For selectors not supported by Css.*.$, i.e. descendant selectors, :nth-child(...), or library-driven markup hooks, but where the selectors themselves are still:

  1. Globally applicable, and
  2. 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

Arbitrary Runtime Selectors

When selector rules are either:

    1. Fundamentally driven by runtime values that change the selector itself, or
    1. 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.$ returns BuildtimeStyles — for use in css= props (transformed at build time by the Vite plugin)
  • RuntimeCss.blue.$ returns RuntimeStyles — for use in useRuntimeStyle (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>;
}

Css.className(...) and Css.style(...)

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 element
  • Css.style(value) appends raw inline styles to the final element's style prop

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.ts file or a third-party class hook
  • Css.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.ts arbitrary-selector rules
  • they cannot be used inside modifier contexts like ifSm, onHover, when(...), or element(...)

Truss Command

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

Configuration

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.

Custom Selectors with .css.ts and Css.className(...)

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.

XStyles / Xss Extension Contracts

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.

add() — Creating Css Expressions from Style Names/Values

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.

with() — Composing Css Expressions

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.

Customization

Truss supports several levels of customization:

  1. Per-project fonts/colors/etc. in truss-config.ts
  2. Per-project rule additions or changes in truss-config.ts
  3. Forking

Per-Project Fonts/Colors/Etc

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

// ...rest of the config file...

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

Per-Project Utility Methods

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).

Forking

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.

Why This Approach?

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 -nl for "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.ts files).

  • 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.

Why Tachyons Instead of Tailwinds?

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.

Themes

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).

Inspiration

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.create values are "arrays of tuple data", instead of object hashes, and so didn't work with Truss's extremely common object literal spreads of css={{ ...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.

Contributing

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 test to run all tests
    • Running individual tests in your IDE/each package should work as well
  • In the root directory run yarn codegen to generate the testing Css.ts files

Todo

  • npx -p @homebound/truss init type experience for setup - inspired by Storybook
  • Support number[] increments as config

About

A TypeScript DSL for writing utility CSS in React/JSX

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages