DEV Community

Cover image for st-core.fscss Charts: Dynamic Updates in Modern Frameworks
FSCSS tutorial for FSCSS tutorial

Posted on

st-core.fscss Charts: Dynamic Updates in Modern Frameworks

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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("");
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Stream updates:

data = await fetchLiveSeries(); // same length → smooth morph
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()];
      })
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
elA.style.cssText = linearVars(seriesA);
elB.style.cssText = linearVars(seriesB);
Enter fullscreen mode Exit fullscreen mode

Spider multi-series: one polarVars(...) per host, distinct --st-accent / opacity tokens as needed.


Checklist

  1. Compile st-core mixins for a fixed N (array length).
  2. Render host + fill/line/dots (dots need real DOM nodes).
  3. Add transition: clip-path (and top / left for dots).
  4. On data change, set --st-p* or --st-sx* / --st-sy* only.
  5. 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-path bands — 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)