25 CSS Spinners 10 / 25
DNA Double Helix Spinner
Eight nucleotide pairs animate as two interweaving dot chains — one cyan, one pink — translating horizontally in opposing directions to simulate a rotating DNA double helix.
This is a full-page demo — interact inside the frame above, or open it in the playground for the full-screen experience.
The code
<div class="sp-10">
<div class="sp-10__dna">
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
</div>
</div> <div class="sp-10">
<div class="sp-10__dna">
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
<div class="sp-10__pair"><div class="sp-10__dot-a"></div><div class="sp-10__bridge"></div><div class="sp-10__dot-b"></div></div>
</div>
</div>.sp-10,.sp-10 *,.sp-10 *::before,.sp-10 *::after{box-sizing:border-box;margin:0;padding:0}
.sp-10{
--bg:#040d1a;
--ca:#00bcd4;
--cb:#f06292;
display:flex;
align-items:center;
justify-content:center;
min-height:100vh;
background:var(--bg);
}
.sp-10__dna{
display:flex;
flex-direction:column;
gap:0;
align-items:center;
}
.sp-10__pair{
display:flex;
align-items:center;
gap:0;
height:9px;
}
.sp-10__dot-a,.sp-10__dot-b{
width:10px;
height:10px;
border-radius:50%;
}
.sp-10__dot-a{
background:var(--ca);
box-shadow:0 0 6px var(--ca);
}
.sp-10__dot-b{
background:var(--cb);
box-shadow:0 0 6px var(--cb);
}
.sp-10__bridge{
width:28px;
height:1px;
background:linear-gradient(to right,var(--ca),var(--cb));
/* The bridge represents the base-pair connection between the two
strands. As the strands cross (50% of the cycle, dots meeting at
center), the visible distance between them shrinks to zero —
so the bridge should ALSO shrink to invisible at that moment.
A symmetric scaleX keyframe makes the bridge "breathe" between
full-width (when strands are spread) and zero-width (when they
cross). transform-origin:center keeps the bridge centered as it
scales. */
transform-origin:center;
animation:sp-10-bridge 1.4s ease-in-out infinite;
}
.sp-10__pair:nth-child(1) .sp-10__bridge{animation-delay:calc(0 * 0.14s)}
.sp-10__pair:nth-child(2) .sp-10__bridge{animation-delay:calc(1 * 0.14s)}
.sp-10__pair:nth-child(3) .sp-10__bridge{animation-delay:calc(2 * 0.14s)}
.sp-10__pair:nth-child(4) .sp-10__bridge{animation-delay:calc(3 * 0.14s)}
.sp-10__pair:nth-child(5) .sp-10__bridge{animation-delay:calc(4 * 0.14s)}
.sp-10__pair:nth-child(6) .sp-10__bridge{animation-delay:calc(5 * 0.14s)}
.sp-10__pair:nth-child(7) .sp-10__bridge{animation-delay:calc(6 * 0.14s)}
.sp-10__pair:nth-child(8) .sp-10__bridge{animation-delay:calc(7 * 0.14s)}
@keyframes sp-10-bridge{
/* 0%/100% = strands at extremes → bridge full width visible. */
0%,100%{transform:scaleX(1);opacity:0.4}
/* 50% = strands crossing at center → bridge collapsed + invisible. */
50%{transform:scaleX(0.05);opacity:0}
}
/* Two strands run the SAME keyframe but at OPPOSITE PHASE so they
cross each other. The trick: dot-A's keyframe goes right→left→right,
dot-B's keyframe goes left→right→left. With opposite phase, when
strand A is pushed +16, strand B is pushed -16 — they meet and cross
at the center every half-cycle, producing the X-shape that reads as
a helix projection.
Why not animation-direction:reverse? Because the keyframe is symmetric
(0% and 100% are identical), so reverse plays the SAME visual sequence.
Reverse only flips things visually if 0% ≠ 100%. Two distinct keyframes
(helix-a and helix-b that start at opposite x positions) is the
reliable approach.
The per-pair animation-delay staggers the crossing motion vertically
so it propagates downward in a wave (~0.14s per row). */
.sp-10__pair:nth-child(1) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(0 * 0.14s)}
.sp-10__pair:nth-child(2) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(1 * 0.14s)}
.sp-10__pair:nth-child(3) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(2 * 0.14s)}
.sp-10__pair:nth-child(4) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(3 * 0.14s)}
.sp-10__pair:nth-child(5) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(4 * 0.14s)}
.sp-10__pair:nth-child(6) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(5 * 0.14s)}
.sp-10__pair:nth-child(7) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(6 * 0.14s)}
.sp-10__pair:nth-child(8) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(7 * 0.14s)}
.sp-10__pair:nth-child(1) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(0 * 0.14s)}
.sp-10__pair:nth-child(2) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(1 * 0.14s)}
.sp-10__pair:nth-child(3) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(2 * 0.14s)}
.sp-10__pair:nth-child(4) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(3 * 0.14s)}
.sp-10__pair:nth-child(5) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(4 * 0.14s)}
.sp-10__pair:nth-child(6) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(5 * 0.14s)}
.sp-10__pair:nth-child(7) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(6 * 0.14s)}
.sp-10__pair:nth-child(8) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(7 * 0.14s)}
/* Strand A starts pushed RIGHT then swings LEFT (front of the helix at 50%). */
@keyframes sp-10-helix-a{
0%,100%{transform:translateX(16px);opacity:0.2}
50%{transform:translateX(-16px);opacity:1}
}
/* Strand B starts pushed LEFT then swings RIGHT — opposite phase, so
strands A and B cross at the center every half-cycle (the X pattern). */
@keyframes sp-10-helix-b{
0%,100%{transform:translateX(-16px);opacity:1}
50%{transform:translateX(16px);opacity:0.2}
}
@media (prefers-reduced-motion: reduce){
.sp-10__dot-a,.sp-10__dot-b{animation:none;transform:none}
} .sp-10,.sp-10 *,.sp-10 *::before,.sp-10 *::after{box-sizing:border-box;margin:0;padding:0}
.sp-10{
--bg:#040d1a;
--ca:#00bcd4;
--cb:#f06292;
display:flex;
align-items:center;
justify-content:center;
min-height:100vh;
background:var(--bg);
}
.sp-10__dna{
display:flex;
flex-direction:column;
gap:0;
align-items:center;
}
.sp-10__pair{
display:flex;
align-items:center;
gap:0;
height:9px;
}
.sp-10__dot-a,.sp-10__dot-b{
width:10px;
height:10px;
border-radius:50%;
}
.sp-10__dot-a{
background:var(--ca);
box-shadow:0 0 6px var(--ca);
}
.sp-10__dot-b{
background:var(--cb);
box-shadow:0 0 6px var(--cb);
}
.sp-10__bridge{
width:28px;
height:1px;
background:linear-gradient(to right,var(--ca),var(--cb));
/* The bridge represents the base-pair connection between the two
strands. As the strands cross (50% of the cycle, dots meeting at
center), the visible distance between them shrinks to zero —
so the bridge should ALSO shrink to invisible at that moment.
A symmetric scaleX keyframe makes the bridge "breathe" between
full-width (when strands are spread) and zero-width (when they
cross). transform-origin:center keeps the bridge centered as it
scales. */
transform-origin:center;
animation:sp-10-bridge 1.4s ease-in-out infinite;
}
.sp-10__pair:nth-child(1) .sp-10__bridge{animation-delay:calc(0 * 0.14s)}
.sp-10__pair:nth-child(2) .sp-10__bridge{animation-delay:calc(1 * 0.14s)}
.sp-10__pair:nth-child(3) .sp-10__bridge{animation-delay:calc(2 * 0.14s)}
.sp-10__pair:nth-child(4) .sp-10__bridge{animation-delay:calc(3 * 0.14s)}
.sp-10__pair:nth-child(5) .sp-10__bridge{animation-delay:calc(4 * 0.14s)}
.sp-10__pair:nth-child(6) .sp-10__bridge{animation-delay:calc(5 * 0.14s)}
.sp-10__pair:nth-child(7) .sp-10__bridge{animation-delay:calc(6 * 0.14s)}
.sp-10__pair:nth-child(8) .sp-10__bridge{animation-delay:calc(7 * 0.14s)}
@keyframes sp-10-bridge{
/* 0%/100% = strands at extremes → bridge full width visible. */
0%,100%{transform:scaleX(1);opacity:0.4}
/* 50% = strands crossing at center → bridge collapsed + invisible. */
50%{transform:scaleX(0.05);opacity:0}
}
/* Two strands run the SAME keyframe but at OPPOSITE PHASE so they
cross each other. The trick: dot-A's keyframe goes right→left→right,
dot-B's keyframe goes left→right→left. With opposite phase, when
strand A is pushed +16, strand B is pushed -16 — they meet and cross
at the center every half-cycle, producing the X-shape that reads as
a helix projection.
Why not animation-direction:reverse? Because the keyframe is symmetric
(0% and 100% are identical), so reverse plays the SAME visual sequence.
Reverse only flips things visually if 0% ≠ 100%. Two distinct keyframes
(helix-a and helix-b that start at opposite x positions) is the
reliable approach.
The per-pair animation-delay staggers the crossing motion vertically
so it propagates downward in a wave (~0.14s per row). */
.sp-10__pair:nth-child(1) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(0 * 0.14s)}
.sp-10__pair:nth-child(2) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(1 * 0.14s)}
.sp-10__pair:nth-child(3) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(2 * 0.14s)}
.sp-10__pair:nth-child(4) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(3 * 0.14s)}
.sp-10__pair:nth-child(5) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(4 * 0.14s)}
.sp-10__pair:nth-child(6) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(5 * 0.14s)}
.sp-10__pair:nth-child(7) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(6 * 0.14s)}
.sp-10__pair:nth-child(8) .sp-10__dot-a{animation:sp-10-helix-a 1.4s ease-in-out infinite;animation-delay:calc(7 * 0.14s)}
.sp-10__pair:nth-child(1) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(0 * 0.14s)}
.sp-10__pair:nth-child(2) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(1 * 0.14s)}
.sp-10__pair:nth-child(3) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(2 * 0.14s)}
.sp-10__pair:nth-child(4) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(3 * 0.14s)}
.sp-10__pair:nth-child(5) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(4 * 0.14s)}
.sp-10__pair:nth-child(6) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(5 * 0.14s)}
.sp-10__pair:nth-child(7) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(6 * 0.14s)}
.sp-10__pair:nth-child(8) .sp-10__dot-b{animation:sp-10-helix-b 1.4s ease-in-out infinite;animation-delay:calc(7 * 0.14s)}
/* Strand A starts pushed RIGHT then swings LEFT (front of the helix at 50%). */
@keyframes sp-10-helix-a{
0%,100%{transform:translateX(16px);opacity:0.2}
50%{transform:translateX(-16px);opacity:1}
}
/* Strand B starts pushed LEFT then swings RIGHT — opposite phase, so
strands A and B cross at the center every half-cycle (the X pattern). */
@keyframes sp-10-helix-b{
0%,100%{transform:translateX(-16px);opacity:1}
50%{transform:translateX(16px);opacity:0.2}
}
@media (prefers-reduced-motion: reduce){
.sp-10__dot-a,.sp-10__dot-b{animation:none;transform:none}
}How this works
Each row is a flex container holding a cyan dot, a faint bridge line, and a pink dot. The cyan dots all share sp-10-helix which translates them from translateX(16px) to translateX(-16px) and back, while the pink dots use animation-direction:reverse on the same keyframe so they mirror the motion exactly — when cyan dots are pushed right, pink dots are pushed left, creating the characteristic X-crossing pattern of a helix projection.
Row-level animation-delay values stagger each pair by 0.14s so the crossing motion propagates downward in a wave. The opacity cycling from 1 to 0.2 simulates depth — dots in the "back" of the helix appear dimmer.
Customize
- Change helix colours via
--ca(strand A) and--cb(strand B) — a gold/silver combination creates a metallic look. - Add more base pairs by duplicating
.sp-10__pairrows and extending thenth-childdelay pattern by0.14sincrements. - Increase bridge width from
28pxto40pxand translate distance from16pxto22pxfor a wider helix appearance. - Change the animation duration from
1.4sto2sfor a slower, more molecular animation suitable for science-themed UIs. - Replace dots with short rectangles (
border-radius:2pxon a10px × 4pxelement) to simulate nucleotide base-pair segments.
Watch out for
- The
animation-direction:reverseoverride must use!importanthere because the delay rules have higher specificity — verify overrides are working in browser DevTools if helix appears to move in the same direction on both strands. - The
translateXvalues are symmetric around zero — changing only one strand's translation distance will break the crossing visual; always update both. - On very small screens the 108px-wide helix (8 × 9px gaps + dots) may overflow narrow containers — test at 320px viewport width.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 60+ | 12+ | 60+ | 60+ |
Uses only flexbox and transform/opacity animations; no modern-only features required.