Circular Gallery

Twenty photo thumbnails arranged in a circle with CSS offset-path, hover-spread interactions, and anchor-positioned full-size preview.

Circular Gallery

Twenty photo thumbnails are positioned along a circle using CSS Motion Path (offset-path: circle()). Hovering a card triggers a ripple effect — up to four neighboring cards spread apart using --d offset variables set by :has() + adjacent-sibling combinators, with no JavaScript. Clicking a card targets it via its #item-N hash anchor, revealing a full-size circular preview at the center. The preview and tooltip use CSS Anchor Positioning with position: fixed — which works self-contained because the wrapper’s transform: rotate() makes it a containing block for fixed descendants.

Requires Chrome 116+ for CSS Anchor Positioning. An @property declaration makes the rotation angle animatable.

Basic Usage

Circular Gallery

Sizes

Small

Large

CSS Custom Properties

PropertyDefaultDescription
--art-circular-gallery-size600pxOverall container size; all other dimensions scale from this
--art-circular-gallery-radiuscalc(size * 0.42)Radius of the circle on which cards are placed
--art-circular-gallery-card-widthcalc(size * 0.07)Width of each thumbnail card
--art-circular-gallery-card-border-radiuscalc(size * 0.012)Border radius of thumbnail cards
--art-circular-gallery-arc-size0.95Fraction of the circle arc used (gap between first and last card)
--art-circular-gallery-arc-center0.275Arc midpoint position; controls where the cluster appears on the circle
--art-circular-gallery-arc-shift-delta0.0075Per-unit spread amount when neighbors respond to hover
--art-circular-gallery-card-trans-duration1000msDuration of the card position transition
--art-circular-gallery-rotation0degOverall rotation of the gallery (@property typed, animatable)

Per-item inline style properties:

PropertyDescription
--iCard index (1–20); determines position on the circle arc
--bg-imgCSS url() of the image shown in the full-size center preview

HTML Structure

<section class="art-circular-gallery">
  <div id="item-1" data-title="Photographer Name"
       style="--i:1; --bg-img:url(https://example.com/photo.jpg)">
    <a href="#item-1">
      <img src="https://example.com/photo.jpg" alt="Photographer Name">
    </a>
  </div>
  <!-- repeat for items 2 through 20 -->
  <h1>Circular Gallery</h1>
</section>

Each div carries:

  • id — used as the :target anchor; clicking the a href="#item-N" activates it
  • data-title — shown as a tooltip via content: attr(data-title) on ::after
  • --i — integer 1–20 that controls where the card sits on the arc
  • --bg-img — the full-size image shown in the circular center preview

The h1 must be the last child so it sits at the center and does not consume an --i slot.

API Reference

ClassDescription
art-circular-galleryRoot element — defines the circle layout, all tokens, and hover-spread selectors
art-circular-gallery-smSmall size (--art-circular-gallery-size: 400px)
art-circular-gallery-lgLarge size (--art-circular-gallery-size: 800px)

CSS

@property --art-circular-gallery-rotation {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}

