> All in One 586

Ads

Thursday, February 12, 2026

Making a Responsive Pyramidal Grid With Modern CSS

In the previous article, we built the classic hexagon grid. It was a responsive implementation without the use of media queries. The challenge was to improve a five-year old approach using modern CSS.

Support is limited to Chrome only because this technique uses recently released features, including corner-shape, sibling-index(), and unit division.

In this article, we will explore another type of grid: a pyramidal one. We are still working with hexagon shapes, but a different organization of the elements.

A demo worth a thousand words:

For better visualization, open the full-page view of the demo to see the pyramidal structure. On screen resize, you get a responsive behavior where the bottom part starts to behave similarly to the grid we created in the previous article!

Showing how a stack of hexagon shapes arranged in a pyramid grid needs to respond to changes in screen size, highlighting on hexagon on the left edge and how it needs to adjust according to the new layout.

Cool right? All of this was made without a single media query, JavaScript, or a ton of hacky CSS. You can chunk as many elements as you want, and everything will adjust perfectly.

Before we start, do yourself a favor and read the previous article if you haven’t already. I will skip a few things I have already explained there, such as how the shapes are created as well as a few formulas I will reuse here. Similar to the previous article, the implementation of the pyramidal grid is an improvement of a five-year old approach, so if you want to make a comparison between 2021 and 2026, check out that older article as well.

The Initial Configuration

This time, we will rely on CSS Grid instead of Flexbox. With this structure, it’s easy to control the placement of items inside columns and rows rather than adjusting margins.

<div class="container">
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <!-- etc. -->
</div>
.container {
  --s: 40px;  /* size  */
  --g: 5px;   /* gap */

  display: grid;
  grid-template-columns: repeat(auto-fit, var(--s) var(--s));
  justify-content: center;
  gap: var(--g);
}

.container > * {
  grid-column-end: span 2;
  aspect-ratio: cos(30deg);
  border-radius: 50% / 25%;
  corner-shape: bevel;
  margin-bottom: calc((2*var(--s) + var(--g))/(-4*cos(30deg)));
}

I am using the classic repeated auto-fit to create as many columns as the free space allows. For the items, it’s the same code of the previous article for creating hexagon shapes.

You wrote var(--s) twice. Is that a typo?

It’s not! I want my grid to always have an even number of columns, where each item spans two columns (that’s why I am using grid-column-end: span 2). With this configuration, I can easily control the shifting between the different rows.

Zooming into the gap between hexagon shapes, which are highlighted in pink.

Above is a screenshot of DevTools showing the grid structure. If, for example, item 2 spans columns 3 and 4, then item 4 should span columns 2 and 3, item 5 should span columns 4 and 5, and so on.

It’s the same logic with the responsive part. Each first item of every other row is shifted by one column and starts on the second column.

Zooming into the gap between hexagon shapes, which are highlighted in pink.

With this configuration, the size of an item will be equal to 2*var(--s) + var(--g). For this reason, the negative bottom margin is different from the previous example.

So, instead of this:

margin-bottom: calc(var(--s)/(-4*cos(30deg)));

…I am using:

margin-bottom: calc((2*var(--s) + var(--g))/(-4*cos(30deg)));

Nothing fancy so far, but we already have 80% of the code. Believe it or not, we are only one property away from completing the entire grid. All we need to do is set the grid-column-start of a few elements to have the correct placement and, as you may have guessed, here comes the trickiest part involving a complex calculation.

The Pyramidal Grid

Let’s suppose the container is large enough to contain the pyramid with all the elements. In other words, we will ignore the responsive part for now. Let’s analyze the structure and try to identify the patterns:

A stack of 28 hexagon shapes arranged in a pyramid-shaped grid. The first diagonal row on the right is highlighted showing how the shapes are aligned on the sides.

