Every atomic-CSS system eventually hits the same wall: the built-in breakpoints and pseudo-classes are great — until the day you need something they never shipped with. A brand-specific selector. A retina-display query. A tablet breakpoint that doesn’t match any of the defaults. Most tools force you to crack open one central config file, edit it, and hope you don’t step on a teammate’s PR.
traceless-style solves this differently with tl.extend — and the way it solves it is the most interesting part.
What tl.extend actually does
tl.extend registers custom variants. The key detail: both the runtime and the compiler discover them automatically. At build time, a first pass scans every file in the project for tl.extend({ variants: {...} }) calls and merges everything it finds into a single map. A second pass then uses that merged map whenever it transforms a tl.create call.
The practical result: there is no central registry file to maintain. You can define a custom variant in the same component file where you use it, in a shared theme.ts, or scattered across a dozen files — the compiler finds all of them and treats them as one unified vocabulary.
Signature
function extend(options: ExtendOptions): TracelessStyleInstance;
interface ExtendOptions {
variants: Record<string, string>;
prefix?: string;
}
interface TracelessStyleInstance {
create: typeof create;
merge: typeof merge;
cx: typeof cx;
variants: FlatVariants;
errors: VariantValidationError[];
}
variants is a flat map of variant-key → selector-string. The returned TracelessStyleInstance isn’t just metadata — it’s a fully working instance, with create, merge, and cx already bound to your new variants, plus a variants map and an errors array for anything that failed validation.
Example 1 — Simple custom variants
import { tl } from "traceless-style";
tl.extend({
variants: {
_tablet: "@media (min-width: 900px)",
_retina: "@media (-webkit-min-device-pixel-ratio: 2)",
_brand: ".my-brand &",
_hoverDark: ":is(.dark *):hover",
},
});
// Use anywhere:
const $ = tl.create({
card: {
padding: "1rem",
_tablet: { padding: "2rem" },
_brand: { color: "gold" },
},
});
Once _tablet and _brand are registered, they behave exactly like any built-in variant key (_hover, _focus, etc.) inside any tl.create call, in any file, for the rest of the project.
Example 2 — Using the returned instance
const $$ = tl.extend({ variants: { _tablet: "@media (min-width: 900px)" } });
// Both global and returned forms work — variants are registered in one place.
$$.create({ card: { _tablet: { padding: "2rem" } } });
tl.create({ card: { _tablet: { padding: "2rem" } } }); // also works
This matters for larger codebases: you’re never forced to choose between “import the global tl” and “use a scoped instance.” Registration happens once, globally, and both access patterns stay in sync.
Example 3 — Validation errors
const result = tl.extend({
variants: {
"1bad": "...", // invalid identifier
foo: "color: red; }", // CSS-injection
},
});
console.log(result.errors);
// [
// { key: "1bad", message: "Variant key must be a valid identifier" },
// { key: "foo", message: "Invalid selector: contains '}' or ';'" },
// ]
Instead of silently swallowing a bad key or letting a malformed selector leak raw CSS-injection characters into the generated stylesheet, tl.extend returns a structured errors array you can inspect programmatically. The docs also note these same errors are surfaced as console.warn at runtime, so you get a heads-up even if you never check .errors directly.
Selector forms
| Form | Example | Compiled to |
|---|---|---|
| Pseudo-class | _xyz: ":xyz" |
.tl |
| Pseudo-element | _xyz: "::xyz" |
.tl |
| &-anchored ancestor | _xyz: ".parent &" |
.parent .tl |
| &-anchored sibling | _xyz: ".peer ~ &" |
.peer ~ .tl |
| Media query | _xyz: "@media …" |
@media … { .tl |
| Container query | _xyz: "@container …" |
@container … { .tl |
| Supports query | _xyz: "@supports …" |
@supports … { .tl |
Two rules govern the & token: if your selector is a multi-step selector that uses & (like .parent &), the & gets replaced by the unique generated class. If it’s a parent-style selector with no & at all (like :is(.dark *)), it’s used exactly as written.
Validation rules
Custom variant selectors are validated by validateVariant() (src/compiler/variants.ts), enforcing:
- The selector must be a non-empty string.
- The variant key must be a valid JS identifier (or quoted with
"). - The selector cannot contain raw
;,}, or other CSS-injection characters. -
@media/@container/@supportsrules are explicitly recognized as at-rules.
Why this fits the bigger traceless-style picture
tl.extend‘s “no central config” design isn’t a one-off trick — it’s the same philosophy running through the entire library. traceless-style is a zero-runtime atomic CSS toolkit for React, Next.js, Vite, Remix, Astro, SvelteKit, Qwik, and Solid, built around build-time extraction instead of a runtime CSS-in-JS engine — no Tailwind dependency, no Babel plugin required.
npm install traceless-style
npx traceless-style init
init detects your framework, wires the bundler plugin, and generates your CSS entry — the same two-pass compiler that scans for tl.extend calls is also what powers:
-
WCAG 2.1 + 2.2 contrast validation on every build — AA 4.5:1 / AAA 7:1 / UI 3:1 / focus 3:1 enforced before CSS ever hits disk, with an APCA Lc readout in every diagnostic and an interactive
--fix-contrastprompt. -
Auto dark mode — every color you write gets a derived dark variant via
, with pair-aware contrast preservation, overridable per-block via_dark. -
Auto RTL — physical properties like
marginLeftrewrite to logical equivalents likemarginInlineStartat build time, so one stylesheet serves every script direction. - Strict-by-default lint — inline styles, string classNames, CSS Modules, and Tailwind utilities are blocked outright, backed by a property allowlist and value-injection guards.
-
Diagnostic codes — every error and warning carries a stable
TLS####identifier you can grep and link to docs. -
Two parsers — a zero-native-deps scanner for smaller projects, and an SWC-backed AST extractor that’s nearly 2× faster on 500-file codebases, with
autopicking the right one for you.
How it stacks up against the usual suspects, straight from the project’s own comparison:
| Tailwind | styled-components | CSS Modules | traceless-style | |
|---|---|---|---|---|
| Zero runtime | ✓ | ✗ | ✓ | ✓ |
| Type-safe styles | ✗ | partial | partial | ✓ |
| Atomic dedup | ✓ | ✗ | ✗ | ✓ |
| WCAG contrast at build time | ✗ | ✗ | ✗ | ✓ |
| Auto dark mode | ✗ | manual | manual | ✓ |
| Auto RTL (logical properties) | ✗ | manual | manual | ✓ |
| No bundler plugin to install | ✗ | ✓ | ✓ | ✓ (zero-config init) |
There’s also tooling around all of this: a VS Code extension (autocomplete across 280+ properties, inline color swatches, hover docs, quick-fix diagnostics) and a DevTools browser extension for live class inspection and cascade conflict warnings.
Wrap-up
tl.extend is a small API with an outsized consequence: it lets a design system’s vocabulary grow the same way the codebase does — organically, file by file — instead of forcing every new breakpoint or brand selector through a single config bottleneck. Combined with the validation layer catching bad identifiers and injection attempts before they reach your stylesheet, it’s a genuinely safe way to extend an atomic CSS system at scale.
Full docs: https://github.com/sparkgoldentech/traceless-style