Building an FSCSS Module
Experience writeup from siri-wave.fscss and flux-wave.fscss — what still holds, updated for FSCSS 1.2.6.
Notes from turning two raw CSS components (a Siri-style blob loader and a flowing gradient wave) into reusable modules. The anatomy and silent-failure habits below are still the real lessons.
Annotated for 1.2.x: arrays in declarations, count(@use(...)) and
count(@arr.x!.length), empty{/* preserve */}, stronger
@import, and IDE libck / xfcmd.
See also
create-module ·
modules-guide ·
xfcmds.
Module anatomy that held up
Working modules in the ecosystem (st-core, these two, and later packs) share the same shape:
-
Root tokens —
@define X-root(root:root){...}writes every custom property the module needs on a selector (:rootby default). Call once before anything else. -
Scoped base reset —
@use(st), @use(st) *{...}, never a bare global*. A global reset inside a component module fights the next module on the same page. Shared helper:scoped-resetfromfscss:micros. - Structural mixins — one per visual piece (container, line, band…), each with a selector parameter so the same mixin can target different classes.
-
Optional preset —
X-preset(st:...)wires the common case; individual mixins stay available for custom composition.
@define blocks: what actually breaks
The syntax is forgiving. The mistakes that cost real time were structural.
Don’t wrap a child mixin call in a selector block it doesn’t need
If sk-line(st, w, h) already builds its full selector internally,
wrapping the call doubles the selector:
@use(st) .sk-line-1{
@sk-line(.sk-line-1, 100%) /* wrong — doubles selector */
}
Compiles cleanly, matches nothing. Call the child with the full selector as the argument:
@sk-line(@use(st) .sk-line-1, 100%)
Stray leading punctuation in the root block
Watch for :@use(root){ vs the intended selector form. Scan every root mixin before ship.
Declared parameter never referenced = silent no-op
sk-list(st, count:5) with a body that hardcodes count(5) means
@sk-list(.feed, 6) still renders five items. Every parameter should appear via
@use(...) in the body.
@defines;
as renames only in that file (original name undefined there).
Duplicates / cycles become CSS comments, not infinite loops —
@import guide.
Arrays and loops
@arr.name[] expands as a loop when the reference sits in
selector position, followed by a block:
div:nth-child(@arr.colors[]){
color: @arr.colors[];
}
1.2.x: arrays also resolve cleanly inside declarations — derived arrays,
method results as new sources (!.reverse, !.list, !.length,
!+[…], !-[index]). Use selector+block when you need N rules;
use declaration-time arrays for tokens and parallel data.
When generated rules must not be eaten as a selector wrapper:
empty{ /* preserve */ }
/* loop / generated rules */
Index + values (parallel arrays)
@arr colors[magenta, cyan, green, purple, orange, blue]
@arr colors-i[count(@arr.colors!.length, 1)]
@use(st).band-@arr.colors-i[]{
/* use @arr.colors[@arr.colors-i[]] etc. */
}
Same idea as st-core@v2 chart points. Shared helper:
@mirror(data, idx) from
fscss:micros.
count() with literals, @use, and array length
As of current FSCSS (1.2.x), count() accepts:
- Literals —
count(6),count(6, 1)(end, optional start) - Parameters —
count(@use(n)),count(@use(n), 1)inside@define - Array length —
count(@arr.data!.length),count(@arr.data!.length, 1)
@define bands(st:.wave, n:4){`
@arr idx[count(@use(n), 1)]
@use(st) .band-@arr.idx[]{
/* n items, driven by @use(n) */
}
`}
@bands(.hero-wave, 6)
@arr data[10, 20, 30, 40, 50]
@arr idx[count(@arr.data!.length, 1)]
/* or */
@import((mirror) from fscss:micros)
@mirror(data, idx)
count(@arr.x!.length) when the list already exists,
and count(@use(n)) when the caller only passes a size.
Both are supported; pick the API that matches the mixin.
Older builds were less reliable when count() saw unresolved parameter
text — modules from that era used literal count(4) plus a swappable array name.
On 1.2.6+ you can pass size through @use or derive it from
!.length without that workaround.
Debugging technique that actually worked
exec(_log, "message") remains the reliable way to see expansions.
FSCSS still prefers silent empty output over hard failure for some bad expansions —
log around the suspect site and compare console vs compiled CSS.
exec(_log, "before loop")
@arr colors-i[count(@arr.colors!.length, 1)]
exec(_log, "@arr.colors-i")
In the browser IDE: compile, watch console + CSS pane. CLI: write output to a file and search for expected selectors.
Naming, to avoid collisions
-
Keyframe names — prefix (
siri-pulse,flux-flow). Barepulsecollides as soon as a second animated module lands. -
Custom properties — same namespace everywhere
(
--siri-*, not mixed--wave-*/--siri-wave-*). -
Import names — bare module name or quoted URL/path.
Related packs (
siri-wave,flux-wave) stay separate module names; family ties live in docs and naming, not a variant path syntax. -
package.json —
namedriveslibck my-module;file.usage.directiveshould match prefixes, or use""if helpers are mixed.
Publish shape & IDE enable
Beyond the .fscss source: package.json with
file.modules, file.remote, helpers, and optional
xfcmd.enable / disable so the IDE can run:
libck my-module
yes
Details: modules guide · create-module · xfcmds guide.
Checklist for the next module
Before you open the PR
- Root tokens mixin — one block, called once
- Base reset scoped to the component (or
scoped-resetfrom micros) - Structural mixins take a selector param; no doubled wrappers on child calls
- Every declared parameter used via
@use(...) - Loops: selector+block;
empty{/* preserve */}when needed -
count()via literal,@use(n), or@arr.x!.lengthas fits the API - Keyframes and CSS variables prefixed with the module namespace
-
package.json:extension,fscss_version,modules,remote, accuratehelpers - Optional
xfcmds/enable.xfcmd+disable.xfcmdforlibck - Selective import + CLI/IDE compile;
exec(_log, …)on doubtful expansions
Ship the next one
Anatomy, namespacing, and “does this parameter actually get @used?” still decide
whether a module is pleasant to consume. Use count(@use(n)) or
count(@arr.data!.length) freely on current compilers; reach for
exec(_log, …) when the CSS pane is quiet but the page is wrong.