Pure CSS Products & branding #loop #product #medal #award #coin

Award medal in pure CSS

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.

  • 152 lines of CSS
  • 13 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed
GitHub

How it works

  1. Build it the way a real medal hangs: two ribbon straps meet in a folded tab, and the medal has its own ring (the bail) whose top loop sits just behind the tab. Ribbon and medal read as one object.
  2. The whole piece sways from the neck: transform-origin: 50% 0 on a wrapper and a small rotateZ swing, alternate.
  3. The medal twists on its ring between 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.
  4. The edge is five discs 1.2px apart behind the face, so a turned medal shows a band of metal. The shine runs on the same 5s cycle as the twist, so each sweep is centred on the moment the face looks at you.

Key techniques

  • bail looped behind a folded tab
  • pendulum from transform-origin: top
  • edge from stacked discs
  • shine synced to the twist

Copy-paste code

HTML 13 lines

<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>

CSS 152 lines

.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%); }
}

Sass source 180 lines

$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%);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.