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