clip-path: polygon() can fill a shape. It cannot stroke one.

So pure-CSS chart lines in FSCSS (and libraries like st-core) fake a stroke with a dual-pass polygon: walk the data forward for the top edge, walk it backward for a parallel edge offset by line width, fill the thin band between them.

This post unpacks that pattern with @arr, inline(), $, count(), and .reverse — the same building blocks used in st-core’s @st-chart-line.

The problem

You have points as CSS variables:

--st-p1: 32%; --st-p2: 55%; --st-p3: 20%; /* … Y positions (top = 0%) */ 

You want a polyline with thickness, not a filled area under the curve.

SVG would use stroke + stroke-width. With only clip-path, you build a closed ribbon:

→ → → → (along the data) ← ← ← ← (back along data + width) 

Fill that ribbon with background: var(--st-accent) and it reads as a line.

The full pattern

.line { position: absolute; inset: 0; @arr name-idx[count(@arr.name!.length, 1)] @arr name-reversed-idx[@arr.name-idx!.reverse] clip-path: polygon( inline("{} empty-@arr.name-idx[] { $i: @arr.name-idx[]; num( <$i - 1> * 100 / <@arr.name!.length - 1>)% var(--st-p$i), } ") inline("{} empty-@arr.name-reversed-idx[] reverse { $i: @arr.name-reversed-idx[]; $isLast: equal($i, 1); num( <$i - 1> * 100 / <@arr.name!.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); } 

Assume you already have:

@arr name[40, 60, 20, 80, 50] 

and something (e.g. @st-chart-points) that wrote --st-p1 … --st-p5.

Piece by piece

1. Index array — count() + @arr

@arr name-idx[count(@arr.name!.length, 1)] 
  • @arr.name!.length → number of data points (5 in the example)
  • count(5, 1) → sequence 1, 2, 3, 4, 5
  • Stored as @arr name-idx[...]

You loop indices, not raw values. Y still comes from var(--st-p$i).

2. Reversed index — .reverse

@arr name-reversed-idx[@arr.name-idx!.reverse] 

Forward: [1, 2, 3, 4, 5]

Reverse: [5, 4, 3, 2, 1]

The second pass must go right → left so the polygon boundary does not cross itself when you close the band.

3. inline("{} … ") — expand loops inside polygon()

clip-path: polygon(...) is one CSS declaration. FSCSS cannot drop a normal block loop in the middle of the argument list. inline("...") runs the inner FSCSS and splices the text result into the polygon stop list.

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

Rough compile for 5 points:

0% var(--st-p1), 25% var(--st-p2), 50% var(--st-p3), 75% var(--st-p4), 100% var(--st-p5), 

4. empty-@arr.…[] — the loop form

empty-@arr.name-idx[] { $i: @arr.name-idx[]; /* emitted fragment per index */ } 
  • Iterates every entry of name-idx
  • $i is the current index (1 … N)
  • Whatever you write inside becomes one (or more) polygon stops

The empty- prefix is the FSCSS loop driver used throughout st-core-style mixins.

5. $i and var(--st-p$i) — dynamic custom properties

$i: @arr.name-idx[]; var(--st-p$i) 

$i is substituted at compile time, so you get --st-p1, --st-p2, … — one variable per vertex. That is how array length stays flexible without hardcoding eight stops.

6. X spacing — num() and length math

num( <$i - 1> * 100 / <@arr.name!.length - 1>)% 
i formula (N = 5) X
1 0 * 100 / 4 0%
2 1 * 100 / 4 25%
3 2 * 100 / 4 50%
… … …
5 4 * 100 / 4 100%

Points are evenly distributed across the box, independent of how many values you pass.

7. Second pass — offset Y by stroke width

num( <$i - 1> * 100 / <@arr.name!.length - 1> )% calc(var(--st-p$i) + var(--st-chart-line-width)), 

Same X as the matching forward point. Y is pushed down by --st-chart-line-width (e.g. 1.5px).

Because the index array is reversed, you traverse those offset points from the last data index back to the first.

8. Closing stop

-100% calc(var(--st-p1) + var(--st-chart-line-width)) 

A final stitch so the polygon closes cleanly near the start of the offset edge. (Exact close strategy can vary; st-core uses this trailing stop after the reverse loop.)

9. Fill the band

background: var(--st-accent); 

The clipped region is only the thin ribbon — so a solid fill looks like a stroked line.

Mental model

Pass 1 (forward idx): (x1, y1) → (x2, y2) → … → (xN, yN) Pass 2 (reversed idx, y + width): (xN, yN+w) → … → (x2, y2+w) → (x1, y1+w) Close → fill 
Concern Owned by
How many points @arr.name!.length + count()
Order of second edge .reverse
Emitting stops inside polygon() inline()
Y values var(--st-p$i) (written elsewhere)
Thickness var(--st-chart-line-width)

Minimal standalone sketch

@arr data[30, 55, 25, 70, 45] /* write Y vars (simplified) */ @arr data-idx[count(@arr.data!.length, 1)] empty-@arr.data-idx[] { $i: @arr.data-idx[]; --st-p$i: num(100 - @arr.data[$i])%; } .line { position: absolute; inset: 0; --st-chart-line-width: 2px; --st-accent: #9d7eff; @arr data-idx[count(@arr.data!.length, 1)] @arr data-rev[@arr.data-idx!.reverse] clip-path: polygon( inline("{} empty-@arr.data-idx[] { $i: @arr.data-idx[]; num(<$i - 1> * 100 / <@arr.data!.length - 1>)% var(--st-p$i), } ") inline("{} empty-@arr.data-rev[] { $i: @arr.data-rev[]; num(<$i - 1> * 100 / <@arr.data!.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); } 

Host needs position: relative and a defined height. Put <div class="line"></div> inside it.

Polar variant (same idea)

Spider/radar lines in st-core use the same dual-pass idea in polar space:

  • Forward: var(--st-sx$i) var(--st-sy$i)
  • Reverse: each vertex scaled toward the center (--st-spider-stroke-scale, e.g. 0.97)

Still: forward path + return path + fill the band. Only the coordinate system changes.

Common pitfalls

  1. No index array — looping raw values does not give you $i for --st-p$i.
  2. No reverse on the second pass — self-intersecting polygon, broken stroke.
  3. Forgetting inline() — loop syntax is invalid inside polygon()’s argument list.
  4. Width in % only — prefer px (or a fixed unit) for --st-chart-line-width so thickness stays stable.
  5. Points not written — dual-pass only reads --st-p*; something must write them first.

Why FSCSS helps

Without a preprocessor you either hardcode N stops or generate CSS in JS. With @arr + inline():

  • N is data-driven
  • X spacing is automatic
  • Reverse pass is one .reverse
  • Output is still plain CSS clip-path — fast, cacheable, no chart library

That is the whole dual-pass stroke trick: two ordered walks over the same indices, one offset, one fill.

Further reading

Requires FSCSS 1.2.x for the array / import patterns used above.