If you've ever pulled in heavy libraries just to render a simple line chart, you know the drill: a few hundred KB of JavaScript, a canvas or SVG element, and a hydration step before anything even shows up on screen. For a lot of dashboards, that's massive overkill.
st-core.fscss takes a different approach. It's a CSS-only visualization system built on FSCSS that renders area charts, multi-line charts, and stat dashboards using nothing but clip-path: polygon() and CSS custom properties. No canvas. No SVG. No chart library shipping down the wire.
Here's how it works, and how to build real charts with it.
The Core Idea
Every chart in st-core is really just a polygon. Give the mixin an array of data points, and it:
- Spaces the points evenly across the chart width (0% to 100%, based on array length)
- Normalizes each value into a Y-position
- Generates a
clip-path: polygon()that traces the line (or fills the area beneath it)
@arr myData[50, 10, 97, 35, 66, 50, 80, 54, 70, 60]
@st-chart-fill(.chart-fill, myData)
@st-chart-line(.chart-line, myData)
That's the whole rendering pipeline. The array can be any length: 5 points, 50 points, doesn't matter.
Getting It Running
For Testing - Drop the runtime in your <head>:
<script src="https://cdn.jsdelivr.net/npm/fscss@1.2.3/runtime.min.js" async></script>
Then import the module inside your <style> block:
@import((*) from st-core@v2)
Prefer a build step for production? The CLI compiles .fscss file straight to production CSS:
npm install -g fscss@latest
fscss input.fscss output.css
Vscode Extention for syntax highlights and auto-compile: FSCSS Support
A Basic Area 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>
Two things worth calling out about how this splits responsibilities:
-
@st-chart-fill/@st-chart-linedeclare the renderer for a selector. The array argument just tells it how many points to expect. -
@st-chart-points(myData)is what actually writes the--st-p1through--st-p{n}values onto an element. Any child that doesn't call it inherits from the nearest ancestor that did — which is exactly what makes the multi-series case below work cleanly.
Multi-Line, Multi-Area Charts
This is where the point-inheritance model pays off. Each series gets its own renderer and its own explicit @st-chart-points call, so nothing bleeds into another series by accident:
@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'd
silently inherit .chart's seriesA values */
.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>
Stack as many series as you want this way. Each one is independently opaque, colored, and pointed at its own array.
Custom Data Points and Markers
For a single highlighted marker (a peak, a tooltip anchor), use @st-chart-dot directly:
/* selector, x%, y%, size */
@st-chart-dot(.chart-dot, 70, 60, 12px)
To mark every point in a dataset automatically:
/* Generates .dot-1, .dot-2 ... .dot-N */
@st-chart-dots(.dot-, myData, 8px)
Combine that with @st-chart-grid(.chart-grid, rows, cols) and the axis wrappers @st-chart-axis-x / @st-chart-axis-y, and you've got a fully annotated chart with zero JS.
Making It Interactive Anyway
Pure CSS rendering doesn't mean the data has to be static. Updating a chart at runtime is just a matter of rewriting the --st-p{n} variables:
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]);
Pair that with a transition: clip-path 0.6s cubic-bezier(0.4, 0, 0.2, 1); on .chart-fill / .chart-line, and dataset changes animate smoothly through the browser's compositor, without touching layout or paint.
Why This Matters
- Zero hydration. The chart is visible the instant CSS parses. No JS has to boot before you see data.
- Tiny footprint. The compiled output is shapes and custom properties, around 0.8kb minified.
-
GPU-accelerated updates. Because everything animates through
clip-pathand CSS variables, transitions run on the compositor thread instead of the main thread.
If you're building something where every kilobyte of JS matters (embedded widgets, low-power devices, SEO-sensitive pages), st-core gets you real, responsive charts without the usual charting-library tax.
st-core.fscss is MIT licensed and built on FSCSS
Top comments (0)