CSS Geometric Art
An animated solar system built entirely with CSS: orbiting planets, a glowing star, no SVG and no JavaScript.
CSS geometric art draws shapes and scenes from ordinary boxes, using border-radius, gradients and box-shadow in place of an SVG or an image file. The solar system below is nine empty <div> elements. Every circle is a squared off box with a fifty percent radius, every sphere is a single off center radial-gradient, and the motion is one @keyframes rule shared by all four orbits.
The mechanism worth understanding is the orbit itself. A planet placed on the edge of a rotating parent traces a circle without a single trigonometric function. That idea nests, which the first variant below shows with a moon going round a planet that is itself going round the star. The second variant drops the boxes entirely and paints a sunset from four stacked gradients on one element.
Four orbits, one star
HTML
<div class="art-center">
<div class="solar-system">
<div class="star-field"></div>
<div class="star"></div>
<div class="orbit orbit-1"><div class="planet"></div></div>
<div class="orbit orbit-2"><div class="planet"></div></div>
<div class="orbit orbit-3"><div class="planet"></div></div>
<div class="orbit orbit-4"><div class="planet"></div></div>
</div>
</div>
CSS
.art-center {
display: flex;
align-items: center;
justify-content: center;
}
/* Everything inside is positioned against this box */
.solar-system {
position: relative;
width: 280px;
height: 280px;
}
.star {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #ffd444, #ff9040);
box-shadow:
0 0 12px 4px rgba(255, 212, 68, .5),
0 0 40px 10px rgba(255, 212, 68, .15);
animation: star-pulse 3s ease-in-out infinite;
}
.orbit {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 1px dashed rgba(255, 255, 255, .1);
animation: orbit-spin linear infinite;
}
/* The planet sits on the orbit's edge, so spinning the orbit carries it around */
.orbit .planet {
position: absolute;
top: 50%;
left: 0;
border-radius: 50%;
}
.orbit-1 {
width: 90px;
height: 90px;
animation-duration: 4s;
}
.orbit-1 .planet {
width: 10px;
height: 10px;
margin-top: -5px;
margin-left: -5px;
background: radial-gradient(circle at 35% 35%, #38bdf8, #0055aa);
box-shadow: 0 0 6px rgba(56, 189, 248, .6);
}
.orbit-2 {
width: 156px;
height: 156px;
animation-duration: 8s;
}
.orbit-2 .planet {
width: 14px;
height: 14px;
margin-top: -7px;
margin-left: -7px;
background: radial-gradient(circle at 35% 35%, #ff6b6b, #991111);
box-shadow: 0 0 8px rgba(255, 107, 107, .5);
}
.orbit-3 {
width: 224px;
height: 224px;
animation-duration: 14s;
}
.orbit-3 .planet {
width: 11px;
height: 11px;
margin-top: -5.5px;
margin-left: -5.5px;
background: radial-gradient(circle at 35% 35%, #c084fc, #550099);
box-shadow: 0 0 7px rgba(192, 132, 252, .5);
}
.orbit-4 {
width: 272px;
height: 272px;
animation-duration: 22s;
}
.orbit-4 .planet {
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
background: radial-gradient(circle at 35% 35%, #57d9a3, #006644);
box-shadow: 0 0 6px rgba(87, 217, 163, .5);
}
/* Two dots plus stacked box-shadows stand in for a sky full of stars */
.star-field {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
}
.star-field::before,
.star-field::after {
content: "";
position: absolute;
border-radius: 50%;
background: white;
}
.star-field::before {
width: 2px;
height: 2px;
top: 20%;
left: 15%;
box-shadow:
40px -10px 0 0 rgba(255, 255, 255, .4),
80px 30px 0 0 rgba(255, 255, 255, .3),
-20px 60px 0 0 rgba(255, 255, 255, .5),
110px -20px 0 0 rgba(255, 255, 255, .3),
-30px -10px 0 0 rgba(255, 255, 255, .4),
120px 70px 0 0 rgba(255, 255, 255, .2);
}
.star-field::after {
width: 1px;
height: 1px;
top: 60%;
left: 70%;
box-shadow:
-40px 20px 0 0 rgba(255, 255, 255, .4),
20px -30px 0 0 rgba(255, 255, 255, .3),
-80px -10px 0 0 rgba(255, 255, 255, .5),
30px 40px 0 0 rgba(255, 255, 255, .2);
}
@keyframes orbit-spin {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes star-pulse {
0%, 100% {
box-shadow:
0 0 12px 4px rgba(255, 212, 68, .5),
0 0 40px 10px rgba(255, 212, 68, .15);
}
50% {
box-shadow:
0 0 18px 8px rgba(255, 212, 68, .7),
0 0 60px 20px rgba(255, 212, 68, .25);
}
}
Other ways to build it
A moon on a nested orbit
Put a second orbit inside a planet and the transforms compound. The moon's rotation happens inside the coordinate space the outer orbit has already turned, so the moon circles the planet while the planet circles the star, and neither rule knows anything about the other. The two durations are set independently, which is why the moon completes several laps in the time the planet takes to do one.
HTML
<div class="moon-system">
<div class="star"></div>
<div class="orbit moon-orbit">
<div class="planet moon-planet">
<!-- an orbit inside a planet: the transforms stack -->
<div class="orbit moon-suborbit">
<div class="planet moon-moon"></div>
</div>
</div>
</div>
</div>
CSS
.moon-orbit { width: 150px; height: 150px; animation-duration: 13s; }
.moon-suborbit { width: 46px; height: 46px; animation-duration: 3.2s; }
.moon-planet {
width: 26px;
height: 26px;
/* half its own size, so its center lands on the ring */
margin: -13px 0 0 -13px;
background: radial-gradient(circle at 35% 35%, #38bdf8, #0055aa);
}
.moon-moon {
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
background: #f0f0f0;
}
/* the centering translate has to be restated, or every frame throws it away */
@keyframes orbit-spin {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
A scene from one element and four gradients
No boxes at all here. The sky is a four stop linear-gradient, the sun is a hard edged radial-gradient circle, and each hill is an ellipse pushed below the bottom edge so only its crown shows. Earlier layers paint over later ones, so the hills are listed before the sun and the sky sits at the bottom of the pile. The stars are a ::before with a list of hard box-shadow copies, the same technique the solar system above uses for its star field.
HTML
<div class="art-center">
<div class="art-scene"></div>
</div>
CSS
.art-scene {
position: relative;
width: 280px;
height: 180px;
border-radius: 12px;
overflow: hidden;
background-image:
/* front hill, pushed past the bottom edge so only its crown shows */
radial-gradient(ellipse 160px 74px at 24% 112%,
#120a24 99.5%, transparent 100%),
/* back hill */
radial-gradient(ellipse 180px 62px at 88% 108%,
#2b1a4d 99.5%, transparent 100%),
/* sun: a hard stop, so the circle has a crisp edge */
radial-gradient(circle 30px at 62% 64%,
#ffb03a 99%, transparent 100%),
/* sky, at the bottom of the stack */
linear-gradient(#160d33 0%, #6a2b6b 52%, #d9603f 78%, #ffb46b 100%);
}
/* stars: one dot and a list of hard copies of it */
.art-scene::before {
content: "";
position: absolute;
top: 22px;
left: 30px;
width: 2px;
height: 2px;
border-radius: 50%;
background: #ffffff;
box-shadow:
38px 14px 0 0 rgba(255, 255, 255, .7),
82px -8px 0 0 rgba(255, 255, 255, .5),
126px 26px 0 0 rgba(255, 255, 255, .8),
174px 4px 0 0 rgba(255, 255, 255, .45),
212px 32px 0 0 rgba(255, 255, 255, .6),
-14px 44px 0 0 rgba(255, 255, 255, .4);
}
How it works
Each orbit is an absolutely centered <div> with border-radius: 50%. Rotating the orbit rotates the planet inside it, because the planet sits at top: 50%; left: 0 relative to the orbit, so a spinning orbit carries it around a perfect circle. The star's glow is two layered box-shadow values. A radial-gradient on each planet produces the lit sphere from one element and no image.
The orbit is centered with top: 50%; left: 50% and pulled back by translate(-50%, -50%), which is the standard way to center a box of unknown size. The planet then sits at top: 50%; left: 0, meaning the middle of the orbit's left edge, and negative margins of half its own width and height put its center exactly on the ring. Spin the parent and the child sweeps the circumference. No angle is ever calculated.
The keyframe is the part that bites. transform is a single property, so an animation that sets rotate(360deg) replaces the whole value, centering translate included, and the orbit jumps to a new position the instant the animation starts. The rule here is written translate(-50%, -50%) rotate(360deg) so the centering survives every frame. This catches almost everyone once.
Transforms compound down the tree. A child is positioned inside the parent's already transformed coordinate space, so putting a second orbit inside a planet gives a moon that circles the planet while the planet circles the star. Two independent rotations, no combined maths, and the two durations can differ freely. That is the whole of the first variant.
The sphere effect is one gradient. radial-gradient(circle at 35% 35%, light, dark) puts the highlight up and to the left of center, and the eye reads the falloff as curvature. Every planet uses the same 35% offset, which is what makes them look lit by the same star rather than by four different ones. Change the offset per planet and the scene immediately stops being believable.
The glow is box-shadow with large blur and spread values rather than a filter, and the star's pulse animates those values directly. That is a paint operation on every frame, unlike a transform or opacity animation which the compositor can handle on its own. One pulsing star is nothing. A field of fifty would be worth rebuilding as a scaling element behind the star instead. The same property is used as a pure drawing tool in CSS QR code pattern, where a list of hard edged shadows paints a whole pixel grid.
CSS properties used
border-radius50%turns any square box into a circle, and any rectangle into an ellipse. Every planet, orbit ring and star here is a box with this one declaration.transform- Carries both the centering
translateand the orbit'srotate. Because it is one property, an animation has to restate the translate or it is thrown away. positionabsoluteon the orbits and planets,relativeon the scene, so every coordinate in the drawing is measured from the same box.background- A
radial-gradientwith the center offset up and left reads as a lit sphere. It replaces an image per planet with one line of CSS. box-shadow- With a large blur and spread it draws the star's glow. With zero blur and spread it draws hard cells, which is how the star field is made from two dots.
animation-duration- Set per orbit while the keyframes stay shared, so the inner planets circle faster than the outer ones without a second rule.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
transform | 4 | 3.5 | 3.1 | 12 |
animation | 4 | 5 | 5.1 | 12 |
border-radius | 4 | 3 | 3.1 | 12 |
box-shadow | 4 | 3.5 | 5 | 12 |
gradients | 10 | 36 | 15.4 | 12 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Figures come from Can I Use for CSS 2D Transforms, CSS Animation, Border-radius, Box-shadow, CSS Gradients and the prefers-reduced-motion media query. Every technique on this page predates 2013 apart from the media query. The gradient figure is late because Can I Use only counts full support once the complete modern gradient syntax is present, and the two stop radial gradients used here have worked much longer than that number implies.
Accessibility notes
The whole scene is decoration and carries no information, so the outer element should be aria-hidden="true". Nine empty divs would otherwise be walked by a screen reader for no benefit. If a piece of CSS art does carry meaning, such as a diagram or a chart, rebuild it as SVG with a title and description, because SVG has an accessibility model and a stack of divs does not.
Four objects rotating continuously is exactly the kind of motion prefers-reduced-motion exists for. Stop the animations in that media query rather than slowing them down, and leave the planets parked at their starting angles. The scene still reads as a solar system when nothing is moving, which is the test for whether an animation was load bearing.
Continuous animation also keeps a compositor layer alive for the whole time the page is open, which costs battery on a phone even when the tab is not being looked at. Pausing the animation when the element leaves the viewport is not something CSS can do on its own, so keep decorative motion small and few rather than filling a page with it.
What you can build with it
- Hero backgrounds. A slow moving geometric scene behind a heading, with no image request and no layout shift while it loads.
- Empty states. An illustration for a page with no content yet, drawn in the stylesheet so it inherits the theme colors automatically.
- Loading indicators. The orbit mechanism with one dot instead of four is a spinner, and it needs no image or SVG sprite.
- Diagram decoration. Rings, dots and glows around a real diagram, where the shapes are decorative and the data lives in the markup.
- Teaching transforms. The nested orbit is the clearest demonstration there is that child transforms are applied inside the parent's rotated space.
Mistakes worth avoiding
- Writing
rotate(360deg)alone in the keyframes when the element is centered with a translate. The transform is replaced wholesale, so the orbit jumps off center the moment the animation starts. - Sizing the planet without matching negative margins. It hangs off the ring by half its own width instead of sitting centered on it.
- Giving each planet a different gradient offset. The scene reads as several objects lit from different directions, which looks wrong without anyone being able to say why.
- Animating
box-shadow,widthortopwhen a transform would do. Those repaint or relayout every frame, and on a scene with many moving parts it shows. - Leaving the whole thing exposed to assistive technology. A screen reader announces a run of empty containers and the reader learns nothing.
Frequently asked questions
Why does my rotating element jump when the animation starts?
transform holds one value, so to { transform: rotate(360deg) } discards the translate(-50%, -50%) from the base rule. Restate the translate inside the keyframe, before the rotate, and the jump disappears.How do you make something orbit in CSS without maths?
top: 50%; left: 0 with negative margins of half its size, then animate a full rotation on the parent. The child traces the circle for free, and the parent's size is the orbit radius.Can CSS art be nested?
Should I use CSS art instead of SVG?
How do I make a flat circle look like a sphere?
radial-gradient with its center pushed up and to the left, going from a light tint to a dark one. radial-gradient(circle at 35% 35%, #38bdf8, #0055aa) is the whole effect. Use the same offset on every object in the scene so they share a light source.