
If you’ve ever held a Pantone fan deck — the kind graphic designers used to carry around like a sacred artifact — you know the satisfying way those cards fan out from a single rivet point. Each card swings on its own arc, and you flip through the colors by hand.
I wanted to recreate that experience for this week’s CodePen challenge, which is all about color palettes.
Follow along as we build a fully interactive color fan deck where the spread adapts to the container width, cards know their position among their siblings, and clicking a card to "focus" it is handled entirely by the browser’s native <details> element.
No JavaScript! Let’s dive in!
Our fan deck is a <section> containing a cover card, followed by color cards, each wrapped in a <details> element:
<section>
<!-- cover card -->
<details name="deck">
<summary>Reds <span>×</span></summary>
<ul>
<li style="--c: lab(45% 67 30)">
<strong>Poppy Red</strong>
<dl>
<dt>HEX</dt><dd>#DC3D4C</dd>
<dt>RGB</dt><dd>220, 61, 76</dd>
<dt>LAB</dt><dd>45, 56, 25</dd>
</dl>
</li>
<!-- more colors -->
</ul>
</details>
<details name="deck">
<summary>Blues <span>×</span></summary>
<!-- ... -->
</details>
<!-- more cards -->
</section>
A few things to note:
<details name="deck"> element. The name attribute is the key — it makes them an exclusive accordion. Only one can be open at a time, and clicking the open one closes it.<summary> serves as both the card label and the click target.Not much to see yet. Let’s add some CSS:
I won’t go into the CSS in depth here; it’s simply a <ul> with the color values defined in a <dl> and wrapped up in a grid.
First, we need all cards to occupy the same grid cell, stacked on top of each other:
section {
container-type: inline-size;
display: grid;
place-items: end center;
}
section > * {
grid-area: 1 / -1;
z-index: calc(sibling-count() - sibling-index());
}
Setting container-type: inline-size on the <section> lets us use container query units later. Every direct child is placed in the same grid cell with grid-area: 1 / -1, creating a stack.
The z-index line uses two new CSS functions — sibling-count() and sibling-index() — to ensure the first card sits on top. The first child has sibling-index() of 1, so it gets the highest z-index. The last child gets 1. Natural stacking order — no hardcoded values, no counters, no JavaScript.
So, for now, we just see the cover card — the color cards are hidden behind it (the rivet is an ::after pseudo-element with a radial-gradient):
progress()
This is where it gets interesting. A real fan deck spreads wider when you have room, and collapses into a tight stack in a narrow space. We want the same behavior — and the new CSS progress() function makes it elegant:
section > * {
--spread: progress(100cqi, 300px, 1440px);
--end-degree: calc(var(--spread) * 45deg);
--start-degree: calc(var(--spread) * -45deg);
}
progress() returns a value between 0 and 1 based on where a value falls within a range. Here, progress(100cqi, 300px, 1440px) asks: "How far is the container’s inline size between 300px and 1440px?"
--spread is 0 — no fan, cards stacked flat.--spread is 1 — full fan, cards spanning from -45° to +45°.--spread is 0.5 — half fan.No @container queries, just one line of CSS, and the spread is continuously responsive.
sibling-index()
Now each card needs its own rotation angle, interpolated between --start-degree and --end-degree based on its position in the deck:
section > * {
rotate: calc(
var(--start-degree) +
(var(--end-degree) - var(--start-degree)) *
(sibling-index() - 1) / (sibling-count() - 1)
);
transform-origin: calc(100% - var(--rivet)) calc(100% - var(--rivet));
}
Let’s break it down:
sibling-index() - 1 gives us a zero-based position (0 for first card, 1 for second, etc.)sibling-count() - 1 gives us the total number of "gaps" between cards0 to 1 for each card’s positionThe transform-origin is set to the bottom-right corner — offset by --rivet — so all cards rotate around the same pivot point, just like a physical fan deck with a rivet pin.
Cool! The cards now fan out from a single point, and the spread adjusts automatically with the container width, but they’re not interactive yet.
Now we have:
Let’s resize the browser:
I find this incredibly satisfying!
<details>
Here’s where the <details> element earns its place. By giving all color cards name="deck", the browser enforces exclusive accordion behavior:
[open] attribute), any other open card closes automatically.But the <details> element normally hides its content when closed. We want the color cards to always be visible — the open/closed state should only affect the card’s rotation, not its content visibility. This is where the new ::details-content pseudo-element comes in:
details::details-content {
content-visibility: visible;
display: contents;
}
The ::details-content pseudo-element targets the content slot of a <details> — everything that isn’t the <summary>. By overriding content-visibility to visible and setting display: contents, the card’s color list is always rendered, regardless of the open state.
Let’s see how it looks when we select a card:
When a card is open, we want three things to happen:
We need boolean-like flags — 0 or 1 — that each card can use in its rotation formula. And we can set them entirely with CSS selectors:
/* Any card is active */
section:has(details[open]) > * { --has-active: 1; }
/* Cards before the active one */
section > :has(~ details[open]) { --is-before: 1; }
/* The active card itself */
details[open] { --is-active: 1; }
/* Cards after the active one */
details[open] ~ * { --is-after: 1; }
Four selectors, four flags. Let’s unpack them:
section:has(details[open]) matches the section when any details child is open, then sets --has-active: 1 on all children.:has(~ details[open]) matches any element that has a subsequent sibling that is details[open] — i.e., it comes before the active card.details[open] matches the active card directly.details[open] ~ * matches all subsequent siblings — the cards after the active one.The defaults are all 0, set on the base section > * rule. When no card is open, all flags are 0, and the cards fan normally.
With the flags in place, the rotation formula handles all states:
section > * {
rotate: calc(
(var(--start-degree) + (var(--end-degree) - var(--start-degree))
* (sibling-index() - 1) / (sibling-count() - 1))
* (1 - var(--is-active))
- var(--is-before) * (var(--end-degree) - var(--start-degree))
* (sibling-index() - 1) / (sibling-count() - 1) * 0.85
+ var(--is-after) * (var(--end-degree) - var(--start-degree))
* (1 - (sibling-index() - 1) / (sibling-count() - 1)) * 0.85
);
transition: rotate .25s linear;
}
So what’s going on?
* (1 - var(--is-active)): Multiplying by 0 when active zeroes out the rotation — the card snaps to 0°.0.85 factor collapses them tightly but not completely.(1 - progress) so they fan toward the opposite edge.The transition gives it a smooth, satisfying swing.
This component leans on several CSS features that are all relatively new — so use a modern browser.
| Feature | What It Does Here |
|---|---|
progress() |
Returns 0–1 based on container width, driving the fan spread |
sibling-index() |
Each card knows its position — used for rotation and z-index |
sibling-count() |
Total number of cards — used to normalize position to 0–1 |
<details name=""> |
Exclusive accordion — click to open/close, only one active |
::details-content |
Override content visibility so cards always show their colors |
I’m constantly blown away by how far CSS has progressed — and is progressing. What I find exciting is how these new features compose. None of them alone are revolutionary — but progress() feeding into sibling-index()-driven rotation, toggled by native <details> state detected via :has() selectors, all without a single line of JavaScript.
Here’s a CodePen demo. I urge you to open it full-screen, resize, click etc.: