Introduction
There is no click listener anywhere in this component. The entire open/close state — the cover swinging back, the files fanning out, the search field widening, the badge popping in — is driven by one native form control: a checkbox with display none, targeted by a chain of ~ sibling selectors. Everything downstream of that single boolean is pure CSS.
The interesting engineering isn’t the toggle itself, though — checkbox-driven CSS is a known trick. It’s what happens once you have five files that all need to fan out to slightly different positions, and a file count that needs to always match how many files actually exist. Those two problems are where most implementations either hardcode five near-identical CSS blocks or a manually-typed number that quietly goes stale.
Why a visually-hidden checkbox beats display:none for accessibility — how one generic CSS rule plus a per-element --i custom property replaces five duplicated file classes — how rotateY on a transform-origin edge reads as a cover opening rather than a card tilting — how background-clip:text turns a plain gradient into engraved foil lettering — and the exact DOM-ordering gotcha that makes a CSS counter() read 0 instead of 5.
Step 1 — Visually Hidden, Not display:none
The obvious way to hide the checkbox is display: none, and it’s the wrong way — a display:none element is pulled out of the tab order entirely, so keyboard users simply cannot reach it. The fix is the standard "visually hidden" clip pattern: the element still occupies a 1px box and still receives focus, it just isn’t painted.
.folder__toggle {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
border: 0;
}
.folder__toggle:focus-visible ~ .folder__stage {
outline: 2px solid var(--foil-300);
outline-offset: 5px;
}Every other rule in the component reads state off this one element with .folder__toggle:checked ~ .folder__stage .whatever. No JavaScript ever inspects or sets this checkbox — its only job is to exist, be focusable, and hold a boolean.
Step 2 — Custom Properties Instead of Duplicated Classes
A naive fan-out gives each file its own class — .file-1, .file-2, .file-3 — each repeating the same transition and box-shadow rules with only the transform numbers changed. That works, but adding a sixth file means writing a sixth near-identical block. Instead, every file gets a single shared class, and the numbers that vary live in inline custom properties.
<li class="folder__page" style="--i:0; --shift:-14px; --tilt:-8deg;">…</li>
<li class="folder__page" style="--i:1; --shift:16px; --tilt:6deg;">…</li>
<li class="folder__page" style="--i:2; --shift:-6px; --tilt:-12deg;">…</li>.folder__page {
--i: 0;
--shift: 0px;
--tilt: 0deg;
z-index: calc(20 - var(--i));
background: hsl(calc(205 + var(--i) * 35), 90%, 66%);
transition-delay: calc((4 - var(--i)) * 0.04s);
}
.folder__toggle:checked ~ .folder__stage .folder__page {
transform:
translate(var(--shift), calc(-14px - var(--i) * 15px))
rotate(var(--tilt));
}The color, the stacking order, and the stagger timing are all derived from --i with calc(). A sixth file needs one new <li> with --i:5 — zero new CSS, and it automatically gets a color one hue-step further around the wheel.
Step 3 — rotateY on a Left-Edge Hinge
A folder card that just tilts backward on rotateX reads as a card lifting off the table, not a cover opening. Swapping the rotation axis to Y, and pinning transform-origin to the left edge instead of the center, changes the read entirely — the cover now pivots on a hinge exactly like a real book or portfolio cover.
.folder__cover {
transform-origin: left center;
transition: transform 0.65s cubic-bezier(0.22, 1, 0.36, 1);
}
.folder__toggle:checked ~ .folder__stage .folder__cover {
transform: rotateY(-115deg);
}The -115deg overshoot past a flat -90deg is deliberate — it swings the cover slightly past perpendicular before easing settles it, which reads as a heavier, more physical cover than an exact quarter-turn would.
Step 4 — A Gradient, Clipped to the Letterforms
Engraved foil lettering isn’t a font effect, it’s a gradient effect: paint a vertical light-to-dark gradient across the whole title, then use background-clip: text with a transparent text color so only the letters themselves show the gradient underneath. The top of each letter reads bright gold, the bottom reads a duller bronze, exactly like light catching a stamped foil surface.
.folder__plate-title {
font-weight: 700;
letter-spacing: 0.18em;
background: linear-gradient(180deg, var(--foil-300), var(--foil-700));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}Any time a design brief calls for "engraved", "foil", or "chrome" text, reach for background-clip:text with a gradient before reaching for a text-shadow stack — the gradient reads as a material, not a lighting trick.
Step 5 — counter-reset, counter-increment, and a DOM-Order Trap
The file-count badge doesn’t contain a hardcoded number. counter-reset on the shared container starts a counter at 0, counter-increment on every .folder__page adds one, and the badge reads it back with content: counter(page-count). Add or remove a file in the markup and the badge updates on its own.
.folder__stage {
counter-reset: page-count 0;
}
.folder__page {
counter-increment: page-count 1;
}
.folder__badge-count::after {
content: counter(page-count);
}The trap: a CSS counter’s value at any point is only as high as the increments that occurred *earlier in the document tree*. Placing the badge markup before the <ul> of files — even though CSS then positions it visually above the cover — means the badge is read before a single file has incremented anything, and it permanently shows 0.
Absolute positioning controls where an element is painted. It does nothing to change where that element sits in document order — and counters only care about document order. The fix was moving the badge’s markup after the file list, with z-index doing the work of putting it back on top visually.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --i per file | 0 – 4 | Drives hue, z-index, and transition delay for that file via calc() |
| --shift / --tilt | -14px / -8deg | Hand-tuned horizontal offset and rotation for each file’s fanned position |
| transform-origin | left center | Hinge point for the cover — changes rotateY from a "tilt" into a "swing" |
| rotateY angle | -115deg | Overshoots flat to read as a heavier, more physical cover |
| counter-reset scope | ancestor of both | Must sit on an element earlier in tree order than anything it counts |
Full Source Code
The whole component is a single self-contained HTML file — markup, styles, and a small optional filter script, with no build step and no external dependencies.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/folder-card.html