Phone mockup
Pure CSSA phone with real thickness: screen, back, four thin walls and a stack of rounded slabs that fills the corners. It turns to show both sides.
Twenty flat strips form the cylinder. Every strip shows the same wide label, shifted by its own index, so the artwork wraps around without a seam.
Your edited version
rotateY(i × 18°) translateZ(r), with r = (width / 2) / tan(180° / 20) ≈ 34.7px for 11px strips.background-size = one lap, 220px) and shifts it left by its own index: background-position: i × −11px. Neighbours line up, so the artwork wraps without a seam.animation-delay of i × −(8s / 20) starts strip i already at its place on the way round.rotateX(±90deg). The white streak is outside the spinning element, so it stays where the lamp is.rotateY(i × 18°) translateZ(radius)background-position: i × −strip widthper-strip shading via animation-delaydiscs laid flat with rotateX(90deg)<div class="scene">
<div class="can">
<div class="spin">
<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><i style="--i:8"></i><i style="--i:9"></i>
<i style="--i:10"></i><i style="--i:11"></i><i style="--i:12"></i><i style="--i:13"></i><i style="--i:14"></i>
<i style="--i:15"></i><i style="--i:16"></i><i style="--i:17"></i><i style="--i:18"></i><i style="--i:19"></i>
<b class="top"></b><b class="bottom"></b>
</div>
<span class="streak"></span>
</div>
</div>
.scene {
perspective: 800px;
}
.can {
position: relative;
transform-style: preserve-3d;
transform: rotateX(-16deg) rotateZ(-9deg);
}
.spin {
position: relative;
width: 70px;
height: 118px;
transform-style: preserve-3d;
animation: spin 8s linear infinite;
}
.spin > i {
position: absolute;
top: 0;
left: calc(50% - 5.75px);
width: 11.5px; /* a hair wider than its 11px slice, so no seams show */
height: 100%;
backface-visibility: hidden;
/* the whole label on every strip, one lap (220px) long */
background-image:
linear-gradient(#e6e9f5 0 3%, #9aa1bd 5%, transparent 5% 95%, #9aa1bd 95%, #e6e9f5 97%),
radial-gradient(circle at 55px 50%, #ff4d9d 0 8px, #fff 8.5px 15px, transparent 15.5px),
radial-gradient(circle at 165px 50%, #2ee6d6 0 8px, #fff 8.5px 15px, transparent 15.5px),
linear-gradient(100deg, transparent 0 18%, rgb(255 255 255 / 0.28) 18% 24%, transparent 24% 68%, rgb(255 255 255 / 0.28) 68% 74%, transparent 74%),
linear-gradient(90deg, #8b6cff, #ff4d9d 50%, #8b6cff); /* same colour at both ends: no seam */
background-size: 220px 100%;
/* strip i shows slice i */
background-position: calc(var(--i) * -11px + 0.25px) 0;
transform: rotateY(calc(var(--i) * 18deg)) translateZ(34.7px);
}
/* fixed lighting: each strip darkens and brightens once per lap, started i/20 of the way round */
.spin > i::after {
content: '';
position: absolute;
inset: 0;
background: #05060f;
animation: shade 8s linear infinite;
animation-delay: calc(var(--i) * -0.4s);
}
.top,
.bottom {
position: absolute;
left: 0;
width: 70px;
height: 70px;
border-radius: 50%;
}
/* centred on the top edge, then laid flat */
.top {
top: -35px;
background:
radial-gradient(ellipse 9px 13px at 50% 68%, #3a3f5c 0 95%, transparent 100%),
radial-gradient(circle, #cfd3e6 0 52%, #8f96b3 54% 60%, #eef0fa 62% 86%, #a3a9c4 88%);
transform: rotateX(90deg);
}
.bottom {
top: calc(100% - 35px);
background: radial-gradient(circle, #6d7391 0 50%, #a3a9c4 52%);
transform: rotateX(-90deg);
}
/* a specular streak just in front of the surface; it does not spin */
.streak {
position: absolute;
top: 9%;
left: calc(50% - 19px);
width: 8px;
height: 82%;
border-radius: 4px;
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.55), transparent);
transform: translateZ(35.7px);
pointer-events: none;
}
@keyframes spin {
to { transform: rotateY(360deg); }
}
/* 0% = facing you. Brightest near 330° (lamp front-left); 25–75% faces away and is hidden */
@keyframes shade {
0% { opacity: 0.08; }
6.25% { opacity: 0.23; }
12.5% { opacity: 0.44; }
25%, 50% { opacity: 0.62; }
75% { opacity: 0.34; }
87.5% { opacity: 0.04; }
91.5% { opacity: 0; }
100% { opacity: 0.08; }
}
@use 'sass:math';
$n: 20; // strips
$w: 11px; // the slice of label each strip shows
$h: 118px;
$turn: 8s;
// A closed ring: the strips are the sides of a regular 20-gon, so radius = (w / 2) / tan(180° / n).
$radius: math.div($w * 0.5, math.tan(math.div(180deg, $n))); // ≈ 34.7px
$label: $w * $n; // the label is exactly one lap long: 220px
.d-can {
position: relative;
transform-style: preserve-3d;
transform: rotateX(-16deg) rotateZ(-9deg);
&__spin {
position: relative;
width: 70px;
height: $h;
transform-style: preserve-3d;
animation: d-can-spin $turn linear infinite;
// Sass only hands every strip its index; all the geometry below is one calc() each.
@for $i from 0 to $n {
> i:nth-child(#{$i + 1}) {
--i: #{$i};
}
}
> i {
position: absolute;
top: 0;
left: calc(50% - #{$w * 0.5} - 0.25px);
width: $w + 0.5px; // a hair wider than its slice so no seams show between strips
height: 100%;
backface-visibility: hidden; // the far side of the can is hidden by the near side anyway
// Every strip carries the WHOLE label (background-size = one lap) and slides it left by
// its own index, so strip 7 shows slice 7. Neighbours line up and the art wraps around.
background-image:
linear-gradient(#e6e9f5 0 3%, #9aa1bd 5%, transparent 5% 95%, #9aa1bd 95%, #e6e9f5 97%),
radial-gradient(circle at 55px 50%, var(--hot) 0 8px, #fff 8.5px 15px, transparent 15.5px),
radial-gradient(circle at 165px 50%, var(--accent-2) 0 8px, #fff 8.5px 15px, transparent 15.5px),
linear-gradient(100deg, transparent 0 18%, rgb(255 255 255 / 0.28) 18% 24%, transparent 24% 68%, rgb(255 255 255 / 0.28) 68% 74%, transparent 74%),
linear-gradient(90deg, var(--accent), var(--hot) 50%, var(--accent)); // same colour at both ends = no seam
background-size: $label 100%;
background-position: calc(var(--i) * #{-$w} + 0.25px) 0;
transform: rotateY(calc(var(--i) * #{math.div(360deg, $n)})) translateZ($radius);
// Lighting must stay put while the can turns. Each strip darkens and brightens over one
// lap; the negative delay starts strip i already i/20 of the way round.
&::after {
content: '';
position: absolute;
inset: 0;
background: #05060f;
animation: d-can-shade $turn linear infinite;
animation-delay: calc(var(--i) * #{math.div($turn, -$n)});
}
}
}
// Discs centred ON the top / bottom edge, then laid flat: no translate needed.
&__top,
&__bottom {
position: absolute;
left: 0;
width: 70px;
height: 70px;
border-radius: 50%;
}
&__top {
top: -35px;
background:
radial-gradient(ellipse 9px 13px at 50% 68%, #3a3f5c 0 95%, transparent 100%),
radial-gradient(circle, #cfd3e6 0 52%, #8f96b3 54% 60%, #eef0fa 62% 86%, #a3a9c4 88%);
transform: rotateX(90deg);
}
&__bottom {
top: calc(100% - 35px);
background: radial-gradient(circle, #6d7391 0 50%, #a3a9c4 52%);
transform: rotateX(-90deg);
}
// A fixed specular streak floating just in front of the surface. It is outside the spinning
// element, so it stays where the "lamp" is.
&__streak {
position: absolute;
top: 9%;
left: calc(50% - 19px);
width: 8px;
height: 82%;
border-radius: 4px;
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.55), transparent);
transform: translateZ($radius + 1px);
pointer-events: none;
}
}
@keyframes d-can-spin {
to {
transform: rotateY(360deg);
}
}
// 0% = strip facing the viewer. The lamp sits front-left, so the brightest point is at about
// 330° (91%). Between 25% and 75% the strip faces away and is not drawn at all.
@keyframes d-can-shade {
0% {
opacity: 0.08;
}
6.25% {
opacity: 0.23;
}
12.5% {
opacity: 0.44;
}
25%,
50% {
opacity: 0.62;
}
75% {
opacity: 0.34;
}
87.5% {
opacity: 0.04;
}
91.5% {
opacity: 0;
}
100% {
opacity: 0.08;
}
}