A deep dive into st-core@v2.fscss — how a pure-CSS chart module is assembled with FSCSS: tokens, inverted Y, array indexes, inline(), and dual-pass strokes.
Requires: FSCSS ≥ 1.2.3
Repo: github.com/fscss-ttr/st-core.fscss
What you’re building
Not a JS chart engine. A set of @define mixins that:
- Install design tokens
- Map a data array →
--st-p1…N(CSS Y positions) - Paint fill and line with
clip-path: polygon(...) - Optionally add grid, dots, stat cards, phone shell
Runtime data changes only CSS variables. Geometry is compile-time (or runtime-expanded) CSS.
Step 0 — Mental model
Human values [42, 58, 65, …] // “higher = better”
│
▼ num(100 - v)%
--st-p1, --st-p2, … // CSS top% (0% = top of box)
│
▼ even X: (i-1)/(N-1) * 100%
clip-path polygon points
│
▼
.area fill + fake “stroke” line + dots
In CSS, y = 0% is the top. A score of 90 should sit near the top → store 10% in --st-p*.
Step 1 — Token root
Everything themable hangs off custom properties.
@define st-root(root:root){`
:@use(root){
--st-bg: #0e0d14;
--st-surface: #161422;
--st-card: #1c1a2e;
--st-accent: #9d7eff;
--st-accent-2: #c4a8ff;
--st-green: #4fffb0;
--st-red: #ff5e7d;
--st-text: #e8e3ff;
--st-muted: #6b6488;
--st-border: rgba(157,126,255,.15);
--st-radius-xl: 40px;
--st-radius-lg: 16px;
--st-pad: 24px;
/* default 8-point chart (overwritten by data) */
--st-p1: 68%; --st-p2: 59%; --st-p3: 70%; --st-p4: 35%;
--st-p5: 58%; --st-p6: 22%; --st-p7: 42%; --st-p8: 55%;
--st-chart-line-width: 1.5px;
}
`}
Usage: @st-root() → usually on :root.
Why: Fill/line/dot only read variables — swap brand without touching polygon math.
Step 2 — Fixed 8-point setter (v1-style helper)
Still useful for demos with a fixed series length:
@define st-chart(p1: 88, p2: 59, p3: 70, p4: 35, p5: 58, p6: 22, p7: 42, p8: 55){
--st-p1: num(100 - @use(p1))%;
--st-p2: num(100 - @use(p2))%;
/* … p3–p8 same pattern … */
}
num(100 - x) is the invert-Y core. v2 generalizes this to any N via arrays.
Step 3 — Array → --st-pN (st-chart-points)
This is the heart of v2: length comes from @arr, not eight parameters.
@define st-chart-points(p){`
inline("
@arr @use(p)-idx[count(@arr.@use(p)!.length, 1)]
empty{ /* preserve */ }
empty-@arr.@use(p)-idx[]{
$idx: @arr.@use(p)-idx[];
--st-p$idx: num(100 - @arr.@use(p)[$idx])%;
}")
`}
| Piece | Role |
|---|---|
p |
Name of the data array (e.g. myData) |
count(@arr.@use(p)!.length, 1) |
Index list 1…N
|
empty{ /* preserve */ } |
Keeps loop output from being eaten as selectors |
--st-p$idx: num(100 - value)% |
One variable per point |
inline("…") |
Emits declarations only so you can call this inside .chart { }
|
Call site:
@arr myData[42, 58, 65, 60, 78, 70, 92]
.chart {
@st-chart-points(myData)
position: relative;
height: 220px;
}
After expand: --st-p1…--st-p7 exist. JS can later overwrite them; the polygon still reads the same names.
Step 4 — Area fill (st-chart-fill)
Close a polygon: along the series, then bottom-right, bottom-left.
@define st-chart-fill(st:.st-chart-fill, p){`
@use(st){
position: absolute;
inset: 0;
@arr @use(p)-idx[count(@arr.@use(p)!.length, 1)]
clip-path: polygon(
inline("{}
empty-@arr.@use(p)-idx[] {
$i: @arr.@use(p)-idx[];
num(<$i - 1> * 100 / <@arr.@use(p)!.length - 1>)% var(--st-p$i),
}
100% 100%,
0% 100%
")
);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--st-accent) 35%, transparent),
transparent
);
}
`}
X formula
Even spacing for any N ≥ 2.
Y = var(--st-p$i) (already inverted).
inline("{} …") — loop body is rule-shaped (empty-@arr… { }); inline() strips braces so only x% y%, fragments sit inside polygon().
HTML:
<div class="chart">
<div class="st-chart-fill"></div>
</div>
@st-chart-fill(.st-chart-fill, myData)
Step 5 — Line as a thin polygon (st-chart-line)
CSS has no “stroke this polyline” for arbitrary points. st-core fakes a stroke:
- Walk forward along the top edge (the visible line).
- Walk back slightly below each point (
y + line-width). - Close the strip.
@define st-chart-line(st:.st-chart-line, p){`
@use(st){
position: absolute;
inset: 0;
@arr @use(p)-idx[count(@arr.@use(p)!.length, 1)]
@arr @use(p)-reversed-idx[@arr.@use(p)-idx!.reverse]
clip-path: polygon(
inline("{}
empty-@arr.@use(p)-idx[] {
$i: @arr.@use(p)-idx[];
num(<$i - 1> * 100 / <@arr.@use(p)!.length - 1>)% var(--st-p$i),
}
")
inline("{}
empty-@arr.@use(p)-reversed-idx[] reverse {
$i: @arr.@use(p)-reversed-idx[];
num(<$i - 1> * 100 / <@arr.@use(p)!.length - 1>)%
calc(var(--st-p$i) + var(--st-chart-line-width)),
}
")
-100% calc(var(--st-p1) + var(--st-chart-line-width))
);
background: var(--st-accent);
}
`}
| Idea | Detail |
|---|---|
| Derived array |
@arr …-reversed-idx[@arr.…-idx!.reverse] (1.2.x) |
| Second pass | Same X, Y shifted by --st-chart-line-width
|
| Width helper |
@st-chart-line-width(2.5px) sets the variable |
Fill and line share --st-p*; only the polygon path differs.
Step 6 — Dots (st-chart-dots / st-chart-dot)
One series of dots — loop selectors:
@define st-chart-dots(st:.st-chart-dot-, p, size: 8px){`
@arr @use(p)-idx[count(@arr.@use(p)!.length, 1)]
empty{ /* preserve */ }
@use(st)@arr.@use(p)-idx[] {
$i: @arr.@use(p)-idx[];
position: absolute;
left: calc(num(<$i - 1> * 100 / <@arr.@use(p)!.length - 1>)% - 6px);
top: calc(var(--st-p$i) - 6px);
%2(width, height[: @use(size);])
border-radius: 50%;
background: #fff;
border: 2.5px solid var(--st-accent);
}
`}
Emits .st-chart-dot-1, .st-chart-dot-2, … positioned on the series.
Single highlight dot — explicit x/y (human y inverted in the mixin):
@define st-chart-dot(st:.st-chart-dot, x:0, y:0, size: 12px){`
@use(st){
position: absolute;
left: calc(@use(x)% - 6px);
top: calc(num(-<@use(y)> + 100)% - 6px);
%2(width, height[: @use(size);])
border-radius: 50%;
background: #fff;
border: 2.5px solid var(--st-accent);
}
`}
Step 7 — Chrome: grid, axes, stat card, phone
These are ordinary layout mixins on the same tokens:
-
st-chart-grid(st, rows, cols)—repeating-linear-gradientmesh -
st-chart-axis-x/st-chart-axis-y— flex label rows -
st-stat-card— label / value / delta (.up/.down) -
st-phone/st-container— device frame and page shell
No new math — they make it look like a product, not a codepen fragment.
Step 8 — Minimal page that uses it
@import((*) from st-core@v2)
@st-root()
@arr sales[42, 58, 65, 60, 78, 70, 92]
@st-chart-fill(.fill, sales)
@st-chart-line(.line, sales)
.chart {
@st-chart-points(sales)
position: relative;
height: 220px;
background: var(--st-surface);
border-radius: var(--st-radius-lg);
}
.fill {
--st-accent: #22c55e;
opacity: 0.25;
transition: clip-path 0.6s ease;
}
.line {
--st-accent: #22c55e;
@st-chart-line-width(2.5px)
transition: clip-path 0.6s ease;
}
<div class="chart">
<div class="fill"></div>
<div class="line"></div>
</div>
Live values from JS:
const el = document.querySelector('.chart');
el.style.cssText = values
.map((v, i) => `--st-p${i + 1}: ${100 - v}%;`)
.join(' ');
Design rules baked into v2
-
Points once, draw many times —
@st-chart-pointsowns--st-p*; fill/line/dots only consume. -
Index arrays are local —
count(length, 1)next to each mixin; don’t hand-write1,2,3,…. -
inline()at polygon boundaries — loops need{ };polygon()needs commas, not rules. -
empty/empty-preserve — stops the compiler from treating loop output as selectors. - N is fixed at expand time — changing series length in JS needs a compiled template ≥ max N (pad or recompile). Changing values is always fine.
Feature map (source order)
| Define | Role |
|---|---|
st-root |
Tokens + default --st-p*
|
st-chart |
Fixed 8-arg invert helper |
st-chart-points |
Array → --st-pN
|
st-chart-fill |
Area polygon |
st-chart-line |
Dual-pass stroke polygon |
st-chart-line-width |
Stroke thickness token |
st-chart-dot / st-chart-dots
|
Markers |
st-chart-grid / axes |
Background mesh + labels |
st-stat-card |
KPI block |
st-phone / st-container
|
Shell UI |
st-cat-bar-fill |
Horizontal bar |
From scratch checklist
-
@st-rootwith accent +--st-chart-line-width - Data
@arr+@st-chart-points - Fill polygon (series + baseline)
- Line polygon (forward + reverse + width)
- Optional dots/grid
- JS only assigns
--st-pN
You’re not implementing a chart library in JavaScript. You’re compiling a CSS renderer and treating data as theme variables.
Source: st-core@v2.fscss · README.md · templates/admin-dashboard
Invert Y. Index the array. inline the points. Let the browser paint.

Top comments (0)