Skip to main content

Command Palette

Search for a command to run...

st-core.fscss v2: Full Release Notes & Technical Reference

Updated
10 min readView as Markdown

Pure CSS statistical dashboard components. No JavaScript dependencies. No SVG. No canvas.

st-core.fscss is a CSS visualization system built on the FSCSS ecosystem. It renders area charts, multi-line charts, stat cards, and dashboard chrome using nothing but clip-path: polygon(), repeating-linear-gradient(), and CSS custom properties. This post covers the full v2 release: what changed, every mixin the library ships, and how the underlying array-to-polygon math actually works.

MIT licensed. Repo: github.com/fscss-ttr/st-core.fscss. Requires FSCSS v1.2.3+.


What's New in v2

v1 charts were locked to exactly 8 fixed data points, mapped onto hardcoded variables --st-p1 through --st-p8. v2 removes that ceiling:

  • Dynamic datasets (@arr) — pass arrays of any length: 5 points, 12, 50+.
  • Automatic X-spacing — points distribute evenly across 0% to 100% based on array.length, no manual positioning.
  • Inline normalization — natural 0–100 values get normalized to CSS percentages directly inside the style calculations.
  • Dual-edge polygon polyline — line strokes are drawn as a closed top-to-bottom band rather than a hack around border, giving precise stroke rendering.

The v1 API (@st-chart, 8 named params) is still available for anyone with a genuinely fixed 8-point series who doesn't want to declare an array.


Installation

Runtime / CDN mode — for prototyping, drop this in your <head>:

<script src="https://cdn.jsdelivr.net/npm/fscss@1.2.3/runtime.min.js" async></script>

Then import the module in your <style> block:

@import((*) from st-core@v2)

CLI / compiled mode — for production, compile .fscss straight to .css:

npm install -g fscss@latest
fscss input.fscss output.css

VS Code syntax highlighting and auto-compile: FSCSS Support extension.


How It Works, Conceptually

Every chart is a polygon. Define an array, apply a renderer mixin against it, done:

@arr myData[50, 10, 97, 35, 66, 50, 80, 54, 70, 60]

@st-chart-fill(.chart-fill, myData)
@st-chart-line(.chart-line, myData)

The mixin reads the array's length to compute horizontal steps, reads normalized Y values to compute vertical position, and emits a clip-path: polygon(). Two responsibilities are deliberately kept separate:

  • Renderers (@st-chart-fill, @st-chart-line, @st-chart-dots) declare shape. They take an array only to know how many points to loop over.
  • @st-chart-points(array) declares values. It's the only mixin that writes --st-p1--st-p{n} onto an element. Every renderer only reads those variables.

Because these are ordinary inherited custom properties, any child element that skips @st-chart-points inherits values from the nearest ancestor that called it. That's convenient for a single-series chart (call it once, on the container) and mandatory to override for multi-series charts (each series needs its own call, or it silently renders its neighbor's data).


Full Mixin Reference

@st-root() — Design Tokens

@st-root()                 /* targets :root */
@st-root(root.class...)    /* targets a custom scope */

Initializes every color, radius, and spacing token the rest of the library reads via var(--st-*). Nothing downstream hardcodes a color. It also seeds --st-p1--st-p8 with placeholder defaults — a safety net so a component reading --st-p$i before @st-chart-points has run gets a plausible shape instead of a broken layout, rather than undefined.

@st-container(selector) — Viewport Wrapper

@st-container(body)

Centers content in the viewport with the design-token background and text color. Defaults to body.

@st-phone(selector) — Device Frame

@st-phone(.wrapper)

A 360px-wide rounded card frame with layered shadow, meant to mimic a phone-sized dashboard surface. overflow: hidden keeps chart fills and lines from bleeding past the rounded corners.

@st-chart-points(array) — Value Normalizer

.chart {
  @st-chart-points(myData)
}

