A Different Kind of CSS-Only Image Slider
Most image sliders follow the same pattern: images stack and slide horizontally or vertically, often with a JavaScript library powering the transitions. There's another way to approach the problem. Instead of sliding content along a straight path, we can arrange images on an invisible circle and rotate the whole group. Each image moves along a circular track and returns to its original position after a full revolution. No image duplication is needed since a full rotation naturally loops back to the start.
This trick relies entirely on CSS transforms and some thoughtful animation timing. The HTML stays minimal: one container holding as many <img> elements as needed. The wrapper and images use CSS Grid to stack them all in the same position:
.gallery {
--s: 280px; /* control the size */
display: grid;
width: var(--s);
aspect-ratio: 1;
padding: calc(var(--s) / 20); /* we will see the utility of this later */
border-radius: 50%;
}
.gallery > img {
grid-area: 1 / 1;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
Each image is centered on a large circle that also passes through the center of every other image. To get the circular motion, each image needs a custom rotation origin. The key is setting transform-origin to a point beyond the image's own bounds — far enough that rotating it traces a circle with the right radius.

For N images of size S, the radius of the containing circle is (S/2) / sin(180deg/N). Content of a few quick geometry turns yields that the offset from an image's center is S/2 + (1/(2 * tan(180deg/N))). When N is four, this lands on 120.7%, which is how the keyframes are initially set up.
Setting Up the Motion
All images are stacked on top of each other with position: relative and they share the same size via CSS Grid placement. The rotation animation moves each image around its combined origin:
.gallery > img {
/* same as before */
animation: m 8s infinite linear;
transform-origin: 50% 120.7%;
}
@keyframes m {
100% { transform: rotate(-360deg); }
}
If every image rotates in sync, only the top one is visible. The next step is to stagger each image's animation start with a negative delay so they're spread evenly around the circle. With four images and an animation of 12 seconds, the delays become 0s, -3s, -6s, and -9s:
.gallery > img:nth-child(2) { animation-delay: -2s; } /* -1 * 8s / 4 */
.gallery > img:nth-child(3) { animation-delay: -4s; } /* -2 * 8s / 4 */
.gallery > img:nth-child(4) { animation-delay: -6s; } /* -3 * 8s / 4 */
Without hiding overflow yet, you'd see something interesting but not yet clean — each image is visible during its own quarter of the rotation, but there's overlap you need to deal with. Adding a pause inside the keyframes prevents continuous motion between images. For four images, that's 90-degree steps, each followed by a brief stillness:
@keyframes m {
0%, 3% { transform: rotate(0); }
22%, 27% { transform: rotate(-90deg); }
47%, 52% { transform: rotate(-180deg); }
72%, 77% { transform: rotate(-270deg); }
98%, 100% { transform: rotate(-360deg); }
}
Now each image pauses in view for a moment before moving along. An easing curve like cubic-bezier() on the keyframes makes the transition smooth rather than abrupt.
Scaling Up to Any Image Count
The hardcoded values work only when there are exactly four images. To support N images without manually calculating offsets and pauses each time, use a CSS preprocessor like Sass. Define a variable, $n, and derive the animation timings from it:
@keyframes m {
0%, 3% { transform: rotate(0); }
22%, 27% { transform: rotate(-90deg); }
47%, 52% { transform: rotate(-180deg); }
72%, 77% { transform: rotate(-270deg); }
98%, 100% {transform: rotate(-360deg); }
}
Rotation angles are set to calc(var(--_i) / $n * 360deg) for each step, with -360deg steps applied in reverse. The timing percentages become 100% / $n per segment. Negative delays are generated by:
@for $i from 2 to ($n + 1) {
.gallery > img:nth-child(#{$i}) {
animation-delay: calc(#{(1 - $i) / $n} * 8s);
}
}
For the special case where images pause briefly after each step:
@keyframes m {
0% { transform: rotate(0); }
@for $i from 1 to $n {
#{($i / $n) * 100}% { transform: rotate(#{($i / $n) * -360}deg); }
}
100% { transform: rotate(-360deg); }
}
Updating transform-origin for N images uses the same geometric relationship. The images form a regular polygon around a central circle; each side is S. The radius from center to image center is (S/2) / sin(180deg/N). Converted to a percentage of image width for the transform-origin property gives:
transform-origin: 50% (50% / math.sin(180deg / $n) + 50%);
Finishing Touches
A rotating dashed border ties the piece together. A pseudo-element on the wrapper creates that decorative ring, with a repeating conic gradient layered against a mask to form a hollow center. It gets the same rotation animation applied to it:
.gallery {
padding: calc(var(--s) / 20); /* the padding is needed here */
position: relative;
}
.gallery::after {
content: "";
position: absolute;
inset: 0;
padding: inherit; /* Inherits the same padding */
border-radius: 50%;
background: repeating-conic-gradient(#789048 0 30deg, #DFBA69 0 60deg);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
}
.gallery::after,
.gallery >img {
animation: m 8s infinite cubic-bezier(.5, -0.2, .5, 1.2);
}
What you end up with is a flexible slider where images rotate through a continuous motion cycle with a small, controlled hold each time one reaches center view. As they pass the viewport's visible zone, the next image is revealed. This works with default image sizing only if all images share the same dimensions; otherwise, adjust the ones you load to keep the geometry stable.



