Re-creating a Pantone Color Deck in CSS

By Mads Stoumann on Feb 11, 2026. Originally published on DEV.to.
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:

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

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:

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:

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?

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