DEV Community

Cover image for From Static to Real-Time: Data Viz with st-core.fscss (Pure CSS Charts)
FSCSS tutorial for FSCSS tutorial

Posted on • Originally published at fscss.devtem.org

From Static to Real-Time: Data Viz with st-core.fscss (Pure CSS Charts)

This is a follow-up to my earlier post (Building a trading dashboard UI with zero JavaScript — just st-core.fscss.)If you haven't read that one, the quick summary: st-core is an FSCSS plugin that lets you declare chart infrastructure — fills, lines, dots, axes, grids — entirely at the CSS layer. No canvas, no chart library.

That post showed you the static side. This one shows you how to make it live.


The "Aha" Moment

The thing that unlocks st-core for real data viz is understanding what the chart actually is under the hood.

The chart shape IS a clip-path polygon.

When st-core compiles your FSCSS, it generates CSS that reads from a set of custom properties — --st-p1 through --st-p8 — and uses them to construct the polygon coordinates. That's it. So updating the chart at runtime is just:

element.style.cssText = `--st-p1: 40%; --st-p2: 75%; --st-p3: 55%; ...`;
Enter fullscreen mode Exit fullscreen mode

One line. No DOM surgery. No re-render cycle. The CSS transition handles the rest.


The Demo: Expenses Chart

Here's the full chart we're building — an analytics card with a live-updating area chart, a stat card, and a peak-tracking dot.

CodePen demo

<!DOCTYPE html>
<html lang="en">
<head>
  <script src="https://cdn.jsdelivr.net/npm/fscss@1.1.24/exec.min.js" async></script>
  <style>

    @import((*) from st-core) @st-root() @st-container(body) .wrapper {
      flex-direction: column;
      gap: 16px;
      width: 100%;
    }

    @st-chart-fill(.chart-fill)
    @st-chart-line(.chart-line)
    @st-chart-dot(.chart-dot, 70, 60)
    .chart {
      width: 100%;
      height: 200px;
      border-radius: 20px;
      @st-chart-points(20, 25, 21, 37, 30, 60, 27, 50)
    }

    @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-fill, .chart-line {
      height: 100%;
      transition: clip-path .9s cubic-bezier(.4, 0, .2, 1);
    }

  </style>
</head>
<body>
  <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"><span class="tooltip">$405.67</span></div>
      <div class="chart-grid"></div>
      <div class="y-axis">
        <span>0</span><span>10</span><span>20</span><span>30</span>
        <span>40</span><span>50</span><span>60</span><span>70</span>
      </div>
    </div>

    <div class="x-axis days">
      <span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span>
      <span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span>
    </div>

  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Directive Breakdown

The chart block

@st-chart-fill(.chart-fill)
@st-chart-line(.chart-line)
@st-chart-dot(.chart-dot, 70, 60)
.chart {
  @st-chart-points(20, 25, 21, 37, 30, 60, 27, 50)
}
Enter fullscreen mode Exit fullscreen mode
Directive What it generates
@st-chart-fill Area fill via clip-path polygon, reads --st-p1..8
@st-chart-line Stroke-only overlay of the same shape
@st-chart-dot Absolute-positioned peak marker, initial position (70, 60)
@st-chart-points Compiles the 8 values into --st-p1 through --st-p8

Support directives

@st-stat-card(.stat-card)        /* label / value / delta slots */
@st-chart-axis-x(.x-axis)        /* wires x-axis label container */
@st-chart-axis-y(.y-axis)        /* wires y-axis label container */
@st-chart-grid(.chart-grid, 10, 7) /* 10 rows × 7 cols grid */
Enter fullscreen mode Exit fullscreen mode

The JS Bridge (19 lines)

This is the entire runtime layer. No chart library, no framework.

const chartFill = document.querySelector(".chart-fill");
const chartLine = document.querySelector(".chart-line");
const chartDot  = document.querySelector(".chart-dot");

// st-core clip-path is top-down, so flip the value
function normalize(n) {
  return (100 - n) + '%';
}

// X positions for the 8 columns
const xStops = ['0%', '14%', '28%', '42%', '57%', '71%', '85%', '100%'];

