st-core.fscss v2: Full Release Notes & Technical Reference
Pure CSS statistical dashboard components. No JavaScript dependencies. No SVG. No canvas.
st-core.fscss is a CSS visualization system built on the FSCSS ecosystem. It renders area charts, multi-line charts, stat cards, and dashboard chrome using nothing but clip-path: polygon(), repeating-linear-gradient(), and CSS custom properties. This post covers the full v2 release: what changed, every mixin the library ships, and how the underlying array-to-polygon math actually works.
MIT licensed. Repo: github.com/fscss-ttr/st-core.fscss. Requires FSCSS v1.2.3+.
What's New in v2
v1 charts were locked to exactly 8 fixed data points, mapped onto hardcoded variables --st-p1 through --st-p8. v2 removes that ceiling:
- Dynamic datasets (
@arr) — pass arrays of any length: 5 points, 12, 50+. - Automatic X-spacing — points distribute evenly across
0%to100%based onarray.length, no manual positioning. - Inline normalization — natural 0–100 values get normalized to CSS percentages directly inside the style calculations.
- Dual-edge polygon polyline — line strokes are drawn as a closed top-to-bottom band rather than a hack around
border, giving precise stroke rendering.
The v1 API (@st-chart, 8 named params) is still available for anyone with a genuinely fixed 8-point series who doesn't want to declare an array.
Installation
Runtime / CDN mode — for prototyping, drop this in your <head>:
<script src="https://cdn.jsdelivr.net/npm/fscss@1.2.3/runtime.min.js" async></script>
Then import the module in your <style> block:
@import((*) from st-core@v2)
CLI / compiled mode — for production, compile .fscss straight to .css:
npm install -g fscss@latest
fscss input.fscss output.css
VS Code syntax highlighting and auto-compile: FSCSS Support extension.
How It Works, Conceptually
Every chart is a polygon. Define an array, apply a renderer mixin against it, done:
@arr myData[50, 10, 97, 35, 66, 50, 80, 54, 70, 60]
@st-chart-fill(.chart-fill, myData)
@st-chart-line(.chart-line, myData)
The mixin reads the array's length to compute horizontal steps, reads normalized Y values to compute vertical position, and emits a clip-path: polygon(). Two responsibilities are deliberately kept separate:
- Renderers (
@st-chart-fill,@st-chart-line,@st-chart-dots) declare shape. They take an array only to know how many points to loop over. @st-chart-points(array)declares values. It's the only mixin that writes--st-p1…--st-p{n}onto an element. Every renderer only reads those variables.
Because these are ordinary inherited custom properties, any child element that skips @st-chart-points inherits values from the nearest ancestor that called it. That's convenient for a single-series chart (call it once, on the container) and mandatory to override for multi-series charts (each series needs its own call, or it silently renders its neighbor's data).
Full Mixin Reference
@st-root() — Design Tokens
@st-root() /* targets :root */
@st-root(root.class...) /* targets a custom scope */
Initializes every color, radius, and spacing token the rest of the library reads via var(--st-*). Nothing downstream hardcodes a color. It also seeds --st-p1…--st-p8 with placeholder defaults — a safety net so a component reading --st-p$i before @st-chart-points has run gets a plausible shape instead of a broken layout, rather than undefined.
@st-container(selector) — Viewport Wrapper
@st-container(body)
Centers content in the viewport with the design-token background and text color. Defaults to body.
@st-phone(selector) — Device Frame
@st-phone(.wrapper)
A 360px-wide rounded card frame with layered shadow, meant to mimic a phone-sized dashboard surface. overflow: hidden keeps chart fills and lines from bleeding past the rounded corners.
@st-chart-points(array) — Value Normalizer
.chart {
@st-chart-points(myData)
}
Builds a throwaway index array the same length as your data, loops over it, and for each index writes:
--st-p{i}: (100 - value_at_i)%
The inversion matters because CSS boxes render top-down (0% is the top) while chart data conceptually grows bottom-up. This is the only mixin in the library that writes --st-p* from an arbitrary-length array — call it once per dataset, on whichever element needs those values (container, or a specific child for a second series).
@st-chart-fill(selector, array) — Area Fill
@st-chart-fill(.chart-fill, myData)
Builds the same index-array loop as @st-chart-points, but uses it to emit clip-path: polygon() stops instead of variables. X-position per point is computed inline as (i - 1) * 100 / (length - 1), spreading points evenly regardless of array length. Y-position reads var(--st-p{i}) — it never touches raw array values directly. The loop generates only the top edge of the shape; 100% 100%, 0% 100% closes it down to the bottom corners. The fill itself is a top-to-bottom gradient from translucent --st-accent to transparent, layered on top of the polygon shape.
@st-chart-line(selector, array) — Polyline Stroke
@st-chart-line(.chart-line, myData)
clip-path has no native stroke concept, so this mixin fakes one by drawing a thin closed band: the top edge follows the data forward (left to right), the bottom edge follows the same data in reverse (right to left), offset downward by --st-chart-line-width. Walking the second pass in reverse is what closes the polygon without the shape crossing itself. The band is filled solid with var(--st-accent), which reads visually as a line with real thickness.
@st-chart-line-width(value) — Stroke Width Override
.chart-line {
@st-chart-line-width(2.5px);
}
Overrides the 1.5px default set in @st-root. Since @st-chart-line reads var(--st-chart-line-width) at draw time, this is also changeable live via element.style.setProperty('--st-chart-line-width', '3px') without recompiling anything.
@st-chart-dot(selector, x%, y%, size) — Single Manual Marker
@st-chart-dot(.chart-dot, 70, 60, 12px)
Unlike the renderer mixins above, this takes explicit coordinates rather than an array — for annotating one specific point (a peak, a tooltip anchor) independent of any dataset. Applies the same top-down inversion (100 - y) inline rather than through the variable system, and centers the marker on the coordinate via a -6px offset on both axes.
@st-chart-dots(prefix, array, size) — Auto-Generated Markers
@st-chart-dots(.dot-, myData, 8px)
Generates .dot-1, .dot-2, ... .dot-N — one rule per array index. Internally it writes top twice: once computed directly from the raw array value (a self-contained fallback with no dependency on --st-p*), then again from var(--st-p{i}). CSS resolves duplicate declarations in source order, so the second write wins at render time — meaning @st-chart-dots, despite having a self-sufficient fallback, still effectively requires @st-chart-points(array) to have run somewhere in scope for correct vertical positioning. Horizontal positioning is fully self-sufficient either way, since it's computed from array length alone.
@st-chart-grid(selector, rows, cols) — Background Grid
@st-chart-grid(.chart-grid, 10, 7)
No polygons involved — two stacked repeating-linear-gradient()s do the work. One draws horizontal lines every 100% / rows, the other vertical lines every 100% / cols, each a hard 1px stripe followed by transparent space. The two axes are tinted differently (--st-muted vs --st-accent) as a subtle visual cue, and the whole thing sits at opacity: .2 so it reads as reference lines rather than competing with the actual data.
@st-chart-axis-x(selector) / @st-chart-axis-y(selector) — Axis Wrappers
@st-chart-axis-x(.x-axis)
@st-chart-axis-y(.y-axis)
Pure layout, no data dependency — they just space out hand-written <span> labels. The X axis is a horizontal flex row with justify-content: space-between. The Y axis uses flex-direction: column-reverse so a 0, 20, 40...100 label list reads correctly bottom-to-top against a chart where 0% is the top of the box, and is positioned absolute to overlay the chart rather than push content beside it.
@st-stat-card(selector) — Stat Card Component
@st-stat-card(.stat-card)
One call generates five rules at once: the card container plus three fixed inner-class conventions — .st-stat-label, .st-stat-value, .st-stat-delta — and two delta-direction modifiers, .up (green) and .down (red). The mixin owns that internal naming contract, so markup only needs to use the fixed class names inside a .stat-card.
@st-cat-bar-fill(selector, range) — Progress Bar Fill
@st-cat-bar-fill(.bar-fill, 75)
Unrelated to the line/area system — for horizontal progress or category bars. range is a plain percentage, written to --st-cat-bar-fill-range and consumed as width. transform-origin: left is preset in case you want to animate the fill in with a scaleX transform from zero.
Updating Charts from JavaScript
Pure CSS rendering doesn't mean the data has to be static. Updating a chart at runtime is just rewriting the --st-p{n} variables directly:
const chartLine = document.querySelector(".chart-line");
const normalize = (n) => (100 - n) + '%';
function updatePoints(pointsArray) {
const cssVars = pointsArray
.map((v, i) => `--st-p${i + 1}: ${normalize(v)};`)
.join(' ');
chartLine.style.cssText = cssVars;
}
updatePoints([50, 20, 85, 40, 95]);
Add transition: clip-path 0.6s cubic-bezier(0.4, 0, 0.2, 1); to .chart-fill / .chart-line, and dataset changes animate through the compositor thread, untouched by layout or paint.
Worked Examples
Basic area + line chart
<style>
@import((*) from st-core@v2)
@st-root()
@arr myData[20, 45, 28, 80, 65, 90, 40]
@st-chart-fill(.chart-fill, myData)
@st-chart-line(.chart-line, myData)
.chart {
@st-chart-points(myData)
position: relative;
height: 200px;
width: 100%;
max-width: 400px;
background: var(--st-surface);
border-radius: 16px;
}
</style>
<div class="chart">
<div class="chart-fill"></div>
<div class="chart-line"></div>
</div>
Multi-line, multi-area with independent series
@arr seriesA[30, 50, 75, 40, 85, 60]
@arr seriesB[10, 25, 45, 20, 55, 30]
@st-chart-fill(.fill-a, seriesA)
@st-chart-line(.line-a, seriesA)
@st-chart-fill(.fill-b, seriesB)
@st-chart-line(.line-b, seriesB)
.chart {
@st-chart-points(seriesA)
position: relative;
height: 220px;
width: 100%;
background: var(--st-bg);
}
.fill-a { @st-chart-points(seriesA) opacity: 0.6; --st-accent: #9d7eff; }
.line-a { @st-chart-points(seriesA) --st-accent: #9d7eff; }
/* .fill-b / .line-b MUST set their own points — otherwise they
inherit .chart's seriesA values and render the wrong data */
.fill-b { @st-chart-points(seriesB) opacity: 0.3; --st-accent: #4fffb0; }
.line-b { @st-chart-points(seriesB) --st-accent: #4fffb0; }
<div class="chart">
<div class="chart-fill fill-a"></div>
<div class="chart-line line-a"></div>
<div class="chart-fill fill-b"></div>
<div class="chart-line line-b"></div>
</div>
Full mobile dashboard frame
<style>
@import((*) from st-core@v2)
@st-root()
@st-container(body)
@st-phone(.wrapper)
.wrapper { display: flex; flex-direction: column; gap: 16px; padding: 24px; }
@arr myData[56, 67, 70, 43, 67, 80]
@st-chart-fill(.chart-fill, myData)
@st-chart-line(.chart-line, myData)
@st-chart-dot(.chart-dot, 70, 60)
@st-stat-card(.stat-card)
@st-chart-axis-x(.x-axis)
@st-chart-axis-y(.y-axis)
@st-chart-grid(.chart-grid, 10, 7)
.chart {
width: 100%; height: 200px; border-radius: 20px;
position: relative; overflow: hidden;
background: var(--st-surface);
@st-chart-points(myData)
}
.chart-fill, .chart-line { transition: clip-path 0.8s ease-in-out; }
</style>
<div class="wrapper">
<div class="stat-card">
<div class="st-stat-label">TOTAL EXPENSES</div>
<div class="st-stat-value">$1,326.03</div>
<div class="st-stat-delta up">+5.1% vs last week</div>
</div>
<div class="chart">
<div class="chart-fill"></div>
<div class="chart-line"></div>
<div class="chart-dot"></div>
<div class="chart-grid"></div>
<div class="y-axis">
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span><span>100</span>
</div>
</div>
<div class="x-axis">
<span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span>
</div>
</div>
The One Rule That Ties It Together
| Concern | Who owns it | Depends on --st-p*? |
|---|---|---|
| X-position (horizontal spacing) | Computed inline per mixin, from array length | No |
| Y-position (vertical value) | var(--st-p{i}), written only by @st-chart-points |
Yes |
| Shape (fill / line / dot) | @st-chart-fill / @st-chart-line / @st-chart-dots |
Reads, never writes |
| Values | @st-chart-points(array) |
Writes, on whichever element calls it |
Every renderer only needs the array to know how many points to loop over. The actual values reach the page exclusively through --st-p1…--st-p{n}, inherited down the DOM like any custom property. That's the entire reason a multi-series chart needs @st-chart-points(seriesN) called explicitly on each series' own element: skip it, and that element silently inherits whatever its nearest ancestor last set.
Design Token Reference
| Variable | Default | Usage |
|---|---|---|
--st-bg |
#0e0d14 |
Page body background |
--st-surface |
#161422 |
Surface / container background |
--st-card |
#1c1a2e |
Card component background |
--st-accent |
#9d7eff |
Primary brand accent |
--st-accent-2 |
#c4a8ff |
Secondary accent gradient |
--st-green |
#4fffb0 |
Positive delta state |
--st-red |
#ff5e7d |
Negative delta state |
--st-text |
#e8e3ff |
Primary text color |
--st-muted |
#6b6488 |
Muted labels / grid stroke |
--st-radius-xl |
40px |
Outer device frame radius |
--st-radius-lg |
16px |
Component card radius |
--st-chart-line-width |
1.5px |
Line stroke thickness |
Performance & SEO
- Zero hydration overhead — pure CSS charts need no JS initialization before rendering.
- Tiny compiled size — lightweight CSS shapes and variables, around 0.8kb minified.
- GPU-accelerated transitions — dataset updates through CSS custom properties run on the browser's compositor thread.
MIT licensed, built with FSCSS. Repo and contributions: github.com/fscss-ttr/st-core.fscss.