DEV Community

Cover image for How to Build Dual-Pass Strokes with FSCSS Array Methods
FSCSS tutorial for FSCSS tutorial

Posted on

How to Build Dual-Pass Strokes with FSCSS Array Methods

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%) */
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Assume you already have:

@arr name[40, 60, 20, 80, 50]
Enter fullscreen mode Exit fullscreen mode

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)]
Enter fullscreen mode Exit fullscreen mode
  • @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]
Enter fullscreen mode Exit fullscreen mode

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),
  }
")
Enter fullscreen mode Exit fullscreen mode

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),
Enter fullscreen mode Exit fullscreen mode

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

empty-@arr.name-idx[] {
  $i: @arr.name-idx[];
  /* emitted fragment per index */
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode

$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>)%
Enter fullscreen mode Exit fullscreen mode
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)),
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)