Builds a throwaway index array the same length as your data, loops over it, and for each index writes:

--st-p{i}: (100 - value_at_i)%

The inversion matters because CSS boxes render top-down (0% is the top) while chart data conceptually grows bottom-up. This is the only mixin in the library that writes --st-p* from an arbitrary-length array — call it once per dataset, on whichever element needs those values (container, or a specific child for a second series).

@st-chart-fill(selector, array) — Area Fill

@st-chart-fill(.chart-fill, myData)

Builds the same index-array loop as @st-chart-points, but uses it to emit clip-path: polygon() stops instead of variables. X-position per point is computed inline as (i - 1) * 100 / (length - 1), spreading points evenly regardless of array length. Y-position reads var(--st-p{i}) — it never touches raw array values directly. The loop generates only the top edge of the shape; 100% 100%, 0% 100% closes it down to the bottom corners. The fill itself is a top-to-bottom gradient from translucent --st-accent to transparent, layered on top of the polygon shape.

@st-chart-line(selector, array) — Polyline Stroke

@st-chart-line(.chart-line, myData)

clip-path has no native stroke concept, so this mixin fakes one by drawing a thin closed band: the top edge follows the data forward (left to right), the bottom edge follows the same data in reverse (right to left), offset downward by --st-chart-line-width. Walking the second pass in reverse is what closes the polygon without the shape crossing itself. The band is filled solid with var(--st-accent), which reads visually as a line with real thickness.

@st-chart-line-width(value) — Stroke Width Override

.chart-line {
  @st-chart-line-width(2.5px);
}

Overrides the 1.5px default set in @st-root. Since @st-chart-line reads var(--st-chart-line-width) at draw time, this is also changeable live via element.style.setProperty('--st-chart-line-width', '3px') without recompiling anything.

@st-chart-dot(selector, x%, y%, size) — Single Manual Marker

@st-chart-dot(.chart-dot, 70, 60, 12px)

Unlike the renderer mixins above, this takes explicit coordinates rather than an array — for annotating one specific point (a peak, a tooltip anchor) independent of any dataset. Applies the same top-down inversion (100 - y) inline rather than through the variable system, and centers the marker on the coordinate via a -6px offset on both axes.

@st-chart-dots(prefix, array, size) — Auto-Generated Markers

@st-chart-dots(.dot-, myData, 8px)

Generates .dot-1, .dot-2, ... .dot-N — one rule per array index. Internally it writes top twice: once computed directly from the raw array value (a self-contained fallback with no dependency on --st-p*), then again from var(--st-p{i}). CSS resolves duplicate declarations in source order, so the second write wins at render time — meaning @st-chart-dots, despite having a self-sufficient fallback, still effectively requires @st-chart-points(array) to have run somewhere in scope for correct vertical positioning. Horizontal positioning is fully self-sufficient either way, since it's computed from array length alone.

@st-chart-grid(selector, rows, cols) — Background Grid

@st-chart-grid(.chart-grid, 10, 7)

No polygons involved — two stacked repeating-linear-gradient()s do the work. One draws horizontal lines every 100% / rows, the other vertical lines every 100% / cols, each a hard 1px stripe followed by transparent space. The two axes are tinted differently (--st-muted vs --st-accent) as a subtle visual cue, and the whole thing sits at opacity: .2 so it reads as reference lines rather than competing with the actual data.

@st-chart-axis-x(selector) / @st-chart-axis-y(selector) — Axis Wrappers

@st-chart-axis-x(.x-axis)
@st-chart-axis-y(.y-axis)

Pure layout, no data dependency — they just space out hand-written <span> labels. The X axis is a horizontal flex row with justify-content: space-between. The Y axis uses flex-direction: column-reverse so a 0, 20, 40...100 label list reads correctly bottom-to-top against a chart where 0% is the top of the box, and is positioned absolute to overlay the chart rather than push content beside it.

@st-stat-card(selector) — Stat Card Component

