Introduction
This gallery arranges twenty cards evenly around a circle, and lets any one of them swell into a large image at the center of the ring the instant you hover or click it. Nothing here is a carousel library or a canvas drawing — it is CSS doing the placement math (offset-path), CSS doing the "portal to the center" trick (anchor positioning), and a small chain of :has() selectors doing the job a JavaScript index-distance calculation would normally do.
The technique rests on four ideas: placing every card on the same circular path but at a different point along it, using anchor positioning so an element can visually "jump" to the ring's exact center without being a child of anything there — separating the two ways a card gets focus, a transient :hover and a persistent :target from a click, since only one of them should survive a mouse-away — and faking sibling-distance-from-hover with a short, explicit chain of :has() selectors, since CSS still has no native "how far is this sibling from the hovered one" function.
How offset-path plus offset-distance turns a plain list into evenly spaced points on a circle — how anchor-name and position-anchor let a pseudo-element on one card render pinned to the ring's center instead of over the card itself — how :target keeps a chosen card open after the mouse leaves, while :hover alone would not — how a handful of :has() rules approximate "nudge the two cards on either side of whichever one is hovered" — and how to drive the whole ring's rotation from a single CSS custom property so a plain range input can spin it.
Step 1 — offset-path Instead of Trigonometry
Each card is an <a> inside an <li>. Rather than computing x/y coordinates with sine and cosine in JavaScript, every card gets the same offset-path: circle(radius at 50% 50%), and only its offset-distance differs — expressed as a percentage of the way around that circle. Twenty cards spread evenly just means twenty evenly spaced percentages, derived from each card's position index.
.ring li {
--card-i: var(--i);
--arc-step: calc(var(--arc-span) / (var(--count) - 1));
--offset-distance: calc(
(var(--arc-start) + (var(--card-i) - 1) * var(--arc-step)) * 100%
);
}
.ring a {
position: absolute;
offset-path: circle(var(--ring-radius) at 50% 50%);
offset-distance: var(--offset-distance);
offset-rotate: auto; /* card tilts to match its point on the circle */
offset-anchor: 50% 0%;
transition: offset var(--card-duration) var(--card-easing);
}--arc-start and --arc-span control where the sequence begins and how much of the full circle it spans — pulling arc-span slightly under 1 leaves a small gap so the first and last card don't touch, and shifting arc-center moves the whole ring's "seam" to wherever looks best, here tucked toward the bottom.
Step 2 — Anchor Positioning, Not a Fixed-Position Guess
When a card is hovered or targeted, a large version of its image needs to appear dead-center in the ring — but the ring itself can be rotated, and the hovered card can be anywhere along the circle. Instead of calculating a center point, the ring gets anchor-name: --ring-center, and each card's ::before pseudo-element is told position-anchor: --ring-center with position-area: span-all, which places it exactly over that anchor regardless of where the actual card sits.
.ring {
anchor-name: --ring-center;
}
.ring li::before {
position: fixed;
position-anchor: --ring-center;
position-area: span-all;
background-image: var(--img);
background-size: cover;
opacity: var(--center-opacity, 0);
scale: var(--center-scale, .85);
transition: opacity 200ms ease, scale var(--card-duration) var(--card-easing);
}The pseudo-element never moves toward the center — it is simply anchored there from the start, and opacity/scale are the only things that change on hover.
Step 3 — Why Hover Alone Is Not Enough
Each card is also a same-page anchor link (href="#specimen-01"), so clicking it sets the URL fragment and makes that <li> match :target — a selector that stays true until a different fragment is clicked, unlike :hover which drops the instant the mouse leaves. That is what lets someone click a card, move the mouse away to read the caption, and still see the enlarged view.
.ring li:has(a:hover),
.ring li:target {
--title-opacity: 1;
}
.ring li:target {
--z: 3;
--center-opacity: 1;
--center-scale: 1;
--plate-outline: var(--line-strong);
}A visible "Close ✕" link that points to href="#" is required alongside :target — without a way to clear the fragment, a clicked card would stay pinned open forever with no CSS-only way back.
Step 4 — Faking "Distance From Hovered Sibling"
When one card is hovered, the two or three cards on either side of it get a small offset-distance nudge so the ring visually "makes room." CSS has no built-in way to ask "how many siblings away is the element currently hovered," so this is written out explicitly: one selector for +1 sibling, one for +2, one for +3, and matching selectors going backward, each setting a --d custom property consumed back in the offset-distance calculation. The chain also has to wrap around at the first and last card, since the ring has no true start or end.
/* one step clockwise from whichever card is hovered */
.ring:has(> li:hover) > li:hover + li,
.ring:has(> li:last-child:hover) > li:nth-child(1) { --d: 3; }
/* one step counter-clockwise from whichever card is hovered */
.ring li:has(+ li:hover),
.ring:has(> li:nth-child(1):hover) > li:nth-last-child(1) { --d: -3; }Repeating that pattern out to +4/-4 with progressively smaller --d values gives a shift that fades out the further a card is from the hovered one, without ever touching JavaScript.
Step 5 — One Custom Property Drives the Whole Ring
The entire ring only ever reads a single --rotation value to orient itself, registered with @property so its angle type animates smoothly instead of snapping. A plain range input just writes that one property back onto the ring element on every input event — there is no per-card rotation logic anywhere else.
const ring = document.querySelector('.ring');
const rotate = document.getElementById('rotate');
rotate.addEventListener('input', (event) => {
ring.style.setProperty('--rotation', `${event.target.value}deg`);
});Because the enlarged center view sits in fixed position rather than rotating with the ring, its own transform applies the inverse rotation (calc(var(--rotation) * -1)) so the big image always stays upright no matter how far the ring has been spun.
Step 6 — Making the Trick Usable, Not Just Clever
Every card is a real link with its own aria-label naming the specimen, so keyboard and screen-reader users get the same information a sighted mouse user gets from the caption. A visible :focus-visible outline is added since the default browser outline can get lost against a rotated, offset-path-placed element. A prefers-reduced-motion query collapses all the ring, card, and pseudo-element transitions down to near-zero duration for anyone who has that preference set.
.ring a:focus-visible {
outline: 2px solid var(--rust);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.ring, .ring a, .ring li::before, .ring li::after {
transition-duration: 1ms !important;
}
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --ring-radius | 38vmin | Overall size of the circle the cards sit on; larger spreads cards further apart |
| --arc-span | .94 | How much of the full circle the cards occupy; below 1 leaves a visible gap at the seam |
| --arc-center | .275 | Where along the circle that gap sits; moves the ring's visual "start" point |
| --card-duration / --card-easing | 900ms / custom cubic-bezier | How quickly and with what feel a card glides to its new offset-distance |
| --shift-unit | .0075 | How far a neighboring card is nudged per --d step during the hover chain |
| --card-width | clamp(38px, 6.6vw, 108px) | Size of each card thumbnail; scales with viewport between the clamp bounds |
Full Source Code
The complete component is a single self-contained HTML document — the ring markup, every :has()-based hover rule, the anchor-positioned center view, and the rotation script, and nothing else. No build step, no external assets beyond the placeholder photos.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/circular-gallery.html