Regardless of the number of items, the structure is somehow static. The items on the left (i.e., the first item of each row) are always the same (1, 2, 4, 7, 11, and so on). A trivial solution is to target them using the :nth-child() selector.

:nth-child(1) { grid-column-start: ?? }
:nth-child(2) { grid-column-start: ?? }
:nth-child(4) { grid-column-start: ?? }
:nth-child(7) { grid-column-start: ?? }
:nth-child(11) { grid-column-start: ?? }
/* etc. */

The positions of all of them are linked. If item 1 is placed in column x, then item 2 should be placed in column x - 1, item 4 in column x - 2, and so forth.

:nth-child(1) { grid-column-start: x - 0 } /* 0 is not need but useful to see the pattern*/
:nth-child(2) { grid-column-start: x - 1 }
:nth-child(4) { grid-column-start: x - 2 }
:nth-child(7) { grid-column-start: x - 3 }
:nth-child(11) { grid-column-start: x - 4 }
/* etc. */

Item 1 is logically placed in the middle, so if our grid contains N columns, then x is equal to N/2:

:nth-child(1) { grid-column-start: N/2 - 0 }
:nth-child(2) { grid-column-start: N/2 - 1 }
:nth-child(4) { grid-column-start: N/2 - 2 }
:nth-child(7) { grid-column-start: N/2 - 3 }
:nth-child(11){ grid-column-start: N/2 - 4 }

And since each item spans two columns, N/2 can also be seen as the number of items that can fit within the container. So, let’s update our logic and consider N to be the number of items instead of the number of columns.

:nth-child(1) { grid-column-start: N - 0 }
:nth-child(2) { grid-column-start: N - 1 }
:nth-child(4) { grid-column-start: N - 2 }
:nth-child(7) { grid-column-start: N - 3 }
:nth-child(11){ grid-column-start: N - 4 }
/* etc. */

To calculate the number of items, I will use the same formula as in the previous article:

N = round(down, (container_size + gap)/ (item_size + gap));

The only difference is that the size of an item is no longer var(--s)but 2*var(--s) + var(--g), which gives us the following CSS:

.container {
  --s: 40px;  /* size  */
  --g: 5px;   /* gap */

  container-type: inline-size; /* we make it a container to use 100cqw */
}

.container > * {
  --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));
}

.container > *:nth-child(1) { grid-column-start: calc(var(--_n) - 0) }
.container > *:nth-child(2) { grid-column-start: calc(var(--_n) - 1) }
.container > *:nth-child(4) { grid-column-start: calc(var(--_n) - 2) }
.container > *:nth-child(7) { grid-column-start: calc(var(--_n) - 3) }
.container > *:nth-child(11){ grid-column-start: calc(var(--_n) - 4) }
/* etc. */

It works! We have our pyramidal structure. It’s not yet responsive, but we will get there. By the way, if your goal is to build such a structure with a fixed number of items, and you don’t need responsive behavior, then the above is perfect and you’re done!

How come all the items are correctly placed? We only defined the column for a few items, and we didn’t specify any row!

That’s the power of the auto-placement algorithm of CSS Grid. When you define the column for an item, the next one will be automatically placed after it! We don’t need to manually specify a bunch of columns and rows for all the items.

Improving the Implementation

You don’t like those verbose :nth-child() selectors, right? Me too, so let’s remove them and have a better implementation. Such a pyramid is well known in the math world, and we have something called a triangular number that I am going to use. Don’t worry, I will not start a math course, so here is the formula I will be using:

j*(j + 1)/2 + 1 = index

…where j is a positive integer (zero included).

In theory, all the :nth-child can be generated using the following pseudo code:

for(j = 0; j< ?? ;j++) {
  :nth-child(j*(j + 1)/2 + 1) { grid-column-start: N - j }
}

We don’t have loops in CSS, so I will follow the same logic I did in the previous article (which I hope you read, otherwise you will get a bit lost). I express j using the index. I solved the previous formula, which is a quadratic equation, but I am sure you don’t want to get into all that math.