@layer css-art {
  .art-circular-gallery {
    --art-circular-gallery-size: 600px;
    --art-circular-gallery-radius: calc(var(--art-circular-gallery-size) * 0.42);
    --art-circular-gallery-card-width: calc(var(--art-circular-gallery-size) * 0.09);
    --art-circular-gallery-card-border-radius: calc(var(--art-circular-gallery-size) * 0.012);
    --art-circular-gallery-arc-size: 0.95;
    --art-circular-gallery-arc-center: 0.275;
    --art-circular-gallery-arc-start: calc(
      var(--art-circular-gallery-arc-center) - var(--art-circular-gallery-arc-size) / 2
    );
    --art-circular-gallery-arc-shift-delta: 0.0075;
    --art-circular-gallery-card-trans-duration: 1000ms;
    --art-circular-gallery-card-trans-easing: linear(
      0, 0.01 0.8%, 0.038 1.6%, 0.154 3.4%, 0.781 9.7%, 1.01 12.5%,
      1.089 13.8%, 1.153 15.2%, 1.195 16.6%, 1.219 18%, 1.224 19.7%,
      1.208 21.6%, 1.172 23.6%, 1.057 28.6%, 1.007 31.2%, 0.969 34.1%,
      0.951 37.1%, 0.953 40.9%, 0.998 50.4%, 1.011 56%, 0.998 74.7%, 1
    );

    anchor-name: --art-circular-gallery-center;
    transform: rotate(var(--art-circular-gallery-rotation));
    transition: transform 700ms linear;
    position: relative;
    width: var(--art-circular-gallery-size);
    height: var(--art-circular-gallery-size);
    aspect-ratio: 1;
    display: grid;
    place-content: center;
    font-family: sans-serif;

    h1 {
      margin: 0;
      text-align: center;
      font-size: clamp(1rem, calc(var(--art-circular-gallery-size) * 0.035), 2.5rem);
      font-weight: 300;
      letter-spacing: 0.05em;
      transform: rotate(calc(var(--art-circular-gallery-rotation) * -1));
    }

    div {
      --arc-step: calc(var(--art-circular-gallery-arc-size) / 19);
      --arc-shift: calc(var(--art-circular-gallery-arc-shift-delta) * var(--d, 1));
      --card-offset-distance: calc(
        (var(--art-circular-gallery-arc-start)
         + (var(--i) - 1) * var(--arc-step)
         + var(--arc-shift)) * 100%
      );

      &:has(a:hover), &:target { --title-opacity: 1; }
      &:target {
        --zindex: 2;
        --center-opacity: 1;
        --center-scale: 1;
        --image-outline-color: rgba(255 255 255 / 0.5);
      }

      &::before {
        content: "";
        position: fixed;
        position-anchor: --art-circular-gallery-center;
        position-area: span-all;
        transform: rotate(calc(var(--art-circular-gallery-rotation) * -1));
        width: calc(var(--art-circular-gallery-size) * 0.4);
        aspect-ratio: 1;
        border-radius: 9in;
        background: var(--bg-img);
        background-size: cover;
        background-repeat: no-repeat;
        outline: 10px solid rgba(255 255 255 / 0.3);
        outline-offset: -10px;
        opacity: var(--center-opacity, 0);
        scale: var(--center-scale, 0);
        pointer-events: none;
        transition-property: scale, opacity;
        transition-duration: 300ms, 150ms;
      }

      &::after {
        content: attr(data-title);
        position: fixed;
        position-anchor: --art-circular-gallery-card;
        position-area: center span-all;
        z-index: 2;
        pointer-events: none;
        opacity: var(--title-opacity, 0);
        transition: opacity 150ms;
        border: 1px solid rgb(255 255 255 / 0.75);
        background: rgb(0 0 0 / 0.25);
        backdrop-filter: blur(3px);
        padding: 0.25em 1em;
        border-radius: 5px;
        font-size: clamp(0.55rem, calc(var(--art-circular-gallery-size) * 0.012), 0.85rem);
        white-space: nowrap;
        @starting-style { opacity: 0; }
      }

      a {
        position: absolute;
        display: block;
        width: var(--art-circular-gallery-card-width);
        aspect-ratio: 4 / 6;
        border-radius: var(--art-circular-gallery-card-border-radius);
        border: 2px solid rgba(255 255 255 / 0.6);
        offset-path: circle(var(--art-circular-gallery-radius) at 50% 50%);
        offset-distance: var(--card-offset-distance);
        offset-rotate: auto;
        offset-anchor: 50% 0%;
        transition:
          offset var(--art-circular-gallery-card-trans-duration)
            var(--art-circular-gallery-card-trans-easing),
          scale 200ms ease;
        z-index: var(--zindex, 1);
        anchor-name: --art-circular-gallery-card;

        &:hover {
          scale: 2.5;
          z-index: 3;
        }

        img {
          width: 100%;
          height: 100%;
          object-fit: cover;
          display: block;
          border-radius: inherit;
          outline: 5px solid var(--image-outline-color, transparent);
          outline-offset: -5px;
          transition: outline 300ms ease-in-out;
        }
      }
    }
  }

  /* Forward sibling spread (+1 to +4) */
  .art-circular-gallery:has(> *:hover) > *:hover + *,
  .art-circular-gallery:has(> :last-child:hover) > :nth-child(1) { --d: 3; }

  .art-circular-gallery:has(> *:hover) > *:hover + * + *,
  .art-circular-gallery:has(> :last-child:hover) > :nth-child(2),
  .art-circular-gallery:has(> :nth-last-child(2):hover) > :nth-child(1) { --d: 2; }

  .art-circular-gallery:has(> *:hover) > *:hover + * + * + *,
  .art-circular-gallery:has(> :last-child:hover) > :nth-child(3),
  .art-circular-gallery:has(> :nth-last-child(2):hover) > :nth-child(2),
  .art-circular-gallery:has(> :nth-last-child(3):hover) > :nth-child(1) { --d: 1; }

  .art-circular-gallery:has(> *:hover) > *:hover + * + * + * + *,
  .art-circular-gallery:has(> :last-child:hover) > :nth-child(4),
  .art-circular-gallery:has(> :nth-last-child(2):hover) > :nth-child(3),
  .art-circular-gallery:has(> :nth-last-child(3):hover) > :nth-child(2),
  .art-circular-gallery:has(> :nth-last-child(4):hover) > :nth-child(1) { --d: 0.5; }

  /* Backward sibling spread (-1 to -4) */
  .art-circular-gallery *:has(+ *:hover),
  .art-circular-gallery:has(> :nth-child(1):hover) :nth-last-child(1) { --d: -3; }

  .art-circular-gallery *:has(+ * + *:hover),
  .art-circular-gallery:has(> :nth-child(1):hover) :nth-last-child(2),
  .art-circular-gallery:has(> :nth-child(2):hover) :nth-last-child(1) { --d: -2; }

  .art-circular-gallery *:has(+ * + * + *:hover),
  .art-circular-gallery:has(> :nth-child(1):hover) :nth-last-child(3),
  .art-circular-gallery:has(> :nth-child(2):hover) :nth-last-child(2),
  .art-circular-gallery:has(> :nth-child(3):hover) :nth-last-child(1) { --d: -1; }

  .art-circular-gallery *:has(+ * + * + * + *:hover),
  .art-circular-gallery:has(> :nth-child(1):hover) :nth-last-child(4),
  .art-circular-gallery:has(> :nth-child(2):hover) :nth-last-child(3),
  .art-circular-gallery:has(> :nth-child(3):hover) :nth-last-child(2),
  .art-circular-gallery:has(> :nth-child(4):hover) :nth-last-child(1) { --d: -0.5; }

  .art-circular-gallery-sm { --art-circular-gallery-size: 400px; }
  .art-circular-gallery-lg { --art-circular-gallery-size: 800px; }
}