DEV Community

Cover image for Re-creating a Pantone Color Deck in CSS
Mads Stoumann
Mads Stoumann

Posted on

Re-creating a Pantone Color Deck in CSS

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!


The Markup

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

A few things to note:

  • The cover card doesn’t toggle — it just sits at the front of the deck.
  • Each color card is a <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.
  • The <summary> serves as both the card label and the click target.

Not much to see yet. Let’s add some CSS:

Single Color Card

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.


Stacking the Deck

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

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):

Cover Card


The Fan Spread with 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);
}
Enter fullscreen mode Exit fullscreen mode

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?"

  • At 300px or below: --spread is 0 — no fan, cards stacked flat.
  • At 1440px or above: --spread is 1 — full fan, cards spanning from -45° to +45°.
  • At 870px (midpoint): --spread is 0.5 — half fan.

No @container queries, just one line of CSS, and the spread is continuously responsive.


Positioning Each Card with 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));
}
Enter fullscreen mode Exit fullscreen mode

Let’s break it down:

  1. sibling-index() - 1 gives us a zero-based position (0 for first card, 1 for second, etc.)
  2. sibling-count() - 1 gives us the total number of "gaps" between cards
  3. Dividing them gives a progress value from 0 to 1 for each card’s position
  4. We multiply that by the degree range and add the start offset

The 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:

Full spread

Let’s resize the browser:

Resized Fan Deck

I find this incredibly satisfying!


Click-to-Focus with Exclusive <details>

Here’s where the <details> element earns its place. By giving all color cards name="deck", the browser enforces exclusive accordion behavior:

  • Click a card’s summary → it opens (gets the [open] attribute), any other open card closes automatically.
  • Click the same summary again → it closes, returning to the default fan.

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

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:

Selected Color Card


CSS-Only State Detection

When a card is open, we want three things to happen:

  1. The active card rotates to 0° (straight up)
  2. Cards before it collapse toward the start
  3. Cards after it push toward the end

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

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.

The Full Rotation Formula

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

So what’s going on?

  • Line 1–2: The normal fan rotation — the same formula from before.
  • * (1 - var(--is-active)): Multiplying by 0 when active zeroes out the rotation — the card snaps to 0°.
  • Before cards: Subtract a value that pushes them further toward the start. The 0.85 factor collapses them tightly but not completely.
  • After cards: Add a value that pushes them further toward the end, using the inverse position (1 - progress) so they fan toward the opposite edge.

The transition gives it a smooth, satisfying swing.


The New CSS Features — a Recap

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

Final Thoughts

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.:

Top comments (11)

Collapse
 
sanseverino profile image
Luigi | Full Stack Web Developer

Hi Mads, this project is absolutely mind-blowing! I'm a beginner developer currently studying HTML and CSS, and seeing how you used trigonometric functions to create this Pantone deck is incredibly inspiring.

I'm working on my CSS foundations, but I'd love to reach this level of 'creative coding' one day.

If you had to suggest one specific topic to study to move from basic layouts to this kind of advanced UI, what would it be?

Thanks for sharing your brilliance with the community!

Collapse
 
madsstoumann profile image
Mads Stoumann

Hi Luigi,
Thank you! You’re on the right track: just keep reading all the cool stuff here on Dev.to (I’ve written a lot about creative CSS-stuff 😊). And do CodePen challenges! It’s a fun thing to do, you learn new stuff and get creative — highly recommended!

Collapse
 
sanseverino profile image
Luigi | Full Stack Web Developer

Thanks for the great tip, Mads! I've actually just started exploring CodePen, so I'll definitely look into the challenges. I'm also going to deep dive into your previous articles—your work is exactly the kind of 'CSS magic' I want to learn. Cheers!

Collapse
 
trinhcuong-ast profile image
Kai Alder

The sibling-index() and sibling-count() combo is wild. I've been waiting for something like this forever — no more nth-child hacks or CSS counter workarounds.

Quick question: how's browser support looking for progress() right now? I'd love to use this pattern in production but I'm guessing it's still Chromium-only? Any ideas for a graceful fallback?

Collapse
 
madsstoumann profile image
Mads Stoumann

It’s in Chrome and Safari, but not Firefox

Collapse
 
capjud95 profile image
Capin Judicael Akpado

Very cool !

Collapse
 
madsstoumann profile image
Mads Stoumann

Thanks!

Collapse
 
jarvisscript profile image
Chris Jarvis

This is very cool. Have you ever played the Pantone board game? You make famous characters with pantone cards and people have to guess what they are. Looks like 8-bit art.

Collapse
 
madsstoumann profile image
Mads Stoumann

No, just Googled it, seems fun!

Collapse
 
artydev profile image
artydev

Mads, my CSS hero :-)

Collapse
 
madsstoumann profile image
Mads Stoumann

Thank you!