j = sqrt(2*index - 1.75) - .5

We can get the index using the sibling-index() function. The logic is to test for each item if sqrt(2*index - 1.75) - .5 is a positive integer.

.container {
  --s: 40px; /* size  */
  --g: 5px; /* gap */

  container-type: inline-size; /* we make it a container to use 100cqw */
}
.container > * {
  --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));
  --_j: calc(sqrt(2*sibling-index() - 1.75) - .5);
  --_d: mod(var(--_j),1);
  grid-column-start: if(style(--_d: 0): calc(var(--_n) - var(--_j)););
}

When the --_d variable is equal to 0, it means that --_j is an integer; and when that’s the case I set the column to N - j. I don’t need to test if --_j is positive because it’s always positive. The smallest index value is 1, so the smallest value of --_j is 0.

Tada! We replaced all the :nth-child() selectors with three lines of CSS that cover any number of items. Now let’s make it responsive!

The Responsive Behavior

Back in my 2021 article, I switched between the pyramidal grid and the classic grid based on screen size. I will do something different this time. I will keep building the pyramid until it’s no longer possible, and from there, it will turn into the classic grid.

Showing a stack of hexagon shapes arranged in two shapes: on top is the pyramid grid and below that it becomes a rectangular grid.

Items 1 to 28 form the pyramid. After that, we get the same classic grid we built in the previous article. We need to target the first items of some rows (29, 42, etc.) and shift them. We are not going to set a margin on the left this time, but we do need to set their grid-column-start value to 2.

As usual, we identify the formula of the items, express it using the index, and then test if the result is a positive integer or not:

N*i + (N - 1)*(i - 1) + 1 + N*(N - 1)/2 = index

So:

i = (index - 2 + N*(3 - N)/2)/(2*N - 1)

When i is a positive integer (zero excluded), we set the column start to 2.

.container {
  --s: 40px; /* size  */
  --g: 5px; /* gap */

  container-type: inline-size; /* we make it a container to use 100cqw */
}
.container > * {
  --_n: round(down,(100cqw + var(--g))/(2*(var(--s) + var(--g))));

  /* code for the pyramidal grid */
  --_j: calc(sqrt(2*sibling-index() - 1.75) - .5);
  --_d: mod(var(--_j),1);
  grid-column-start: if(style(--_d: 0): calc(var(--_n) - var(--_j)););

  /* code for the responsive grid */
  --_i: calc((sibling-index() - 2 + (var(--_n)*(3 - var(--_n)))/2)/(2*var(--_n) - 1));
  --_c: mod(var(--_i),1);
  grid-column-start: if(style((--_i > 0) and (--_c: 0)): 2;);
}

Unlike the --_j variable, I need to test if --_i is a positive value, as it can be negative for some index values. For this reason, I have an extra condition compared to the first one.

But wait! That’s no good at all. We are declaring grid-column-start twice, so only one of them will get used. We should have only one declaration, and for that, we can combine both conditions using a single if() statement:

grid-column-start:
if(
  style((--_i > 0) and (--_c: 0)): 2; /* first condition */
  style(--_d: 0): calc(var(--_n) - var(--_j)); /* second condition */
);

If the first condition is true (the responsive grid), we set the value to 2; else if the second condition is true (the pyramidal grid), we set the value to calc(var(--_n) - var(--_j)); else we do nothing.

Why that particular order?

Because the responsive grid should have a higher priority. Check the figure below:

Showing how a stack of hexagon shapes arranged in a pyramid grid needs to respond to changes in screen size, highlighting on hexagon on the left edge and how it needs to adjust according to the new layout.

Item 29 is part of the pyramidal grid since it’s the first item in its row. This means that the pyramidal condition will always be true for that item. But when the grid becomes responsive, that item becomes part of the responsive grid, and the other condition is also true. When both conditions are true, the responsive condition one should win; that’s why it’s the first condition we test.

Let’s see this in play:

Oops! The pyramid looks good, but after that, things get messy.

To understand what is happening, let’s look specifically at item 37. If you check the previous figure, you will notice it’s part of the pyramidal structure. So, even if the grid becomes responsive, its condition is still true and it gets a column value from the formula calc(var(--_n) - var(--_j)) which is not good because we want to keep its default value for auto-placement. That’s the case for many items, so we need to fix them.

To find the fix, let’s see how the values in the pyramid behave. They all follow the formula N - j, where j is a positive integer. If, for example, N is equal to 10 we get:

10, 9, 8, 7, ... ,0, -1 , -2

At certain points, the values become negative, and since negative values are valid, those items will be randomly placed, disrupting the grid. We need to ensure the negative values are ignored, and the default value is used instead.

We use the following to keep only the positive value and transform all the negative ones into zeroes:

max(0, var(--_n) - var(--_j))

We set 0 as a minimum boundary (more on that here) and the values become:

10, 9, 8, 7, ... , 0, 0, 0, 0

We either get a positive value for the column or we get 0.

But you said the value should be the default one and not 0.

Yes, but 0 is an invalid value for grid-column-start, so using 0 means the browser will ignore it and fall back to the default value!

Our new code is:

grid-column-start:
  if(
    style((--_i > 0) and (--_c: 0)): 2; /* first condition */
    style(--_d: 0): max(0,var(--_n) - var(--_j)); /* second condition */
  );

And it works!

You can add as many items as you want, resize the screen, and everything will fit perfectly!

More Examples

Enough code and math! Let’s enjoy more variations using different shapes. I’ll let you dissect the code as homework.

Rhombus grid

You will notice a slightly different approach for setting the gap between the elements in the next three demos.

Octagon grid

Circle grid

And the other hexagon grid:

Conclusion

Do you remember when I told you that we were one property away from completing the grid? That one property (grid-column-start) took us literally the whole article to discuss! This demonstrates that CSS has evolved and requires a new mindset to work with. CSS is no longer a language where you simply set static values such color: red, margin: 10px, display: flex, etc.

Now we can define dynamic behaviors through complex calculations. It’s a whole process of thinking, finding formulas, defining variables, creating conditions, and so on. That’s not something new since I was able to do the same in 2021. However, we now have stronger features that allow us to have less hacky code and more flexible implementations.


Making a Responsive Pyramidal Grid With Modern CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/YCvE4dV
via IFTTT

Wednesday, February 11, 2026

Partly Cloudy today!



With a high of F and a low of 30F. Currently, it's 38F and Clear outside.

Current wind speeds: 8 from the South

Pollen: 0

Sunrise: February 11, 2026 at 07:48PM

Sunset: February 12, 2026 at 06:23AM

UV index: 0

Humidity: 51%

via https://ift.tt/pxbSlKv

February 12, 2026 at 10:02AM

Approximating contrast-color() With Other CSS Features

You have an element with a configurable background color, and you’d like to calculate whether the foreground text should be light or dark. Seems easy enough, especially knowing how mindful we ought to be with accessibility.

There have been a few drafts of a specification function for this functionality, most recently, contrast-color() (formerly color-contrast()) in the CSS Color Module Level 5 draft. But with Safari and Firefox being the only browsers that have implemented it so far, the final version of this functionality is likely still a ways off. There has been a lot of functionality added to CSS in the meantime; enough that I wanted to see whether we could implement it in a cross-browser friendly way today. Here’s what I have:

color: oklch(from <your color> round(1.21 - L) 0 0);

Let me explain how I got here.

WCAG 2.2

WCAG provides the formulas it uses for calculating the contrast between two RGB colors and Stacie Arellano has described in great detail. It’s based on older methods, calculating the luminance of colors (how perceptually bright they appear) and even tries to clamp for the limitations of monitors and screen flare:

