Aduok Code

A Circular Gallery, Built from CSS offset-path, Anchor Positioning, and a Sibling-Distance Hover Chain

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.

What you will learn

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.

01
Part One
Placing Cards on a Circle

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.

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

02
Part Two
The Enlarged Center View

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.

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

03
Part Three
Pinning a Card with :target

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.

CSS
.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 gotcha worth knowing about

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.

04
Part Four
The Neighbor-Shift Hover Chain

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.

CSS
/* 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.

05
Part Five
A Rotation Slider on One Variable

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.

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

06
Part Six
Reduced Motion and Focus

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.

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

PropertyExampleEffect
--ring-radius38vminOverall size of the circle the cards sit on; larger spreads cards further apart
--arc-span.94How much of the full circle the cards occupy; below 1 leaves a visible gap at the seam
--arc-center.275Where along the circle that gap sits; moves the ring's visual "start" point
--card-duration / --card-easing900ms / custom cubic-bezierHow quickly and with what feel a card glides to its new offset-distance
--shift-unit.0075How far a neighboring card is nudged per --d step during the hover chain
--card-widthclamp(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.

HTML — circular-gallery.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/circular-gallery.html
Want to see it in action?Watch the full build on YouTube