function update(points) {
  // Set --st-p1..8 directly on the elements
  const vars = points.map((v, i) => `--st-p${i + 1}: ${normalize(v)};`).join(' ');
  chartLine.style.cssText = vars;
  chartFill.style.cssText = vars;

  // Move dot to the peak
  const highest = Math.max(...points);
  const idx = points.indexOf(highest);
  chartDot.style.top  = normalize(highest);
  chartDot.style.left = xStops[idx];
  chartDot.querySelector('.tooltip').textContent = `$${(highest * 5.5).toFixed(2)}`;
}
Enter fullscreen mode Exit fullscreen mode

Call update() with any array of 8 numbers and the chart animates there. That's the whole bridge.


Connecting to Real Data

WebSocket (e.g. financial feed)

const socket = new WebSocket('wss://your-api.com/expenses/stream');

socket.onmessage = (event) => {
  const { daily } = JSON.parse(event.data);
  // daily is an array of 8 values, one per day
  update(daily);
};
Enter fullscreen mode Exit fullscreen mode

REST polling (e.g. every 30 seconds)

async function poll() {
  const res  = await fetch('/api/expenses/weekly');
  const data = await res.json();
  update(data.points); // [20, 45, 33, 60, 27, 51, 38, 44]
}

poll();
setInterval(poll, 30_000);
Enter fullscreen mode Exit fullscreen mode

In both cases your only job is feeding an array of 8 numbers into update(). The CSS does everything else — shape, fill, transition, dot position.


Why clip-path Makes This Work

st-core uses clip-path: polygon() which means:

  • The shape is arbitrary — true area curves, not stepped bars
  • Transitions are compositor-acceleratedclip-path animates on the GPU, not the main thread
  • No repaints — changing --st-p* variables doesn't touch the DOM structure at all

The transition declaration is doing a lot of heavy lifting here:

.chart-fill, .chart-line {
  transition: clip-path .9s cubic-bezier(.4, 0, .2, 1);
}
Enter fullscreen mode Exit fullscreen mode

That single line is what makes the chart feel like a live dashboard instead of a static image that gets replaced.


The "Gravy Path": Compiling to Pure CSS

The exec.min.js CDN script is for prototyping and demos. For production — and especially for large-scale projects — you compile your .fscss file down to totally pure, standard CSS.

Two ways to do this:

1. VS Code Extension

Install the FSCSS extension from the VS Code marketplace, and install fscss from npm. It watches your .fscss files and compiles on save — you get instant output CSS right next to your source file. This is the fastest feedback loop for building UIs.

2. FSCSS npm package

npm install -g fscss
Enter fullscreen mode Exit fullscreen mode
fscss "path/to/style.fscss" "path/to/style.css" 
Enter fullscreen mode Exit fullscreen mode

More details on that fscss.devtem.org.

The compiled output is 100% standard CSS. No runtime dependency, no JavaScript required for the styles. You ship the .css file and it just works.

Why this matters for large-scale projects

When you're managing a dashboard with dozens of chart components, keeping your chart configuration in .fscss files is a huge advantage:

/* charts/expenses.fscss */
@st-chart-points(20, 25, 21, 37, 30, 60, 27, 50)

/* charts/revenue.fscss */
@st-chart-points(44, 51, 60, 72, 65, 80, 78, 90)
Enter fullscreen mode Exit fullscreen mode

You get a single source of truth for chart shapes, baseline datasets, theming, and responsive behavior — all in .fscss. The compiled CSS is what ships to users. The JS layer stays tiny because it only handles the live variable updates.


The Full Stack, One More Time

.fscss source
  └── compiled via npm
        └── pure CSS output
              ├── clip-path polygon (shape)
              ├── --st-p1..8 variables (data)
              ├── transitions (motion)
              └── stat card / axis / grid layout

Runtime JS (optional, for live data)
  └── update(points[]) → sets --st-p1..8 on elements
        └── CSS transition takes over → smooth animation
Enter fullscreen mode Exit fullscreen mode

The mental model: FSCSS owns the structure and shape. JS owns the numbers. CSS owns the motion.


The core pattern doesn't change for any of these. You're still just feeding --st-p* variables and letting the compiled CSS handle the rest.


Top comments (0)