L1 + 0.05 / L2 + 0.05

…where the lighter color (L1) is on the top. Luminance ranges from 0 to 1, and this fraction is responsible for contrast ratios going from 1 (1.05/1.05) to 21 (1.05/.05).

The formulas for calculating the luminance of RGB colors are even messier, but I’m only trying to determine whether white or black will have higher contrast with a given color, and can get away with simplifying a little bit. We end up with something like this:

L = 0.1910(R/255+0.055)^2.4 + 0.6426(G/255+0.055)^2.4 + 0.0649(B/255+0.055)^2.4

Which we can convert into CSS like this:

calc(.1910*pow(r/255 + .055,2.4)+.6426*pow(g/255 + .055,2.4)+.0649*pow(b/255 + .055,2.4))

We can make this whole thing round to 1 or 0 using round(), 1 for white and 0 for black:

round(.67913 - .1910*pow(r/255 + .055, 2.4) - .6426*pow(g/255 + .055, 2.4) - .0649*pow(b/255 + .055, 2.4))

Let’s multiply that by 255 and use it for all three channels with the relative color syntax. We end up with this:

color: rgb(from <your color>  
  round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
  round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
  round(173.178 - 48.705*pow(r/255 + .055, 2.4) - 163.863*pow(g/255 + .055, 2.4) - 16.5495*pow(b/255 + .055, 2.4), 255)  
);

A formula that, given a color, returns white or black based on WCAG 2. It’s not easy to read, but it works… except APCA is poised to replace it as a newer, better formula in future WCAG guidelines. We can do the math again, though APCA is an even more complicated formula. We could leverage CSS functions to clean it up a little, but ultimately this implementation is going to be inaccessible, hard to read, and difficult to maintain.

New Approach

I took a step back and thought about what else we have available. We do have another new feature we can try out: color spaces. The “L*” value in the CIELAB color space represents perceptual lightness. It is meant to reflect what our eyes can see. It’s not the same as luminance, but it’s close. Maybe we could guess whether to use black or white for better contrast based on perceptual lightness; let’s see if we can find a number where any color with lower lightness we use black, and higher lightness we use white.

You might instinctively think it should be 50% or .5, but it isn’t. A lot of colors, even when they’re bright, still contrast better with white than black. Here’s some examples using lch(), slowly increasing the lightness while keeping the hue the same:

The transition point where it’s easier to read the black text than white usually happens between 60-65. So, I put together a quick Node app using Colorjs.io to calculate where the cut off should be, using APCA for calculating contrast.

For oklch(), I found the threshold to be between .65 and .72, with an average of .69.

In other words:

  • When the OKLCH lightness is .72 or above, black will always contrast better than white.
  • Below .65, white will always contrast better than black.
  • Between .65 and .72, typically both black and white have contrasts between 45-60.

So, just using round() and the upper bound of .72, we can make a new, shorter implementation:

color: oklch(from <your color> round(1.21 - L) 0 0);

If you’re wondering where 1.21 came from, it’s so that .72 rounds down and .71 rounds up: 1.21 - .72 = .49 rounds down, and 1.21 - .71 = .5 rounds up.

This formula works pretty well, having put a couple iterations of this formula into production. It’s easier to read and maintain. That said, this formula more closely matches APCA than WCAG, so sometimes it disagrees with WCAG. For example, WCAG says black has a higher contrast (4.70 than white at 4.3) when placed on #407ac2, whereas APCA says the opposite: black has a contrast of 33.9, and white has a contrast of 75.7. The new CSS formula matches APCA and shows white:

A blue rectangle with white and black text compared on top. White says APCA and black says WCAG.

Arguably, this formula may do a better job than WCAG 2.0 because it more closely matches APCA. That said, you’ll still need to check accessibility, and if you’re held legally to WCAG instead of APCA, then maybe this newer simpler formula is less helpful to you.

