st-core.fscss draws area, line, and spider/radar charts with pure CSS — clip-path: polygon() and custom properties, no SVG path builders, no canvas, no chart runtime on the critical path.
That design has a practical consequence for apps: updating a chart is rewriting CSS variables. Reactive frameworks already do that well. You do not need a JS chart instance, a virtual DOM subtree for every point, or a third-party wrapper’s internal state.
This article covers:
- How data maps to CSS variables
- Smooth transitions when values stream in
- Patterns for Svelte, React, Vue, and plain JS
Requires FSCSS ≥ 1.2.3 and st-core@v2.
Source: github.com/fscss-ttr/st-core.fscss
How dynamic updates work
Linear charts
@st-chart-points(array) writes inverted Y positions on the host:
--st-p1, --st-p2, … --st-pN
Fill, line, and dots only read those variables. Change the variables → the polygon and markers move.
Spider / radar charts
@st-spider-points(array, rmax) writes polar coordinates:
--st-sx1 / --st-sy1 … --st-sxN / --st-syN
Same rule: points write, renderers read. Multi-series means one host per series so variables do not collide.
What you update at runtime
| Chart | Variables to set |
|---|---|
| Linear |
--st-p1 … --st-pN as percentages (top = 0%) |
| Spider |
--st-sxN / --st-syN via calc() + sin / cos
|
Geometry length (number of vertices) is fixed when FSCSS expands the mixins. Streaming updates should keep the same point count as the compiled polygon, or recompile with a new array length.
Smooth transitions
Because the visible shape is clip-path (and dots use top / left), the browser can interpolate on the compositor:
.chart-fill,
.chart-line {
transition: clip-path 0.3s ease;
}
.chart-dot-1,
.chart-dot-2,
.chart-dot-3,
.chart-dot-4,
.chart-dot-5,
.chart-dot-6,
.chart-dot-7,
.chart-dot-8 {
transition: top 0.3s ease, left 0.3s ease;
}
/* Spider markers */
.spider-dot-1,
.spider-dot-2,
.spider-dot-3,
.spider-dot-4,
.spider-dot-5,
.spider-dot-6 {
transition: top 0.3s ease, left 0.3s ease;
}
Note: Add transition: clip-path 0.3s ease, top 0.3s ease; (and left where needed) on the selectors that own the geometry. When new data points stream in, motion stays native and typically hits 60fps without a JS animation loop.
Optional:
.chart-fill,
.chart-line {
transition: clip-path 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
Helper: write variables from JS
Linear (0–100 scores → CSS %)
/** @param {number[]} values scores 0–100 */
export function linearVars(values) {
return values
.map((v, i) => `--st-p${i + 1}: ${100 - v}%`)
.join(";");
}
// host.style.cssText = linearVars([40, 55, 30, 70, 60]);
// or: host.setAttribute("style", linearVars(values));
Spider / radar
/** @param {number[]} values scores 0–100 @param {number} rmax radius % of box */
export function polarVars(values, rmax = 42) {
const n = values.length;
return values
.map((v, i) => {
const ang = (i * 360) / n;
const r = (v / 100) * rmax;
return (
`--st-sx${i + 1}: calc(50% + (${r} * 1%) * sin(${ang} * 1deg));` +
`--st-sy${i + 1}: calc(50% - (${r} * 1%) * cos(${ang} * 1deg));`
);
})
.join("");
}
Call these when props/store data change. No chart library, no requestAnimationFrame required for the interpolation itself.
Dynamic updates in modern frameworks
Because st-core operates on standard CSS variables, reactive frameworks can pass dataset values straight into the host’s style (or a style binding) without re-mounting chart components or syncing third-party chart state.
Svelte
Compile FSCSS once (CLI or runtime). In the component, bind variables when data changes:
<script>
export let data = [40, 55, 30, 70, 60, 45, 80, 50];
function linearVars(values) {
return values.map((v, i) => `--st-p${i + 1}: ${100 - v}%`).join(";");
}
$: chartStyle = linearVars(data);
</script>
<div class="chart" style={chartStyle}>
<div class="chart-fill"></div>
<div class="chart-line"></div>
{#each data as _, i}
<div class="chart-dot-{i + 1}"></div>
{/each}
</div>
<style>
.chart-fill,
.chart-line {
transition: clip-path 0.3s ease;
}
:global([class^="chart-dot-"]) {
transition: top 0.3s ease, left 0.3s ease;
}
</style>
Stream updates:
data = await fetchLiveSeries(); // same length → smooth morph
Spider host:
<div class="spider" style={polarVars(skills, 42)}>
<div class="spider-grid"></div>
<div class="spider-spokes"></div>
<div class="spider-fill"></div>
<div class="spider-line"></div>
{#each skills as _, i}
<div class="spider-dot-{i + 1}"></div>
{/each}
</div>
React
import { useMemo } from "react";
function linearVars(values) {
return values.map((v, i) => `--st-p${i + 1}: ${100 - v}%`).join(";");
}
export function AreaChart({ data }) {
const style = useMemo(() => linearVars(data), [data]);
return (
<div className="chart" style={parseCssText(style)}>
<div className="chart-fill" />
<div className="chart-line" />
{data.map((_, i) => (
<div key={i} className={`chart-dot-${i + 1}`} />
))}
</div>
);
}
/** cssText → React style object for custom properties */
function parseCssText(cssText) {
return Object.fromEntries(
cssText
.split(";")
.filter(Boolean)
.map((pair) => {
const [k, v] = pair.split(":");
return [k.trim(), (v ?? "").trim()];
})
);
}
Or set the host with a ref (avoids parsing):
import { useEffect, useRef } from "react";
export function AreaChart({ data }) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
ref.current.style.cssText = linearVars(data);
}, [data]);
return (
<div className="chart" ref={ref}>
<div className="chart-fill" />
<div className="chart-line" />
{data.map((_, i) => (
<div key={i} className={`chart-dot-${i + 1}`} />
))}
</div>
);
}
Keep transition rules in your global or CSS-module sheet imported once with the st-core output.
Vue 3
<script setup>
import { computed } from "vue";
const props = defineProps({
data: { type: Array, required: true },
});
function linearVars(values) {
return values.map((v, i) => `--st-p${i + 1}: ${100 - v}%`).join(";");
}
const chartStyle = computed(() => linearVars(props.data));
</script>
<template>
<div class="chart" :style="chartStyle">
<div class="chart-fill" />
<div class="chart-line" />
<div
v-for="(_, i) in data"
:key="i"
:class="'chart-dot-' + (i + 1)"
/>
</div>
</template>
<style scoped>
.chart-fill,
.chart-line {
transition: clip-path 0.3s ease;
}
</style>
Vue accepts a CSS text string on :style for custom properties in modern builds; if your toolchain expects an object, split the same way as the React parseCssText helper.
Plain JS / live metrics
const host = document.querySelector(".chart");
async function tick() {
const series = await api.latestWindow(); // length N
host.style.cssText = linearVars(series);
}
setInterval(tick, 2000);
With the transition CSS above, each tick morphs the area/line without tearing down nodes.
Multi-series
Each series needs its own host (same rule as static st-core):
<div class="series-a" style="/* --st-p* for A */">
<div class="fill-a"></div>
<div class="line-a"></div>
</div>
<div class="series-b" style="/* --st-p* for B */">
<div class="fill-b"></div>
<div class="line-b"></div>
</div>
elA.style.cssText = linearVars(seriesA);
elB.style.cssText = linearVars(seriesB);
Spider multi-series: one polarVars(...) per host, distinct --st-accent / opacity tokens as needed.
Checklist
- Compile st-core mixins for a fixed N (array length).
- Render host + fill/line/dots (dots need real DOM nodes).
- Add
transition: clip-path(andtop/leftfor dots). - On data change, set
--st-p*or--st-sx*/--st-sy*only. - Keep N stable while streaming for smooth morphs.
Why this stays lightweight
| Typical chart lib | st-core + framework |
|---|---|
| Chart instance + diff | CSS variables on a host |
| Re-render path / SVG | Same DOM, new custom properties |
| Animation tween in JS | Optional CSS transition
|
| Heavy dependency | FSCSS output + your existing UI framework |
Reactive frameworks already schedule updates when props or stores change. st-core simply consumes those updates as style, which is why dynamic dashboards stay small and predictable.
Further reading
- st-core.fscss — API, linear + spider examples
- Dual-pass strokes and
clip-pathbands — same variables drive both fill and line
Smooth transitions note:
transition: clip-path 0.3s ease, top 0.3s ease; (plus left for polar dots) on your chart selectors gives native, high-frame-rate motion whenever new data points stream in — no chart animation API required.
Thanks for reading
Top comments (0)