Computing with CSS Layout

Bulk math from the browser’s own layout algorithms.


Not every website runs JavaScript. A browser may turn it off, a security policy or an extension may block it, or it may just not load. Meanwhile, HTML and CSS are what the web platform guarantees, even offline.

This document tries to answer one question: how much can CSS compute on its own?

CSS-only physics

The idea: we can leverage the browser’s own layout algorithm to compute for our stylesheets. Plain calc() might not scale, but it is not the only source of math in CSS. By combining container queries and custom properties, we can read an element’s size as a number and use it in the next calculation.

Acknowledgements

This document uses Bikeshed, a nifty tool the CSS Working Group’s drafts are built with. It is obviously not an official draft (though wouldn’t it be fun if they included minigames?). Believe it or not, I enjoy reading specs and this structure helped me organize my thinking.

This work builds on ideas shared by others in the CSS community:

Table of Contents
  1. 1. Layout computation
    1. 1.1. Reading a width
    2. 1.2. Finding the widest
    3. 1.3. Nesting calculations
    4. 1.4. Feeding it back
    5. 1.5. Comparing elements
  2. 2. Primitives
    1. 2.1. Sort
    2. 2.2. Rank
    3. 2.3. Performance
  3. 3. Data-driven stylesheets
    1. 3.1. Using a function
    2. 3.2. Drawing a chart
  4. 4. Browser support

1. Layout computation

Browsers run highly optimized algorithms every frame just to place boxes. That’s the point of layout.

When an auto column finds the widest element, we can think of it as a maximum. A flex row adds up its children’s widths into a sum. order sorts items by a number. Each answer ends up as a size or a position on the page, where modern CSS can read it back as a value.

Sorting algorithms, each step done by layout

1.1. Reading a width

A grid item stretches to fill its column by default. If we give it container-type: inline-size, it becomes a query container: an element whose contents can read its width, and that its own contents never widen.

Inside this special element, 100cqw represents the column’s width as a length, like 300px. Dividing a length by 1px drops the unit and round() makes it whole. This is a number we can use to compute in CSS!

<div class="read">
  <i style="--value: 300">300</i>
  <b class="column-width">width = <output></output></b>
</div>
@property --result { syntax: '<integer>'; inherits: true; initial-value: 0; }

.column-width { display: block; container-type: inline-size; }
.column-width > * { --result: round(100cqw / 1px); }

/* content can't print a number, but a counter can */
output::after { counter-reset: result var(--result); content: counter(result); }

/* the example: one auto column, each item as wide as its --value */
.read { display: inline-grid; grid-template-columns: auto; }
.read > i { width: calc(var(--value) * 1px); }

Registered with @property as an <integer>, the custom property holding it computes to that number where it is set, so the elements inside inherit 300, not 100cqw to be measured again; a counter prints it.

1.1.1. Reading a position

A width can also be a distance. Stretch an element from its parent’s left edge to another element with right: anchor(--target left), and a query container inside it reads how far away that element landed. Here the target sits after two bars in a flex row, so the distance is their sum.

Reading a positionView on CodePen
target
Inputs
View the code (1.6 KB)
<div class="position">
  <label><input type="radio" name="position" id="position-1" checked> 12 + 8</label>
  <label><input type="radio" name="position" id="position-2"> 20 + 25</label>
  <label><input type="radio" name="position" id="position-3"> 30 + 30</label>
  <div class="position-track">
    <i class="position-a"></i>
    <i class="position-b"></i>
    <i class="position-target">target</i>
    <div class="position-distance">
      <b class="position-container"><output></output></b>
    </div>
  </div>
</div>
@property --result { syntax: '<integer>'; inherits: true; initial-value: 0; }
output::after { counter-reset: result var(--result); content: counter(result); }

