DEV Community

Cover image for Build st-core@v2 from scratch
FSCSS tutorial for FSCSS tutorial

Posted on

Build st-core@v2 from scratch

A deep dive into st-core@v2.fscss — how a pure-CSS chart module is assembled with FSCSS: tokens, inverted Y, array indexes, inline(), and dual-pass strokes.

Requires: FSCSS ≥ 1.2.3

Repo: github.com/fscss-ttr/st-core.fscss


What you’re building

Not a JS chart engine. A set of @define mixins that:

  1. Install design tokens
  2. Map a data array → --st-p1…N (CSS Y positions)
  3. Paint fill and line with clip-path: polygon(...)
  4. Optionally add grid, dots, stat cards, phone shell

Runtime data changes only CSS variables. Geometry is compile-time (or runtime-expanded) CSS.


Step 0 — Mental model

Human values  [42, 58, 65, …]     // “higher = better”
      │
      ▼  num(100 - v)%
--st-p1, --st-p2, …               // CSS top% (0% = top of box)
      │
      ▼  even X: (i-1)/(N-1) * 100%
clip-path polygon points
      │
      ▼
.area fill  +  fake “stroke” line  +  dots
Enter fullscreen mode Exit fullscreen mode

In CSS, y = 0% is the top. A score of 90 should sit near the top → store 10% in --st-p*.


Step 1 — Token root

Everything themable hangs off custom properties.

@define st-root(root:root){`
:@use(root){
  --st-bg: #0e0d14;
  --st-surface: #161422;
  --st-card: #1c1a2e;

  --st-accent: #9d7eff;
  --st-accent-2: #c4a8ff;
  --st-green: #4fffb0;
  --st-red: #ff5e7d;

  --st-text: #e8e3ff;
  --st-muted: #6b6488;
  --st-border: rgba(157,126,255,.15);

  --st-radius-xl: 40px;
  --st-radius-lg: 16px;
  --st-pad: 24px;

  /* default 8-point chart (overwritten by data) */
  --st-p1: 68%; --st-p2: 59%; --st-p3: 70%; --st-p4: 35%;
  --st-p5: 58%; --st-p6: 22%; --st-p7: 42%; --st-p8: 55%;

  --st-chart-line-width: 1.5px;
}
`}
Enter fullscreen mode Exit fullscreen mode

Usage: @st-root() → usually on :root.

Why: Fill/line/dot only read variables — swap brand without touching polygon math.


Step 2 — Fixed 8-point setter (v1-style helper)

Still useful for demos with a fixed series length:

@define st-chart(p1: 88, p2: 59, p3: 70, p4: 35, p5: 58, p6: 22, p7: 42, p8: 55){
  --st-p1: num(100 - @use(p1))%;
  --st-p2: num(100 - @use(p2))%;
  /* … p3–p8 same pattern … */
}
Enter fullscreen mode Exit fullscreen mode

num(100 - x) is the invert-Y core. v2 generalizes this to any N via arrays.


Step 3 — Array → --st-pN (st-chart-points)

This is the heart of v2: length comes from @arr, not eight parameters.

@define st-chart-points(p){`
inline("
@arr @use(p)-idx[count(@arr.@use(p)!.length, 1)]

empty{ /* preserve */ }
empty-@arr.@use(p)-idx[]{
  $idx: @arr.@use(p)-idx[];
  --st-p$idx: num(100 - @arr.@use(p)[$idx])%;
}")
`}
Enter fullscreen mode Exit fullscreen mode
Piece Role
p Name of the data array (e.g. myData)
count(@arr.@use(p)!.length, 1) Index list 1…N
empty{ /* preserve */ } Keeps loop output from being eaten as selectors
--st-p$idx: num(100 - value)% One variable per point
inline("…") Emits declarations only so you can call this inside .chart { }

Call site:

@arr myData[42, 58, 65, 60, 78, 70, 92]

.chart {
  @st-chart-points(myData)
  position: relative;
  height: 220px;
}
Enter fullscreen mode Exit fullscreen mode

After expand: --st-p1…--st-p7 exist. JS can later overwrite them; the polygon still reads the same names.


Step 4 — Area fill (st-chart-fill)

Close a polygon: along the series, then bottom-right, bottom-left.

@define st-chart-fill(st:.st-chart-fill, p){`
@use(st){
  position: absolute;
  inset: 0;

  @arr @use(p)-idx[count(@arr.@use(p)!.length, 1)]
  clip-path: polygon(
    inline("{}
      empty-@arr.@use(p)-idx[] {
        $i: @arr.@use(p)-idx[];
        num(<$i - 1> * 100 / <@arr.@use(p)!.length - 1>)% var(--st-p$i),
      }
      100% 100%,
      0% 100%
    ")
  );

  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--st-accent) 35%, transparent),
    transparent
  );
}
`}
Enter fullscreen mode Exit fullscreen mode

X formula

st-core.fscss clip-path formula

Even spacing for any N ≥ 2.

Y = var(--st-p$i) (already inverted).

inline("{} …") — loop body is rule-shaped (empty-@arr… { }); inline() strips braces so only x% y%, fragments sit inside polygon().

HTML:

<div class="chart">
  <div class="st-chart-fill"></div>
</div>
Enter fullscreen mode Exit fullscreen mode
@st-chart-fill(.st-chart-fill, myData)
Enter fullscreen mode Exit fullscreen mode

Step 5 — Line as a thin polygon (st-chart-line)

CSS has no “stroke this polyline” for arbitrary points. st-core fakes a stroke:

  1. Walk forward along the top edge (the visible line).
  2. Walk back slightly below each point (y + line-width).
  3. Close the strip.
