Building an FSCSS Module

Experience writeup — what actually happened when turning two raw CSS components into proper, reusable modules.

Notes from taking two raw CSS components (a Siri-style blob loader and a flowing gradient wave) and turning them into proper, reusable FSCSS modules: siri-wave.fscss and flux-wave.fscss.

Written before opening the PR to list them on fscss.devtem.org/libraries, so the process is repeatable for the next module.

Module anatomy that held up

Every working module in the ecosystem (st-core.fscss, and now these two) follows the same shape:

  1. A root tokens mixin (@define X-root(root:root){...} or X-tokens(...)) — a single @define that writes every custom property the module needs as CSS variables on a selector, :root by default. Called once, before anything else.
  2. A base reset, scoped to the component’s own selector (@use(st), @use(st) *{...}), never a bare * global reset. A global reset bundled inside a component module means every additional module imported on the same page re-applies its own global reset — redundant at best, a silent override fight at worst.
  3. Structural mixins, one per visual piece (container, line, dot, band, card…), each taking a named selector parameter so the same mixin can be reused under different class names on the same page.
  4. A one-call composite (X-preset(st:...)) that wires the above together for the common case, while the individual mixins stay available for anyone who wants to compose their own layout.

@define blocks: what actually breaks

The syntax itself is forgiving. The mistakes that cost real debugging time were structural, not syntactic.

Don’t wrap a child mixin call in a selector block it doesn’t need

A mixin like sk-line(st, w, h) already builds its own full selector internally. Wrapping the call in another selector block:

FSCSS — wrong
@use(st) .sk-line-1{
  @sk-line(.sk-line-1, 100%)   /* wrong */
}

produces a doubled, non-matching selector in the compiled output (something like .sk-body .sk-line-1 .sk-line-1). This compiles without error and produces valid CSS — it just never matches anything in the actual markup. No warning, no red squiggle, just styles that silently don’t apply.

The fix is to call the child mixin directly with the full selector as its argument:

FSCSS — right
@sk-line(@use(st) .sk-line-1, 100%)   /* right */

Watch for stray leading punctuation in the root block

A recurring one across modules: a stray leading colon producing :@use(root){ instead of the intended selector. Worth a visual scan of every root mixin before shipping.

A declared parameter that’s never referenced is a silent no-op

sk-list(st, count:5) compiling fine while its body hardcodes count(5) internally is the clearest example: calling @sk-list(.feed, 6) compiles, runs, and renders exactly 5 items anyway — no warning that the 6 you passed went nowhere.

The only way to catch this is reading the mixin body and checking every declared parameter actually appears inside it via @use(...).

Arrays and loops

The trigger for @arr.name[] to expand as a loop (rather than resolve to a single value) is the array reference sitting in selector position, followed by a block:

FSCSS
div:nth-child(@arr.colors[]){
  color: @arr.colors[];
}

This is the pattern from the docs themselves, and it’s the one that reliably fired across every mixin tested. A bare reference inside a mixin call’s arguments, with no accompanying selector-plus-block, was inconsistent enough in practice that it’s not worth relying on. If a loop needs to run N times, give it a selector position to run in.

The index-array pattern (parallel arrays)

For looping with more than one parallel array:

FSCSS
@arr colors[magenta, cyan, green, purple, orange, blue]
@arr colors-i[count(6,1)]

@use(st).@arr.colors[@arr.colors-i[]]{
  /* ... */
}

Declare the values array, declare a plain index array from count(n), then index into the values array through the index array inside the selector-plus-block. This is how st-core.fscss’s st-chart-dots() generates its eight dots, and it’s what both wave modules ended up converging on independently.

count() has a real, undocumented limit

count() needs a literal number at compile time. It does not resolve an expression that still contains an unresolved parameter:

FSCSS — does not work
@arr flux-i[count(num(@use(count)),1)]   /* does not resolve */

Confirmed by sprinkling exec(_log, "...") calls around the expansion and watching what actually printed at compile time versus what was expected — num(@use(count)) never resolved to a plain number before count() needed it.

This fails silently in the same way as the doubled-selector bug: no compiler error, just an array that doesn’t behave as intended (in this case, one that doesn’t expand at all, or expands with the literal, un-evaluated expression text).

The workaround

Don’t try to make the array’s size dynamic through a parameter. Make the array’s name swappable instead, defaulting to an internally declared array with a real literal count:

FSCSS — works
@define flux-bands(st:.wave, counter: flux-i){`
  @arr flux-i[count(4,1)]

  @use(st).wave-@arr.@use(counter)[]{
    /* ... */
  }
`}

A caller who wants a different count pre-declares their own array (with its own real literal count() call) and passes its name in through counter. This sidesteps the limitation entirely.

Tradeoff: if you extend the count this way, any per-item tokens (like --flux-band5-bg) still need to be declared by hand somewhere — the loop only generates the selector blocks, it doesn’t invent data.

Debugging technique that actually worked

exec(_log, "message") was the only reliable way to see what the compiler was actually doing at a given point, since FSCSS fails silently far more often than it errors.

When something compiles but doesn’t render as expected, the fix isn’t to stare at the FSCSS source harder — it’s to drop exec(_log, ...) calls around the suspect expansion and check what actually printed versus what was assumed.

FSCSS
exec(_log, "before loop")
@arr colors-i[count(6,1)]
exec(_log, "@arr.colors-i")
/* check console for the actual expanded value */

Naming, to avoid collisions

  • Keyframe names need a module-specific prefix. pulse collides the moment a second animated module lands on the same page. siri-pulse, flux-flow — namespaced from the start, cost nothing and prevent a real bug later.
  • Custom property names need the same treatment. The original wave-container bug (tokens declared as --siri-wave-container-width, read back as --wave-container-width) only happened because of a naming mismatch, but the deeper lesson is that generic prefixes like --wave-* are exactly the kind of name a second, unrelated module is likely to reuse. Prefix with the module’s own short namespace, consistently, everywhere.
  • Import / module names can’t be scoped. FSCSS’s @import((*) from name) only accepts a bare module name or a quoted URL/path — there’s no @variant or /variant syntax. Two related components (siri-wave, flux-wave) have to ship as two fully separate module names; there’s no way to publish them as variants of one shared wave name at the import level. Family relationships have to live in naming and documentation instead.

Checklist for the next module

Before you open the PR

  • Root tokens mixin — one custom-property-writing block, called once
  • Base reset scoped to the component’s own selector, never global *
  • Every structural mixin takes a selector param
  • Every declared parameter is actually referenced via @use(...) somewhere in the body
  • No child mixin call gets wrapped in a selector block it doesn’t need
  • Any loop uses the selector-position-plus-block pattern, not a bare call-argument reference
  • Any count() call uses a literal number, not an expression built from @use(...)
  • Keyframe names and custom property names are prefixed with the module’s own namespace
  • exec(_log, ...) used during development to confirm expansions actually did what was intended

Ship the next one

These patterns held up across st-core, siri-wave, and flux-wave. Use the checklist, keep the namespace consistent, and reach for exec(_log, ...) the moment something compiles but doesn’t render.