Twisting puzzle cube
Pure CSSA 3×3×3 cube built as three layers that take turns twisting a quarter turn. Each layer is one flat box with the stickers painted on by gradients.
Two glass cones tip to tip; the sand is one cone shrinking toward the neck while an identical one grows on the floor. Turned half over, it looks exactly as it started, so the loop has no seam.
Your edited version
atan((R − r) / H), like a cone with its tip cut off. --s: -1 flips a strip upward with scaleY(-1) and reverses its lean, so one rule builds both bulbs.scale3d with transform-origin on the tip), so its surface sinks and it keeps touching the glass. Below, the same cone grows from its footprint on the plate.trapezoid strips hinged on the neckscale3d() about the cone tip180° flip = start posecrossed planes for thin posts<div class="scene">
<div class="hourglass">
<div class="flip">
<i style="--i:0; --s:1; --mix:100%"></i>
<i style="--i:1; --s:1; --mix:83%"></i>
<i style="--i:2; --s:1; --mix:67%"></i>
<i style="--i:3; --s:1; --mix:50%"></i>
<i style="--i:4; --s:1; --mix:33%"></i>
<i style="--i:5; --s:1; --mix:17%"></i>
<i style="--i:6; --s:1; --mix:0%"></i>
<i style="--i:7; --s:1; --mix:17%"></i>
<i style="--i:8; --s:1; --mix:33%"></i>
<i style="--i:9; --s:1; --mix:50%"></i>
<i style="--i:10; --s:1; --mix:67%"></i>
<i style="--i:11; --s:1; --mix:83%"></i>
<i style="--i:0; --s:-1; --mix:100%"></i>
<i style="--i:1; --s:-1; --mix:83%"></i>
<i style="--i:2; --s:-1; --mix:67%"></i>
<i style="--i:3; --s:-1; --mix:50%"></i>
<i style="--i:4; --s:-1; --mix:33%"></i>
<i style="--i:5; --s:-1; --mix:17%"></i>
<i style="--i:6; --s:-1; --mix:0%"></i>
<i style="--i:7; --s:-1; --mix:17%"></i>
<i style="--i:8; --s:-1; --mix:33%"></i>
<i style="--i:9; --s:-1; --mix:50%"></i>
<i style="--i:10; --s:-1; --mix:67%"></i>
<i style="--i:11; --s:-1; --mix:83%"></i>
<b style="--y:-76px"></b>
<b style="--y:-69px"></b>
<b style="--y:69px"></b>
<b style="--y:76px"></b>
<span class="post" style="--i:0"></span>
<span class="post" style="--i:1"></span>
<span class="post" style="--i:2"></span>
<div class="sand top">
<i style="--i:0"></i>
<i style="--i:1"></i>
<i style="--i:2"></i>
<i style="--i:3"></i>
<i style="--i:4"></i>
<i style="--i:5"></i>
<i style="--i:6"></i>
<i style="--i:7"></i>
<b></b>
</div>
<div class="sand bottom">
<i style="--i:0"></i>
<i style="--i:1"></i>
<i style="--i:2"></i>
<i style="--i:3"></i>
<i style="--i:4"></i>
<i style="--i:5"></i>
<i style="--i:6"></i>
<i style="--i:7"></i>
<b></b>
</div>
<s class="stream"></s>
</div>
</div>
</div>
.scene {
perspective: 800px;
}
.hourglass {
position: relative;
width: 0;
height: 0;
transform-style: preserve-3d;
animation: spin 20s linear infinite;
}
/* everything turns over together; the neck is at (0, 0) */
.flip {
position: absolute;
transform-style: preserve-3d;
animation: flip 9s infinite;
}
/* glass: 12 strips per bulb. Bulb height 66px, apothem 44px at the plate, 3px at the neck:
strip width 2 × 44 × tan(15deg) = 23.58px, slant √(66² + 41²) = 77.70px, lean atan(41 / 66) = 31.85deg */
.flip > i {
--c: color-mix(in srgb, #8b6cff var(--mix), #2ee6d6);
--edge: color-mix(in srgb, var(--c) 55%, transparent);
position: absolute;
top: 0;
left: -11.79px;
width: 23.58px;
height: 77.7px;
transform-origin: 50% 0; /* hinged on the neck */
transform: rotateY(calc(var(--i) * 30deg)) translateZ(3px) rotateX(calc(var(--s) * 31.85deg)) scaleY(var(--s));
clip-path: polygon(46.59% 0, 53.41% 0, 100% 100%, 0 100%); /* 1.61px wide at the neck */
background:
linear-gradient(to top left, transparent calc(50% - 1px), var(--edge) calc(50% - 1px) 50%, transparent 50%) left / 46.59% 100% no-repeat,
linear-gradient(to top right, transparent calc(50% - 1px), var(--edge) calc(50% - 1px) 50%, transparent 50%) right / 46.59% 100% no-repeat,
linear-gradient(color-mix(in srgb, var(--c) 44%, transparent), color-mix(in srgb, var(--c) 14%, transparent) 50%, color-mix(in srgb, var(--c) 30%, transparent));
}
/* plates: two discs at each end give them thickness */
.flip > b {
position: absolute;
top: -55px;
left: -55px;
width: 110px;
height: 110px;
border: 1.5px solid #a289ff;
border-radius: 50%;
background: radial-gradient(circle, #41387f, #6753c1);
transform: translateY(var(--y)) rotateX(90deg);
}
/* posts: two crossed planes each */
.post {
position: absolute;
top: -76px;
left: -2.5px;
width: 5px;
height: 152px;
transform-style: preserve-3d;
transform: rotateY(calc(var(--i) * 120deg + 60deg)) translateZ(49px);
}
.post::before,
.post::after {
content: '';
position: absolute;
inset: 0;
border-radius: 3px;
background: linear-gradient(90deg, #6f56cc, #b9a7ff, #6f56cc);
}
.post::after {
transform: rotateY(90deg);
}
/* sand: an 8-sided cone 55.44px tall, base apothem 30.73px, tip at the top of its box.
Strip width 2 × 30.73 × tan(22.5deg) = 25.46px, slant 63.39px, lean atan(30.73 / 55.44) = 29deg */
.sand {
position: absolute;
left: -30.73px;
width: 61.46px;
height: 55.44px;
transform-style: preserve-3d;
}
.sand i {
position: absolute;
bottom: 0;
left: calc(50% - 12.73px);
width: 25.46px;
height: 63.39px;
transform-origin: 50% 100%;
transform: rotateY(calc(var(--i) * 45deg)) translateZ(30.73px) rotateX(29deg);
clip-path: polygon(50% 0, 0 100%, 100% 100%);
background: linear-gradient(to top, #ffa851, #ffcb7e);
}
.sand i:nth-child(odd) {
background: linear-gradient(to top, #d18a41, #ffb547);
}
/* the flat face, 1.5px inside the cone so it never touches the strips */
.sand b {
position: absolute;
top: calc(100% - 32.23px);
left: 0;
width: 61.46px;
height: 61.46px;
clip-path: polygon(29.29% 0, 70.71% 0, 100% 29.29%, 100% 70.71%, 70.71% 100%, 29.29% 100%, 0 70.71%, 0 29.29%);
background: radial-gradient(circle, #ffd391, #ffa851);
transform: rotateX(90deg) scale(0.95);
}
/* top bulb: tip-down on the neck, shrinking toward that tip */
.sand.top {
top: 0;
transform-origin: 50% 0;
animation: drain 9s infinite;
}
/* bottom bulb: a pile 3px above the plate, growing from its footprint */
.sand.bottom {
top: 7.56px;
transform-origin: 50% 100%;
animation: fill 9s infinite;
}
.stream {
position: absolute;
top: 0;
left: -1.5px;
width: 3px;
height: 66px;
overflow: hidden;
opacity: 0;
animation: stream 9s linear infinite, face-you 20s linear infinite;
}
.stream::before {
content: '';
position: absolute;
inset: -14px 0 0;
background: repeating-linear-gradient(#ffcb7e 0 4px, transparent 4px 7px);
animation: fall 0.35s linear infinite;
}
@keyframes spin {
from { transform: rotateX(-16deg) rotateY(0deg); }
to { transform: rotateX(-16deg) rotateY(360deg); }
}
@keyframes face-you {
to { transform: rotateY(-360deg); }
}
/* half a turn over, it looks exactly like the start: the jump back to 0deg is invisible */
@keyframes flip {
0%, 84% { transform: rotateZ(0deg); animation-timing-function: ease-in-out; }
100% { transform: rotateZ(180deg); }
}
/* scale = cube root of what is left, so the flow looks steady */
@keyframes drain {
0% { transform: translateY(-7.56px) rotateZ(180deg) scale3d(1, 1, 1); animation-timing-function: ease-in; }
8% { transform: translateY(0) rotateZ(180deg) scale3d(1, 1, 1); animation-timing-function: linear; }
15% { transform: translateY(0) rotateZ(180deg) scale3d(0.965, 0.965, 0.965); animation-timing-function: linear; }
25.5% { transform: translateY(0) rotateZ(180deg) scale3d(0.909, 0.909, 0.909); animation-timing-function: linear; }
43% { transform: translateY(0) rotateZ(180deg) scale3d(0.794, 0.794, 0.794); animation-timing-function: linear; }
60.5% { transform: translateY(0) rotateZ(180deg) scale3d(0.63, 0.63, 0.63); animation-timing-function: linear; }
71% { transform: translateY(0) rotateZ(180deg) scale3d(0.464, 0.464, 0.464); animation-timing-function: linear; }
78%, 100% { transform: translateY(0) rotateZ(180deg) scale3d(0.01, 0.01, 0.01); }
}
@keyframes fill {
0%, 8% { transform: scale3d(0.01, 0.01, 0.01); animation-timing-function: linear; }
15% { transform: scale3d(0.464, 0.464, 0.464); animation-timing-function: linear; }
25.5% { transform: scale3d(0.63, 0.63, 0.63); animation-timing-function: linear; }
43% { transform: scale3d(0.794, 0.794, 0.794); animation-timing-function: linear; }
60.5% { transform: scale3d(0.909, 0.909, 0.909); animation-timing-function: linear; }
71% { transform: scale3d(0.965, 0.965, 0.965); animation-timing-function: linear; }
78%, 100% { transform: scale3d(1, 1, 1); }
}
@keyframes stream {
0%, 8%, 80%, 100% { opacity: 0; }
10%, 77% { opacity: 1; }
}
@keyframes fall {
to { transform: translateY(14px); }
}
@use 'sass:math';
$T: 9s; // one run of the sand plus the flip
$spin: 20s;
// The glass: two 12-sided cones, tip to tip, open by a tiny neck.
$n: 12;
$H: 66px; // one bulb, neck to plate
$R: 44px; // apothem at the plate
$rn: 3px; // apothem at the neck
$wR: 2 * $R * math.tan(math.div(180deg, $n)); // strip width at the plate
$wn: 2 * $rn * math.tan(math.div(180deg, $n)); // strip width at the neck
$L: math.hypot($H, $R - $rn); // slant length of a strip
$lean: math.atan(math.div($R - $rn, $H)); // how far a strip leans out from the axis
$side: math.div(1 - math.div($wn, $wR), 2) * 100%; // where the trapezoid's narrow end starts
$glass-edge: color-mix(in srgb, var(--c) 55%, transparent);
// The sand: an 8-sided cone, the SAME shape in both bulbs. Full, it fills the top bulb from the
// neck up (its slope is a little steeper than the glass, so it stays inside); in the bottom bulb
// it is a pile standing on the plate.
$ns: 8;
$hp: $H * 0.84; // height of the full sand cone
$rs: $hp * 0.6 * math.cos(math.div(180deg, $ns)); // its base apothem (corner radius = 0.6 × height)
$ws: 2 * $rs * math.tan(math.div(180deg, $ns));
$Ls: math.hypot($hp, $rs);
$lean-s: math.atan(math.div($rs, $hp));
$gap: 3px; // the pile stands this far above the plate (planes too close together sort badly)
$drop: $H - $gap - $hp; // after a flip, the sand hangs this far from the neck and slides down
$P: 55px; // plate radius
$sand: color-mix(in srgb, var(--warm) 88%, var(--hot));
.d-hourglass {
position: relative;
width: 0;
height: 0;
transform-style: preserve-3d;
animation: d-hourglass-spin $spin linear infinite;
// everything inside turns over together; the neck is the origin
&__flip {
position: absolute;
transform-style: preserve-3d;
animation: d-hourglass-flip $T infinite;
// glass strips: hinged on the neck, leaning out. --s: -1 flips the upper bulb's strips up
// (scaleY) and reverses the lean, so one rule builds both cones.
> i {
--c: color-mix(in srgb, var(--accent) var(--mix), var(--accent-2));
position: absolute;
top: 0;
left: $wR * -0.5;
width: $wR;
height: $L;
transform-origin: 50% 0;
transform: rotateY(calc(var(--i) * #{math.div(360deg, $n)})) translateZ($rn) rotateX(calc(var(--s) * #{$lean})) scaleY(var(--s));
clip-path: polygon($side 0, 100% - $side 0, 100% 100%, 0 100%);
// slanted edges are painted: a "to corner" gradient's 50% line is its box's diagonal
background:
linear-gradient(to top left, transparent calc(50% - 1px), $glass-edge calc(50% - 1px), $glass-edge 50%, transparent 50%)
left / $side 100% no-repeat,
linear-gradient(to top right, transparent calc(50% - 1px), $glass-edge calc(50% - 1px), $glass-edge 50%, transparent 50%)
right / $side 100% no-repeat,
linear-gradient(
color-mix(in srgb, var(--c) 44%, transparent),
color-mix(in srgb, var(--c) 14%, transparent) 50%,
color-mix(in srgb, var(--c) 30%, transparent)
);
}
// plates: two discs at each end give the wood its thickness
> b {
position: absolute;
top: -$P;
left: -$P;
width: $P * 2;
height: $P * 2;
border: 1.5px solid color-mix(in srgb, var(--accent) 80%, #fff);
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--accent) 38%, var(--surface-solid)),
color-mix(in srgb, var(--accent) 70%, var(--surface-solid))
);
transform: translateY(var(--y)) rotateX(90deg);
&:nth-of-type(1) {
--y: #{-$H - 10px};
}
&:nth-of-type(2) {
--y: #{-$H - 3px};
}
&:nth-of-type(3) {
--y: #{$H + 3px};
}
&:nth-of-type(4) {
--y: #{$H + 10px};
}
}
}
// three posts, each two crossed planes so it has width from every side
&__post {
position: absolute;
top: -$H - 10px;
left: -2.5px;
width: 5px;
height: ($H + 10px) * 2;
transform-style: preserve-3d;
transform: rotateY(calc(var(--i) * 120deg + 60deg)) translateZ($P - 6px);
&::before,
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: 3px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--accent) 80%, #000),
color-mix(in srgb, var(--accent) 60%, #fff),
color-mix(in srgb, var(--accent) 80%, #000)
);
}
&::after {
transform: rotateY(90deg);
}
}
// a sand cone: apex at the top of its box, base on the bottom edge
&__sand {
position: absolute;
left: -$rs;
width: $rs * 2;
height: $hp;
transform-style: preserve-3d;
i {
position: absolute;
bottom: 0;
left: calc(50% - #{$ws * 0.5});
width: $ws;
height: $Ls;
transform-origin: 50% 100%;
transform: rotateY(calc(var(--i) * #{math.div(360deg, $ns)})) translateZ($rs) rotateX($lean-s);
clip-path: polygon(50% 0, 0 100%, 100% 100%);
background: linear-gradient(to top, $sand, color-mix(in srgb, var(--warm) 70%, #fff));
&:nth-child(odd) {
background: linear-gradient(to top, color-mix(in srgb, $sand 82%, #000), var(--warm));
}
}
// the flat face: the sand's surface up top, the pile's footprint below. It sits 1.5px inside
// the cone and a little smaller, so it never touches the strips (touching planes flicker).
b {
position: absolute;
top: calc(100% - #{$rs + 1.5px});
left: 0;
width: $rs * 2;
height: $rs * 2;
clip-path: polygon(29.29% 0, 70.71% 0, 100% 29.29%, 100% 70.71%, 70.71% 100%, 29.29% 100%, 0 70.71%, 0 29.29%);
background: radial-gradient(circle, color-mix(in srgb, var(--warm) 60%, #fff), $sand);
transform: rotateX(90deg) scale(0.95);
}
// top bulb: the cone turned tip-down on the neck. It shrinks towards its tip, so the surface
// sinks and it always touches the glass.
&--top {
top: 0;
transform-origin: 50% 0;
animation: d-hourglass-drain $T infinite;
}
// bottom bulb: a pile on the plate, growing from its footprint
&--bottom {
top: $H - $gap - $hp;
transform-origin: 50% 100%;
animation: d-hourglass-fill $T infinite;
}
}
// the falling stream: a dashed line that scrolls down, turned back against the spin so it
// always faces you
&__stream {
position: absolute;
top: 0;
left: -1.5px;
width: 3px;
height: $H;
overflow: hidden; // safe: nothing inside it is 3D
opacity: 0;
animation:
d-hourglass-stream $T linear infinite,
d-hourglass-face $spin linear infinite;
&::before {
content: '';
position: absolute;
inset: -14px 0 0;
background: repeating-linear-gradient(color-mix(in srgb, var(--warm) 70%, #fff) 0 4px, transparent 4px 7px);
animation: d-hourglass-fall 0.35s linear infinite;
}
}
}
@keyframes d-hourglass-spin {
from {
transform: rotateX(-16deg) rotateY(0deg);
}
to {
transform: rotateX(-16deg) rotateY(360deg);
}
}
@keyframes d-hourglass-face {
to {
transform: rotateY(-360deg);
}
}
// Turned half over, the hourglass looks exactly as it did at the start (the full pile is now
// on top), so jumping back to 0deg is invisible.
@keyframes d-hourglass-flip {
0%,
84% {
transform: rotateZ(0deg);
animation-timing-function: ease-in-out;
}
100% {
transform: rotateZ(180deg);
}
}
// The sand's volume goes with the cube of its size, so a steady flow is a cube-root curve.
$flow: (0, 0.1, 0.25, 0.5, 0.75, 0.9, 1);
@keyframes d-hourglass-drain {
// right after a flip the pile hangs from the plate: it slides down onto the neck
0% {
transform: translateY(-$drop) rotateZ(180deg) scale3d(1, 1, 1);
animation-timing-function: ease-in;
}
@each $f in $flow {
$s: math.max(math.pow(1 - $f, math.div(1, 3)), 0.01);
#{8 + 70 * $f * 1%} {
transform: translateY(0) rotateZ(180deg) scale3d($s, $s, $s);
animation-timing-function: linear;
}
}
100% {
transform: translateY(0) rotateZ(180deg) scale3d(0.01, 0.01, 0.01);
}
}
@keyframes d-hourglass-fill {
0% {
transform: scale3d(0.01, 0.01, 0.01);
}
@each $f in $flow {
$s: math.max(math.pow($f, math.div(1, 3)), 0.01);
#{8 + 70 * $f * 1%} {
transform: scale3d($s, $s, $s);
animation-timing-function: linear;
}
}
100% {
transform: scale3d(1, 1, 1);
}
}
@keyframes d-hourglass-stream {
0%,
8%,
80%,
100% {
opacity: 0;
}
10%,
77% {
opacity: 1;
}
}
@keyframes d-hourglass-fall {
to {
transform: translateY(14px);
}
}