LCH vs. OKLCH

I did run the numbers for both, and aside from OKLCH being designed to be a better replacement for LCH, I also found that the numbers support that OKLCH is a better choice.

With LCH, the gap between too dark for black and too light for white is often bigger, and the gap moves around more. For example, #e862e5 through #fd76f9 are too dark for black and too light for white. With LCH, that runs between lightness 63 through 70; for OKLCH, it’s .7 through .77. The scaling of OKLCH lightness just better matches APCA.

One Step Further

While “most-contrast” will certainly be better, we can implement one more trick. Our current logic simply gives us white or black (which is what the color-contrast() function is currently limited to), but we can change this to give us white or another given color. So, for example, white or the base text color. Starting with this:

color: oklch(from <your color> round(1.21 - L) 0 0);  

/* becomes: */

--white-or-black: oklch(from <your color> round(1.21 - L) 0 0);  
color: rgb(  
  from color-mix(in srgb, var(--white-or-black), <base color>)  
  calc(2*r) calc(2*g) calc(2*b)  
);

It’s some clever math, but it isn’t pleasant to read:

  • If --white-or-black is white, color-mix() results in rgb(127.5, 127.5, 127.5) or brighter; doubled we’re at rgb(255, 255, 255) or higher, which is just white.
  • If --white-or-black is black, color-mix() cuts the value of each RGB channel by 50%; doubled we’re back to the original value of the <base color>.

Unfortunately, this formula doesn’t work in Safari 18 and below, so you need to target Chrome, Safari 18+ and Firefox. However, it does give us a way with pure CSS to switch between white and a base text color, instead of white and black alone, and we can fallback to white and black in Safari <18.

You can also rewrite these both using CSS Custom Functions, but those aren’t supported everywhere yet either:

@function --white-black(--color) {  
  result: oklch(from var(--color) round(1.21 - l) 0 0);  
}

@function --white-or-base(--color, --base) {  
  result: rgb(from color-mix(in srgb, --white-black(var(--color)), var(--base)) calc(2*r) calc(2*g) calc(2*b));  
}

Conclusion

I hope this technique works well for you, and I’d like to reiterate that the point of this approach — looking for a threshold and a simple formula — is to make the implementation flexible and easy to adapt to your needs. You can easily adjust the threshold to whatever works best for you.


Approximating contrast-color() With Other CSS Features originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/oFkzOwf
via IFTTT

Tuesday, February 10, 2026

Mostly Clear today!



With a high of F and a low of 23F. Currently, it's 30F and Clear outside.

Current wind speeds: 6 from the Southeast

Pollen: 0

Sunrise: February 10, 2026 at 07:49PM

Sunset: February 11, 2026 at 06:22AM

UV index: 0

Humidity: 65%

via https://ift.tt/jr4f2GC

February 11, 2026 at 10:02AM

Monday, February 9, 2026

Mostly Clear today!



With a high of F and a low of 25F. Currently, it's 45F and Clear outside.

Current wind speeds: 12 from the North

Pollen: 0

Sunrise: February 9, 2026 at 07:50PM

Sunset: February 10, 2026 at 06:21AM

UV index: 0

Humidity: 30%

via https://ift.tt/mMWOr1z

February 10, 2026 at 10:02AM

Trying to Make the Perfect Pie Chart in CSS

Sunday, February 8, 2026

Clear today!



With a high of F and a low of 34F. Currently, it's 39F and Clear outside.

Current wind speeds: 6 from the Southwest

Pollen: 0

Sunrise: February 8, 2026 at 07:51PM

Sunset: February 9, 2026 at 06:20AM

UV index: 0

Humidity: 34%

via https://ift.tt/yMBlV1D

February 9, 2026 at 10:02AM

Making a Responsive Pyramidal Grid With Modern CSS

In the previous article , we built the classic hexagon grid. It was a responsive implementation without the use of media queries. The challe...