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 (5in the example) -
count(5, 1)→ sequence1, 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 -
$iis 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
-
No index array — looping raw values does not give you
$ifor--st-p$i. - No reverse on the second pass — self-intersecting polygon, broken stroke.
-
Forgetting
inline()— loop syntax is invalid insidepolygon()’s argument list. -
Width in % only — prefer
px(or a fixed unit) for--st-chart-line-widthso thickness stays stable. -
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
-
st-core.fscss —
@st-chart-line,@st-spider-line -
FSCSS —
@arr,inline(), CLI
Requires FSCSS 1.2.x for the array / import patterns used above.
Top comments (0)