@st-stat-card(.stat-card)

One call generates five rules at once: the card container plus three fixed inner-class conventions — .st-stat-label, .st-stat-value, .st-stat-delta — and two delta-direction modifiers, .up (green) and .down (red). The mixin owns that internal naming contract, so markup only needs to use the fixed class names inside a .stat-card.

@st-cat-bar-fill(selector, range) — Progress Bar Fill

@st-cat-bar-fill(.bar-fill, 75)

Unrelated to the line/area system — for horizontal progress or category bars. range is a plain percentage, written to --st-cat-bar-fill-range and consumed as width. transform-origin: left is preset in case you want to animate the fill in with a scaleX transform from zero.


Updating Charts from JavaScript

Pure CSS rendering doesn't mean the data has to be static. Updating a chart at runtime is just rewriting the --st-p{n} variables directly:

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]);

Add transition: clip-path 0.6s cubic-bezier(0.4, 0, 0.2, 1); to .chart-fill / .chart-line, and dataset changes animate through the compositor thread, untouched by layout or paint.


Worked Examples

Basic area + line 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>

Multi-line, multi-area with independent series

@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
   inherit .chart's seriesA values and render the wrong data */
.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>

Full mobile dashboard frame

<style>
@import((*) from st-core@v2)
@st-root()
@st-container(body)
@st-phone(.wrapper)

.wrapper { display: flex; flex-direction: column; gap: 16px; padding: 24px; }

@arr myData[56, 67, 70, 43, 67, 80]

@st-chart-fill(.chart-fill, myData)
@st-chart-line(.chart-line, myData)
@st-chart-dot(.chart-dot, 70, 60)
@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 {
  width: 100%; height: 200px; border-radius: 20px;
  position: relative; overflow: hidden;
  background: var(--st-surface);
  @st-chart-points(myData)
}
.chart-fill, .chart-line { transition: clip-path 0.8s ease-in-out; }
</style>

<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"></div>
    <div class="chart-grid"></div>
    <div class="y-axis">
      <span>0</span><span>20</span><span>40</span><span>60</span><span>80</span><span>100</span>
    </div>
  </div>

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

The One Rule That Ties It Together

Concern Who owns it Depends on --st-p*?
X-position (horizontal spacing) Computed inline per mixin, from array length No
Y-position (vertical value) var(--st-p{i}), written only by @st-chart-points Yes
Shape (fill / line / dot) @st-chart-fill / @st-chart-line / @st-chart-dots Reads, never writes
Values @st-chart-points(array) Writes, on whichever element calls it

Every renderer only needs the array to know how many points to loop over. The actual values reach the page exclusively through --st-p1--st-p{n}, inherited down the DOM like any custom property. That's the entire reason a multi-series chart needs @st-chart-points(seriesN) called explicitly on each series' own element: skip it, and that element silently inherits whatever its nearest ancestor last set.


Design Token Reference

Variable Default Usage
--st-bg #0e0d14 Page body background
--st-surface #161422 Surface / container background
--st-card #1c1a2e Card component background
--st-accent #9d7eff Primary brand accent
--st-accent-2 #c4a8ff Secondary accent gradient
--st-green #4fffb0 Positive delta state
--st-red #ff5e7d Negative delta state
--st-text #e8e3ff Primary text color
--st-muted #6b6488 Muted labels / grid stroke
--st-radius-xl 40px Outer device frame radius
--st-radius-lg 16px Component card radius
--st-chart-line-width 1.5px Line stroke thickness

Performance & SEO

  • Zero hydration overhead — pure CSS charts need no JS initialization before rendering.
  • Tiny compiled size — lightweight CSS shapes and variables, around 0.8kb minified.
  • GPU-accelerated transitions — dataset updates through CSS custom properties run on the browser's compositor thread.

MIT licensed, built with FSCSS. Repo and contributions: github.com/fscss-ttr/st-core.fscss.