@define st-chart-line(st:.st-chart-line, p){`
@use(st){
  position: absolute;
  inset: 0;

  @arr @use(p)-idx[count(@arr.@use(p)!.length, 1)]
  @arr @use(p)-reversed-idx[@arr.@use(p)-idx!.reverse]

  clip-path: polygon(
    inline("{}
      empty-@arr.@use(p)-idx[] {
        $i: @arr.@use(p)-idx[];
        num(<$i - 1> * 100 / <@arr.@use(p)!.length - 1>)% var(--st-p$i),
      }
    ")
    inline("{}
      empty-@arr.@use(p)-reversed-idx[] reverse {
        $i: @arr.@use(p)-reversed-idx[];
        num(<$i - 1> * 100 / <@arr.@use(p)!.length - 1>)%
          calc(var(--st-p$i) + var(--st-chart-line-width)),
      }
    ")
    -100% calc(var(--st-p1) + var(--st-chart-line-width))
  );

  background: var(--st-accent);
}
`}
Enter fullscreen mode Exit fullscreen mode
Idea Detail
Derived array @arr …-reversed-idx[@arr.…-idx!.reverse] (1.2.x)
Second pass Same X, Y shifted by --st-chart-line-width
Width helper @st-chart-line-width(2.5px) sets the variable

Fill and line share --st-p*; only the polygon path differs.


Step 6 — Dots (st-chart-dots / st-chart-dot)

One series of dots — loop selectors:

@define st-chart-dots(st:.st-chart-dot-, p, size: 8px){`
  @arr @use(p)-idx[count(@arr.@use(p)!.length, 1)]
  empty{ /* preserve */ }
  @use(st)@arr.@use(p)-idx[] {
    $i: @arr.@use(p)-idx[];
    position: absolute;
    left: calc(num(<$i - 1> * 100 / <@arr.@use(p)!.length - 1>)% - 6px);
    top: calc(var(--st-p$i) - 6px);
    %2(width, height[: @use(size);])
    border-radius: 50%;
    background: #fff;
    border: 2.5px solid var(--st-accent);
  }
`}
Enter fullscreen mode Exit fullscreen mode

Emits .st-chart-dot-1, .st-chart-dot-2, … positioned on the series.

Single highlight dot — explicit x/y (human y inverted in the mixin):

@define st-chart-dot(st:.st-chart-dot, x:0, y:0, size: 12px){`
@use(st){
  position: absolute;
  left: calc(@use(x)% - 6px);
  top: calc(num(-<@use(y)> + 100)% - 6px);
  %2(width, height[: @use(size);])
  border-radius: 50%;
  background: #fff;
  border: 2.5px solid var(--st-accent);
}
`}
Enter fullscreen mode Exit fullscreen mode

Step 7 — Chrome: grid, axes, stat card, phone

These are ordinary layout mixins on the same tokens:

  • st-chart-grid(st, rows, cols) — repeating-linear-gradient mesh
  • st-chart-axis-x / st-chart-axis-y — flex label rows
  • st-stat-card — label / value / delta (.up / .down)
  • st-phone / st-container — device frame and page shell

No new math — they make it look like a product, not a codepen fragment.


Step 8 — Minimal page that uses it

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

@st-root()

@arr sales[42, 58, 65, 60, 78, 70, 92]

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

.chart {
  @st-chart-points(sales)
  position: relative;
  height: 220px;
  background: var(--st-surface);
  border-radius: var(--st-radius-lg);
}

.fill {
  --st-accent: #22c55e;
  opacity: 0.25;
  transition: clip-path 0.6s ease;
}
.line {
  --st-accent: #22c55e;
  @st-chart-line-width(2.5px)
  transition: clip-path 0.6s ease;
}
Enter fullscreen mode Exit fullscreen mode
<div class="chart">
  <div class="fill"></div>
  <div class="line"></div>
</div>
Enter fullscreen mode Exit fullscreen mode

Live values from JS:

const el = document.querySelector('.chart');
el.style.cssText = values
  .map((v, i) => `--st-p${i + 1}: ${100 - v}%;`)
  .join(' ');
Enter fullscreen mode Exit fullscreen mode

Design rules baked into v2

  1. Points once, draw many times — @st-chart-points owns --st-p*; fill/line/dots only consume.
  2. Index arrays are local — count(length, 1) next to each mixin; don’t hand-write 1,2,3,….
  3. inline() at polygon boundaries — loops need { }; polygon() needs commas, not rules.
  4. empty / empty- preserve — stops the compiler from treating loop output as selectors.
  5. N is fixed at expand time — changing series length in JS needs a compiled template ≥ max N (pad or recompile). Changing values is always fine.

Feature map (source order)

Define Role
st-root Tokens + default --st-p*
st-chart Fixed 8-arg invert helper
st-chart-points Array → --st-pN
st-chart-fill Area polygon
st-chart-line Dual-pass stroke polygon
st-chart-line-width Stroke thickness token
st-chart-dot / st-chart-dots Markers
st-chart-grid / axes Background mesh + labels
st-stat-card KPI block
st-phone / st-container Shell UI
st-cat-bar-fill Horizontal bar

From scratch checklist

  1. @st-root with accent + --st-chart-line-width
  2. Data @arr + @st-chart-points
  3. Fill polygon (series + baseline)
  4. Line polygon (forward + reverse + width)
  5. Optional dots/grid
  6. JS only assigns --st-pN

You’re not implementing a chart library in JavaScript. You’re compiling a CSS renderer and treating data as theme variables.


Source: st-core@v2.fscss · README.md · templates/admin-dashboard

Invert Y. Index the array. inline the points. Let the browser paint.

Top comments (0)