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.
A medal hanging from a neck ribbon: it sways from the neck and twists on its ring, with a real metal edge and a shine that sweeps each time it faces you.
Your edited version
transform-origin: 50% 0 on a wrapper and a small rotateZ swing, alternate.rotateY(-50deg) and rotateY(50deg), never edge-on, while the bail stays flat: as a child of the medal it runs the opposite twist, which cancels it, so it never swings through the ribbon.bail looped behind a folded tabpendulum from transform-origin: topedge from stacked discsshine synced to the twist<div class="scene">
<div class="badge">
<div class="swing">
<i class="strap"></i><i class="strap"></i>
<div class="medal">
<i class="bail"></i>
<u style="--i:0"></u><u style="--i:1"></u><u style="--i:2"></u><u style="--i:3"></u><u style="--i:4"></u>
<div class="face"><b></b><small>WINNER</small><em></em></div>
</div>
<i class="tab"></i>
</div>
</div>
</div>
.scene {
perspective: 800px;
}
.badge {
--gold: #ffd28f;
--gold-deep: #aa7519;
position: relative;
width: 120px;
height: 196px;
transform-style: preserve-3d;
}
/* sways from the top, like something hanging from a neck */
.swing {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: 50% 0;
animation: sway 2.5s ease-in-out infinite alternate;
}
/* two straps pinned at the tab, splayed toward the neck, slightly behind everything */
.strap {
position: absolute;
top: 0;
left: calc(50% - 12px);
width: 24px;
height: 92px;
background:
linear-gradient(90deg, rgb(0 0 0 / 0.28), transparent 30% 70%, rgb(0 0 0 / 0.28)),
linear-gradient(90deg, #8b6cff 0 30%, #fff 30% 36%, #ff4d9d 36% 64%, #fff 64% 70%, #8b6cff 70%);
transform-origin: 50% 100%;
transform: translateX(-7px) translateZ(-3px) rotate(-20deg);
}
.strap + .strap {
transform: translateX(7px) translateZ(-2px) rotate(20deg);
}
/* the folded end of the ribbon, in front of the bail so the bail looks looped through it */
.tab {
position: absolute;
top: 80px;
left: calc(50% - 19px);
width: 38px;
height: 14px;
border-radius: 3px;
background:
linear-gradient(rgb(255 255 255 / 0.25), transparent 40%, rgb(0 0 0 / 0.3)),
linear-gradient(90deg, #8b6cff, #ff4d9d 50%, #8b6cff);
box-shadow: 0 2px 4px rgb(0 0 0 / 0.35);
transform: translateZ(2px);
}
.medal {
position: absolute;
top: 100px;
left: calc(50% - 48px);
width: 96px;
height: 96px;
transform-style: preserve-3d;
animation: twist 5s ease-in-out infinite alternate;
}
/* the edge: five discs 1.2px apart */
.medal u {
position: absolute;
inset: 0.5px;
border-radius: 50%;
background: linear-gradient(90deg, var(--gold-deep), var(--gold) 50%, var(--gold-deep));
transform: translateZ(calc((var(--i) - 2) * 1.2px));
}
/* the bail hangs flat from the tab while the medal turns on it: as the medal's child it runs
the opposite twist with the same timing, which cancels it (turning with the medal would swing
it through the tab and show a hard cut) */
.bail {
position: absolute;
top: -16px;
left: calc(50% - 9px);
width: 18px;
height: 20px;
box-sizing: border-box;
border: 3px solid var(--gold);
border-radius: 50%;
box-shadow: inset 0 0 0 1px var(--gold-deep), 0 0 0 1px var(--gold-deep);
animation: untwist 5s ease-in-out infinite alternate;
}
.face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
align-content: center;
gap: 2px;
overflow: hidden; /* fine: the face itself is flat */
border-radius: 50%;
background:
radial-gradient(circle at 32% 26%, rgb(255 255 255 / 0.55), transparent 42%),
conic-gradient(from 20deg, var(--gold), var(--gold-deep), var(--gold), #c89445, var(--gold));
box-shadow:
inset 0 0 0 4px #ffe3b8,
inset 0 0 0 6px var(--gold-deep),
inset 0 0 0 12px rgb(170 117 25 / 0.25);
color: #5a3a10;
font-family: system-ui, sans-serif;
transform: translateZ(3px);
}
.face b {
width: 40px;
height: 40px;
clip-path: polygon(50% 0, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
background: linear-gradient(160deg, #fff8de, var(--gold) 45%, var(--gold-deep));
}
.face small {
font-size: 8px;
font-weight: 800;
letter-spacing: 2px;
}
/* one sweep per twist, centred on the moment the face looks straight at you */
.face em {
position: absolute;
inset: 0;
background: linear-gradient(115deg, transparent 38%, rgb(255 255 255 / 0.7) 50%, transparent 62%);
transform: translateX(-120%);
animation: shine 5s linear infinite;
}
@keyframes sway {
from { transform: rotateZ(-3deg); }
to { transform: rotateZ(3deg); }
}
@keyframes twist {
from { transform: rotateY(-50deg); }
to { transform: rotateY(50deg); }
}
@keyframes untwist {
from { transform: rotateY(50deg); }
to { transform: rotateY(-50deg); }
}
@keyframes shine {
0%, 35% { transform: translateX(-120%); }
65%, 100% { transform: translateX(120%); }
}
$d: 96px; // medal diameter
$twist: 5s; // one twist from left to right (then back: alternate)
$sway: 2.5s;
// A medal hanging from a neck ribbon. The ribbon's two straps meet in a folded tab; the medal's
// own ring (the bail) is looped through that tab, so ribbon and medal are one object. The whole
// piece sways from the neck, and the medal twists on its ring the way a real one does, never far
// enough to turn edge-on.
.d-badge {
--gold: color-mix(in srgb, var(--warm) 80%, #fff);
--gold-deep: color-mix(in srgb, var(--warm) 65%, #3a2200);
position: relative;
width: 120px;
height: 196px;
transform-style: preserve-3d;
&__swing {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: 50% 0;
animation: d-badge-sway $sway ease-in-out infinite alternate;
}
// The straps: pinned at the tab, splayed toward the neck. Slightly behind everything else.
&__strap {
position: absolute;
top: 0;
left: calc(50% - 12px);
width: 24px;
height: 92px;
background:
linear-gradient(90deg, rgb(0 0 0 / 0.28), transparent 30% 70%, rgb(0 0 0 / 0.28)),
linear-gradient(90deg, var(--accent) 0 30%, #fff 30% 36%, var(--hot) 36% 64%, #fff 64% 70%, var(--accent) 70%);
transform-origin: 50% 100%;
transform: translateX(-7px) translateZ(-3px) rotate(-20deg);
& + & {
transform: translateX(7px) translateZ(-2px) rotate(20deg);
}
}
// Where the straps are folded together: a short band the bail hangs from, in front of it.
&__tab {
position: absolute;
top: 80px;
left: calc(50% - 19px);
width: 38px;
height: 14px;
border-radius: 3px;
background:
linear-gradient(rgb(255 255 255 / 0.25), transparent 40%, rgb(0 0 0 / 0.3)),
linear-gradient(90deg, var(--accent), var(--hot) 50%, var(--accent));
box-shadow: 0 2px 4px rgb(0 0 0 / 0.35);
transform: translateZ(2px);
}
&__medal {
position: absolute;
top: 100px;
left: calc(50% - #{$d * 0.5});
width: $d;
height: $d;
transform-style: preserve-3d;
animation: d-badge-twist $twist ease-in-out infinite alternate;
// the edge: five discs 1.2px apart, so a turned medal shows a band of metal
u {
position: absolute;
inset: 0.5px;
border-radius: 50%;
background: linear-gradient(90deg, var(--gold-deep), var(--gold) 50%, var(--gold-deep));
transform: translateZ(calc((var(--i) - 2) * 1.2px));
}
}
// The bail: hangs from the tab and stays flat while the medal turns on it. It is a child of the
// medal, so it runs the exact opposite twist (same timing) to cancel it; turning with the medal
// would swing it through the tab, and a plane crossing a plane shows as a hard cut.
&__bail {
position: absolute;
top: -16px;
left: calc(50% - 9px);
width: 18px;
height: 20px;
border: 3px solid var(--gold);
border-radius: 50%;
box-shadow:
inset 0 0 0 1px var(--gold-deep),
0 0 0 1px var(--gold-deep);
animation: d-badge-untwist $twist ease-in-out infinite alternate;
}
// overflow: hidden is fine here: the face is flat, it is the medal that is 3D
&__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
align-content: center;
gap: 2px;
overflow: hidden;
border-radius: 50%;
background:
radial-gradient(circle at 32% 26%, rgb(255 255 255 / 0.55), transparent 42%),
conic-gradient(from 20deg, var(--gold), var(--gold-deep), var(--gold), color-mix(in srgb, var(--gold-deep) 70%, var(--gold)), var(--gold));
// raised rim: two inset rings
box-shadow:
inset 0 0 0 4px color-mix(in srgb, var(--gold) 70%, #fff),
inset 0 0 0 6px var(--gold-deep),
inset 0 0 0 12px color-mix(in srgb, var(--gold-deep) 25%, transparent);
color: color-mix(in srgb, var(--warm) 30%, #2a1600);
transform: translateZ(3px);
// star emblem
b {
width: 40px;
height: 40px;
clip-path: polygon(50% 0, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
background: linear-gradient(160deg, #fff8de, var(--gold) 45%, var(--gold-deep));
}
small {
font-size: 8px;
font-weight: 800;
letter-spacing: 2px;
}
// One sweep per twist, centred on the moment the face looks straight at you.
em {
position: absolute;
inset: 0;
background: linear-gradient(115deg, transparent 38%, rgb(255 255 255 / 0.7) 50%, transparent 62%);
transform: translateX(-120%);
animation: d-badge-shine $twist linear infinite;
}
}
}
@keyframes d-badge-sway {
from {
transform: rotateZ(-3deg);
}
to {
transform: rotateZ(3deg);
}
}
@keyframes d-badge-twist {
from {
transform: rotateY(-50deg);
}
to {
transform: rotateY(50deg);
}
}
@keyframes d-badge-untwist {
from {
transform: rotateY(50deg);
}
to {
transform: rotateY(-50deg);
}
}
@keyframes d-badge-shine {
0%,
35% {
transform: translateX(-120%);
}
65%,
100% {
transform: translateX(120%);
}
}