/* 6px a unit */
.position-track { --a: 12; --b: 8; position: relative; display: flex; height: 72px; }
body:has(#position-2:checked) .position-track { --a: 20; --b: 25; }
body:has(#position-3:checked) .position-track { --a: 30; --b: 30; }
.position-a { width: calc(var(--a) * 6px); }
.position-b { width: calc(var(--b) * 6px); }
.position-track > i { flex: none; }
.position-target { anchor-name: --target; }

.position-distance { position: absolute; top: 36px; left: 0; right: anchor(--target left); }

.position-container { display: block; container-type: inline-size; }
.position-container output { --result: round(100cqw / 6px); }

/* the bars' numbers, and the sum the read is */
.position-a::before { counter-reset: a var(--a); content: counter(a); }
.position-b::before { counter-reset: b var(--b); content: counter(b); }
.position-container::before { counter-reset: a var(--a) b var(--b); content: counter(a) " + " counter(b) " = "; }

1.2. Finding the widest

If we put three elements in a grid with one auto column, that column grows to fit the widest, so its width is max(12, 40, 30), and the query container in it reads 40. We never wrote a max in calc(). The grid algorithm had to find one anyway, just to lay the elements out, and we borrow that calculation.

One grid column, read backView on CodePen
View the code (0.6 KB)
<div class="max">
  <i style="--value: 12">12</i>
  <i style="--value: 40">40</i>
  <i style="--value: 30">30</i>
  <b class="column-width">max(12, 40, 30) = <output></output></b>
</div>
@property --result { syntax: '<integer>'; inherits: true; initial-value: 0; }
.column-width { display: block; container-type: inline-size; }
output::after { counter-reset: result var(--result); content: counter(result); }
.max { display: inline-grid; grid-template-columns: auto; }
.max > i { width: calc(var(--value) * 6px); }
.max > .column-width > * { --result: round(100cqw / 6px); }

1.3. Nesting calculations

We can nest calculations as we do elements. For example, a flex row that hugs its content is as wide as its items added up, so each row becomes a sum. An auto column holding the rows takes the widest of them, so its query container reads max(12 + 40, 30 + 7 + 9), which is 52.

A sum inside a maxView on CodePen
View the code (0.7 KB)
<div class="nest">
  <div>
    <i style="--value: 12">12</i>
    <i style="--value: 40">40</i>
  </div>
  <div>
    <i style="--value: 30">30</i>
    <i style="--value: 7">7</i>
    <i style="--value: 9">9</i>
  </div>
  <b class="column-width">max(12 + 40, 30 + 7 + 9) = <output></output></b>
</div>
@property --result { syntax: '<integer>'; inherits: true; initial-value: 0; }
.column-width { display: block; container-type: inline-size; }
output::after { counter-reset: result var(--result); content: counter(result); }
.nest { display: inline-grid; grid-template-columns: auto; }
.nest > div { display: flex; }
.nest i { flex: none; width: calc(var(--value) * 6px); }
.nest > .column-width > * { --result: round(100cqw / 6px); }

1.4. Feeding it back

Once layout gives us a number, we can use it again as a width, an order, a grid-column, or on another calculation. As usual, there’s one catch: 100cqw measures the nearest query container, so whatever uses the result has to live inside the element that read it. The markup ends up with three levels, the calculation, the query container and the elements inside it.

A maximum, used as a width and read againView on CodePen
View the code (1.1 KB)
<div class="max">
  <i style="--value: 12">12</i>
  <i style="--value: 40">40</i>
  <i style="--value: 30">30</i>
  <b class="column-width">max(12, 40, 30) = <output></output>
    <span class="use">
      <span><i class="answer"></i><i style="--value: 25">25</i></span>
      <b class="column-width">max(12, 40, 30) + 25 = <output></output></b>
    </span>
  </b>
</div>
@property --result { syntax: '<integer>'; inherits: true; initial-value: 0; }
.column-width { display: block; container-type: inline-size; }
output::after { counter-reset: result var(--result); content: counter(result); }
.max { display: inline-grid; grid-template-columns: auto; }
.max > i { width: calc(var(--value) * 6px); }
.max > .column-width > * { --result: round(100cqw / 6px); }
.use { display: grid; width: max-content; }
.use > span { display: flex; }
.use i { flex: none; width: calc(var(--value) * 6px); }
.use .answer { width: calc(var(--result) * 6px); }
.answer::before { counter-reset: v var(--result); content: counter(v); }
.use > .column-width > * { --result: round(100cqw / 6px); }

1.5. Comparing elements

Some frontend tasks require that we compare everything with everything, like ranking a list. Plain calc() can handle it, as long as we write every comparison: for fifty numbers that’s 50 formulas of 49 comparisons each, 2,450 in total. The newer min() and max() get it down to 403, but the formulas grow with the number of items.

Layout can do the comparing for us. If each item’s number is its order, the browser lays them out from smallest to largest, with no formula per pair. Then we can combine it with calc() for the rest of the math. Ranking 300 numbers took 95 ms in calc(), comparing every pair, and 6 ms in layout (§2.3).

Fifty numbers, summed and compared by layout
View the code (3.3 KB)
<i id="play" hidden></i><i id="pause" hidden></i>
<div class="bulk-demo">
  <div class="bulk-clip">
    <div class="bulk-sum">
      <div class="bulk-row">
        <i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
      </div>
      <b><u></u></b>
    </div>
  </div>
  <div class="bulk-bin">
    <i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i> <b><u></u></b>
  </div>
  <div class="bulk-bars">
    <i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
  </div>
</div>
@property --t { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --k { syntax: '<integer>'; inherits: true; initial-value: 0; }
@property --n { syntax: '<integer>'; inherits: true; initial-value: 0; }
/* one clock; every number is a wave of it and of its place */
@keyframes clock { to { --t: 1; } }
.bulk-demo { animation: clock 5s linear infinite paused; }
:root:has(#play:target) .bulk-demo { animation-play-state: running; }
.bulk-demo i {
  --k: sibling-index();
  --n: round(4 + (48 + 44 * sin(var(--k) * 5.4deg)) * (0.5 - 0.5 * cos(var(--t) * 360deg + var(--k) * 12.5deg)));
  width: calc(var(--n) * 1px);
  height: 0;
}
/* the sum */
.bulk-clip { height: 0; overflow: hidden; }
.bulk-sum { width: max-content; }
.bulk-row { display: flex; }
.bulk-row > i { flex: none; }
/* the largest */
.bulk-bin { display: grid; grid-template-columns: max-content; width: max-content; height: 0; }
.bulk-bin > i, .bulk-bin > b { grid-area: 1 / 1; }
.bulk-demo b { display: block; container-type: inline-size; }
.bulk-demo u { counter-reset: out round(100cqw / 1px); }
/* the bars: the same numbers, drawn */
.bulk-bars > i {
  position: absolute;
  bottom: 20px;
  height: calc(var(--n) * 3px);
  left: calc(20px + (var(--k) - 1) * (100% - 40px) / 50);
  width: calc((100% - 40px) / 50 + 0.5px);
  background: #3a4049;
}
/* the look */
.bulk-demo {
  position: relative;
  height: 390px;
  box-sizing: border-box;
  background: #101216;
  font: 600 15px ui-monospace, Menlo, monospace;
}
.bulk-demo u { position: absolute; text-decoration: none; font-weight: 600; }
/* not bolder for being in a b */
.bulk-sum u { top: 18px; left: 20px; color: #afafaf; }
.bulk-sum u::after { content: "sum = n1 + n2 + … + n50 = " counter(out); }
.bulk-bin u {
  left: 20px;
  right: 20px;
  bottom: calc(20px + 100cqw / 1px * 3px);
  border-bottom: 2px dashed #d8a93c;
  color: #d8a93c;
  z-index: 1;
}
.bulk-bin u::after { content: "largest = max(n1, n2, …, n50) = " counter(out); }

2. Primitives

A primitive is a calculation that layout performs when it places elements, such as a sum, a maximum or a sort. Comparing is where plain calc() struggles most, so the two examples below, a sort and a rank, let the order property do it. The API section has many more.

2.1. Sort

When each item’s value is also its order, the grid lines them up from smallest to largest. A query container under the middle column (3 of 5) then reads the median. order only takes whole numbers.

Inputs
View the code (1.2 KB)
<label><input type="checkbox"> Last: 180 → 120</label>
<div class="g">
  <div class="item" style="--value: 190"></div>
  <div class="item" style="--value: 130"></div>
  <div class="item" style="--value: 240"></div>
  <div class="item" style="--value: 150"></div>
  <div class="item c"></div>
  <div class="median"><span class="f"></span> <output></output></div>
</div>
@property --result { syntax: '<integer>'; inherits: true; initial-value: 0; }
.median { display: block; container-type: inline-size; }
.median > * { --result: round(100cqw / 1px); }
output::after { counter-reset: result var(--result); content: counter(result); }
.item { width: calc(var(--value) * 1px); }
/* only draws the items */
.item { display: block; height: 30px; background: #3a4049; }
.item::before { counter-reset: value var(--value); content: counter(value); }
.g { display: inline-grid; }
.item { grid-row: 1; order: var(--value); }
.c { --value: var(--c); }
.median { grid-row: 2; grid-column: 3; }
.f::before {
  counter-reset: c var(--c);
  content: "median(190, 130, 240, 150, " counter(c) ") =";
}
/* the values, and the button */
:root { --c: 180; }
body:has(:checked) { --c: 120; }

Tick the Last input and the last value drops below the middle, so the median becomes 150.

2.2. Rank

A rank is how many items come before one once they’re sorted. Sorted by order into 100px columns, an item sits 100px further right for every item before it. An element stretched from the grid’s left edge to that item, an anchor, is as wide as that distance, and its query container divides it by 100px to read the rank. Of two equal values, the one earlier in the markup comes first.

Inputs
View the code (1.6 KB)
<label><input type="checkbox"> Last: 70 → 50</label>
<div class="g">
  <div class="item" style="--value: 42"></div>
  <div class="item" style="--value: 88"></div>
  <div class="item t" style="--value: 61"></div>
  <div class="item" style="--value: 17"></div>
  <div class="item c"></div>
  <div class="before">
    <div class="rank"><span class="f"></span> <output></output></div>
  </div>
</div>
@property --result { syntax: '<integer>'; inherits: true; initial-value: 0; }
.rank { display: block; container-type: inline-size; }
output::after { counter-reset: result var(--result); content: counter(result); }
/* only draws the items */
.item { display: block; height: 30px; background: #3a4049; }
.item::before { counter-reset: value var(--value); content: counter(value); }
/* the items sorted by order, one 100px column each */
.g {
  display: inline-grid;
  grid-auto-flow: column;
  grid-auto-columns: 100px;
  position: relative;
}
.item { width: auto; order: var(--value); }
.c { --value: var(--c); }
/* the ranked item is an anchor; an element from the grid's left edge to its left edge, under the row */
.t { anchor-name: --ranked; background: #6a5627; }
.before { position: absolute; left: 0; right: anchor(--ranked left); top: calc(100% + 8px); }
.g { margin-bottom: 44px; }
/* only makes room for the answer under the row */
/* the query container inside it: 100px times the items before */
.rank { width: 100%; padding: 0; }
.rank > * { --result: round(100cqw / 100px); }
.f::before { content: "items before 61 ="; }
/* the values, and the button */
:root { --c: 70; }
body:has(:checked) { --c: 50; }

When the last value drops from 70 to 50, three items come before 61.

2.3. Performance

Performance is notoriously hard to test, but calc() plus layout wins on some clearly hard cases. Each primitive was timed in Chrome on 300 numbers that change at every update, in plain calc() and with layout helping it.

Primitive Plain calc() calc() plus layout
Atitem i, for a computed i 99.1 ms, 31 KB of CSS 3.9 ms
Rankeach number’s place, sorted 95 ms, 5.0 MB of CSS 6 ms
Sortthe numbers in order 36.8 ms, 1.8 MB of CSS 3.2 ms
Line countlines a row takes as it wraps 3 ms, 101 KB of CSS 1 ms
Equal growth with a capa grid’s width shared by capped tracks 2.9 ms, 3 KB of CSS 1.8 ms
Prefix sumwhere each one starts 0.7 ms, 36 KB of CSS 0.8 ms

3. Data-driven stylesheets

d3 is an extremely powerful JavaScript library, with modules for scales, shapes, layouts and statistics. It stands for Data-Driven Documents: bind data to the page and let it decide what appears. I’ve always been drawn to it.

A data-driven stylesheet attempts to do the same with just CSS: the numbers live in custom properties, which are computed by markup into sizes, positions and colors. Layout supplies the algorithm, and the HTML the dataset. Ideally, adding an element adds work for the browser, and not a term to a formula.

That is the goal of dCSS, an open source library of data functions, each one a class you put on HTML. Each function is valid, semantic HTML, and its result comes from layout, or from calc() where arithmetic is enough.

3.1. Using a function

A dCSS function is a class you put on a <figure> element. Each number is an <li> with its --value, and each empty <output> after the list prints one answer, such as --dcss-mean, formatted by dcss-format-fixed to its --precision. Since a screen reader can’t read a custom property, the list is aria-hidden and the <figcaption> gives the numbers in words, so it stays accessible.

<link rel="stylesheet" href="dcss.css">
<link rel="stylesheet" href="families/array.css">
<link rel="stylesheet" href="families/format.css">

<figure class="dcss-summarize dcss-format-fixed">
  <ul aria-hidden="true">
    <li style="--value: 12"></li>
    <li style="--value: 40"></li>
    <li style="--value: 30"></li>
  </ul>
  <output style="--value: var(--dcss-sum); --precision: 0"></output>   <!-- 82 -->
  <output style="--value: var(--dcss-mean); --precision: 1"></output>  <!-- 27.3 -->
  <output style="--value: var(--dcss-max); --precision: 0"></output>   <!-- 40 -->
  <figcaption>The sum, mean and largest of 12, 40 and 30</figcaption>
</figure>

3.2. Drawing a chart

Let’s use this pattern to build something concrete. We can visualize how much of the web uses six CSS layout properties, month by month since 2016. A registered number runs from the first month to the last, and each property’s share is a keyframe per month, so the browser works out every value in between. The lines are drawn once, as SVG, and a clip-path uncovers them as the months go by.

As the lines climb, the y-axis grows to fit them. That is the part layout computes, with dcss-max: each property is one <li>, its --value the highest share it has reached so far. The plot takes the largest, adds a tenth so no line touches the top, and scales the gridlines and the lines to it.

CSS layout, adopted
View the code (1.3 KB)
<!-- A minimal example of the pattern, not the chart's own code: -->
<!-- dcss-max reads the largest value, and the bars and gridlines scale to it. -->
<link rel="stylesheet" href="dcss.css">
<link rel="stylesheet" href="families/array.css">

<figure class="dcss-max">
  <ul aria-hidden="true">
    <!-- tenths of a percent: dCSS takes whole numbers -->
    <li style="--value: 707"></li>
    <li style="--value: 664"></li>
    <li style="--value: 389"></li>
  </ul>
  <div class="plot">
    <i class="gridline" style="--p: 20"></i>
    <i class="gridline" style="--p: 40"></i>
    <i class="gridline" style="--p: 60"></i>
    <b class="bar" style="--v: 70.7"></b>
    <b class="bar" style="--v: 66.4"></b>
    <b class="bar" style="--v: 38.9"></b>
  </div>
  <figcaption>flex-wrap 70.7%, gap 66.4%, aspect-ratio 38.9%</figcaption>
</figure>

<style>
  .plot { position: relative; height: 200px; display: flex; align-items: end; gap: 8px; }
  .plot > * { --max: max(var(--dcss-max) / 10 * 1.1, 12); }   /* the largest, a tenth more, never under 12% */
  .gridline { position: absolute; inset: auto 0; top: calc((1 - var(--p) / var(--max)) * 100%); border-top: 1px solid #333; }
  .bar { flex: 1; height: calc(var(--v) / var(--max) * 100%); background: coral; }
</style>

4. Browser support

Feature Baseline Chrome Safari Firefox
Container query units Widely available 105+ 16+ 110+
:has() Widely available 105+ 15.4+ 121+
@property Newly available 2024 85+ 16.4+ 128+
Math functions: round(), mod(), abs(), sign(), sin() Newly available 2025 138+ 15.4+ 118+
anchor-name and anchor() Newly available 2026 125+ 26+ 147+
Style queries on custom properties Newly available 2026 111+ 18+ 151+
sibling-index() Newly available 2026 138+ 26.2+ 154+
View timelines, with timeline-scope Newly available 2026 116+ 26+ 159+
Dividing a length by a length (100cqw / 1px) No Baseline entry 140+ 26+ 158+

Until now, a page without JavaScript mostly flipped switches: a checkbox, a :target, a hover. With the layout engine’s math on our side, a stylesheet can run what we usually reached for a script for, and it gets even more powerful paired with Progressive Enhancement.

Given enough elements and time, CSS can compute anything.