tscss is a CSS macro/transform system built on top of lightningcss. It lets users execute TypeScript functions inside CSS files to generate declarations, selectors, animations, and more at build time.
npm org: @subwaytime/tscss
Workspace packages: @tscss/core, @tscss/directives, @tscss/helpers, @tscss/vite
Input CSS → lightningcss parse → AST visitor → process directives → serialize → Output CSS
↓
processRule (style rules)
processAtFunctionsInTokens (value functions)
collectRootRules (spawn directives)
- Parser: lightningcss (fast, spec-compliant CSS parser)
- Visitor:
Rule.stylefor at-rules inside selectors,Rule.unknownfor top-level at-rules - Metadata: WeakMap-based store (
meta.ts) tags handlers with type info
packages/
core/ # @tscss/core — transform engine
constant.ts # global TSCSS constant for error prefixes
index.ts # exports: transformCSS, inlineCSS, generateId, defineHelper, types
transform.ts # main pipeline: preprocess → lightningcss → collect root rules
types.ts # Handler, FunctionHandlers, HandlerResult
utils/
defineHelper.ts # defineHelper() factory — creates directives with alias/schema support
meta.ts # WeakMap metadata store (setMeta, getMeta)
processRule.ts # AST visitor for style/unknown/nested-declarations rules
processDeclarationsBlock.ts # value function replacement inside declarations
processNestedDeclarations.ts # thin wrapper for nested declarations
helpers.ts # token utilities: argsToStrings, getArgsFromPrelude, normalizeValue
processAtFunctions.ts # token-level @name(...) handler inside declaration values
tokenUtils.ts # serializeTokenOrValue, resolveVarTokens, tokensToString
parseRule.ts # parseRule() and parseValueToTokens() via lightningcss
id.ts # generateId() via nanoid
inlineCSS.ts # recursive @import inlining with cycle detection
directives/ # @tscss/directives — directive factories
index.ts # exports: defineHelper, cssVar, declaration, selector, media, keyframes, fontFace
cssVar.ts # custom properties (with typed @property support, random names)
declaration.ts # fixed property wrapper
selector.ts # selector variant generator (&:hover)
media.ts # @media query wrapper
keyframes.ts # @keyframes animations (spawn-based)
fontFace.ts # @font-face + utility class (spawn-based)
helpers/ # @tscss/helpers — pre-built directives
index.ts # single barrel: exports all directives
background/ # one file per helper
bg.ts, checkerboard.ts, dotPattern.ts, grid.ts,
horizontalLines.ts, paper.ts, verticalLines.ts
border/
perfectBorder.ts
colors/ # one file per helper
black.ts, complement.ts, darken.ts, grayscale.ts, lighten.ts,
mix.ts, opacity.ts, saturate.ts, tint.ts, white.ts
math/ # one file per helper
abs.ts, lerp.ts, negate.ts, ratio.ts
layouts/ # one file per helper
autoGrid.ts
transforms/ # one file per helper
pxToRem.ts, remToPx.ts,
toHsl.ts, toHwb.ts, toOklab.ts, toOklch.ts, toRgb.ts
unified/ # dispatch helpers
bg.ts, format.ts, mod.ts
types/ # TypeScript type utilities (Px, Rem, Color, etc.)
vite/ # @tscss/vite — Vite plugin
index.ts # tscss plugin with inlineCSS, loadFromDir, HMR
Every directive is created via defineHelper(options):
export interface DefineHelperOptions {
type?: string; // 'var' | 'declaration' | 'selector' | 'media' | 'function'
property?: string; // auto-wrap bare at-rules with this property
name?: string; // custom property name (for type='var')
inline?: boolean; // extract to :root when false (for type='var')
spawn?: () => string; // generates root-level CSS (keyframes, fontFace, @property)
typedProperties?: TypedPropertyConfig[]; // typed @property rules for custom properties
alias?: string | string[]; // alternate at-rule names for this directive
schema?: TSchema; // valibot schema for runtime argument validation
vars?: (args, ctx) => string[]; // custom property declarations (e.g. --name: value;)
value: (args, ctx) => string | string[]; // main handler function
}- Bare at-rule inside selector:
@paper(50px);→ auto-wraps withproperty - Value function in declaration:
color: @mix(red, blue, 50);→ replaces inline - Spawn for root-level CSS:
@fadeIn;→@keyframes fadeIn { ... }at top level
Directives expose .value for string-level composition:
const v = verticalLines.value(['20px']); // returns raw stringmeta.ts uses a WeakMap<Handler, HandlerMeta> to store type info:
interface HandlerMeta {
type: string; // 'var' | 'declaration' | 'selector' | 'media' | 'function'
property?: string; // for declaration auto-wrap
name?: string; // custom property name
inline?: boolean; // extract to :root
typedProperties?: TypedPropertyConfig[]; // typed @property rules
aliases?: string[]; // expanded alternate names
}processRule.ts reads meta to decide how to handle each directive:
type === 'selector'→ generates new style rule with modified selectortype === 'declaration'→ wraps result withproperty: value;type === 'var'withvars→ emitsvarsdeclarations (or extracts to :root)type === 'var'withoutvars→ falls back to manual--name: value;wrappingtype === 'media'→ wraps parent rule in @media queryhandler.spawn→ skips inline, CSS collected bycollectRootRulestype === 'declaration'withvars+inline: false→ extracts vars to :root
- Bundler: tsdown (Rolldown-powered, fast TypeScript bundler)
- Config:
tsdown.config.tsat root — builds all 4 packages into./dist/ - Format: ESM only (no CJS)
- Workspace: Bun workspaces (
packages/*)
- Runner: vitest (via
bun testwhich runsvitest) - Files:
tests/core.test.ts— defineHelper, bare args, schema validation, blocked aliasestests/directives.test.ts— cssVar, declaration, selector, media, keyframes, fontFacetests/helpers/— background, color, transform, border, layout, unified, integration tests (one file per category)tests/vite.test.ts— loadFromDir and inlineCSS tests
- Current: 60+ tests (all passing)
- No separate
codegen.ts— CSS string generation is handled by lightningcss serialization after AST mutation. - WeakMap metadata — avoids polluting handler functions with properties.
spawnoverroot: true—spawnis a direct property on the handler, not stored in meta. Keeps root CSS generation co-located with the directive factory via closure.- String-level helpers are directives — all helpers in
@tscss/helpersare directives (no separate string utilities). Composition uses.value. - Bare argument syntax — quotes are optional:
@mix(red, blue, 50)works the same as@mix('red', 'blue', '50'). - ESM-only — no CJS builds. All consumers are assumed to be modern ESM (Vite, Bun, Node ESM).
- One file per helper — helpers are organized in category folders (
background/,colors/, etc.) with no per-category barrel files. generateId(4)for custom properties — nanoid-generated suffixes avoid collisions better than incrementing counters.BLOCKED_ALIASES— reserved aliases can be blocked from user-defined directives.varis auto-injected bydefineHelperwhentype === 'var', bypassing the block. This prevents any user directive from claimingvarwhile keeping the array clean.
- Create file in
packages/directives/(e.g.,myDirective.ts) - Export factory function that returns
defineHelper({ ... }) - Add export to
packages/directives/index.ts - Add to
packages/vite/index.tsre-exports - Add tests in the appropriate
tests/*.test.tsfile
- Create file in the appropriate
packages/helpers/<category>/folder - Use
defineHelper({ schema: v.tuple([...]), value: (...) })for runtime validation - Add export to
packages/helpers/index.ts - Add tests in
tests/helpers.test.ts
packages/helpers/layouts/— reserved for user (empty)packages/helpers/transitions/— reserved for user (empty)