Pure CSS Buttons & forms #controls #form-hack #stars

Flipping star rating in pure CSS

Five thin star plates, each a front and a gold back with an edge between them. Radio inputs and labels choose a rating, and every star up to it flips over, one after another.

  • 208 lines of CSS
  • 16 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Every length is a multiple of one base unit, --u, tied to the canvas — the stars, the thickness between their layers, the caption — so the row is the same share of a gallery card, the editor and a recording canvas. The numbers below are those units.
  2. Each star is five copies of one clip-path star stacked 1 unit apart: the front (plain) and back (gold) faces with three edge layers between them, so a turning star shows a real thickness.
  3. The back is placed with rotateY(180deg) translateZ(2 units): it faces the other way, and once the star turns half round it reads correctly. backface-visibility: hidden on both faces means only the one facing you is drawn.
  4. The five radios come first, so ~ can reach everything after them. Rating r is one rule: #star-r:checked ~ .stars label:nth-child(-n + r) selects the first r stars, which turn over.
  5. transition-delay: calc(var(--i) * 90ms) lights them left to right; the resting style has the reverse delay, so a lower rating empties the row from the right. The delay in the state you go to is the one that counts.
  6. The labels are the static hit targets (only the star inside moves), and the real radios keep arrow keys, forms and screen readers working. A ring bursts once as each star lands: an animation that only starts when its rule starts to match.

Key techniques

  • #star-n:checked ~ label:nth-child(-n + n)
  • clip-path star faces, stacked edge layers
  • rotateY(180deg) + backface-visibility
  • transition-delay from --i

Copy-paste code

HTML 16 lines

<div class="rating" role="radiogroup" aria-label="Rate this effect">
  <b class="title" aria-hidden="true">Rate this effect</b>
  <input type="radio" name="rating" id="star-1" aria-label="1 star, Poor" />
  <input type="radio" name="rating" id="star-2" aria-label="2 stars, Okay" />
  <input type="radio" name="rating" id="star-3" aria-label="3 stars, Good" />
  <input type="radio" name="rating" id="star-4" aria-label="4 stars, Great" checked />
  <input type="radio" name="rating" id="star-5" aria-label="5 stars, Amazing!" />
  <div class="stars">
    <label for="star-1" style="--i:0"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
    <label for="star-2" style="--i:1"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
    <label for="star-3" style="--i:2"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
    <label for="star-4" style="--i:3"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
    <label for="star-5" style="--i:4"><span class="lift"><span class="star"><i></i><i></i><i></i><i></i><i></i></span></span></label>
  </div>
  <p class="words" aria-hidden="true"><span>Tap a star</span><span>Poor</span><span>Okay</span><span>Good</span><span>Great</span><span>Amazing!</span></p>
</div>

CSS 208 lines

.rating {
  /* one base unit: every length below is a multiple of it, so the row is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.38vmin;
  display: grid;
  justify-items: center;
  /* five stars in a row is a wide shape. The caption and the gaps carry the height: at the old
     11/12/14 they did not, and the model came out at the 92vmin width limit and under the
     40vmin floor */
  gap: calc(18 * var(--u));
  perspective: calc(800 * var(--u));
  transform-style: preserve-3d;
}

/* the real radios stay (arrow keys, forms, screen readers), only invisible */
.rating input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.title {
  /* the stage's own ink, softened with opacity: a fixed pale lavender was 2.5:1 on the light stage */
  color: inherit;
  opacity: 0.72;
  font-size: calc(16 * var(--u));
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.stars {
  display: flex;
  gap: calc(9 * var(--u));
  transform-style: preserve-3d;
}

/* the label is the static hit target; only the star inside it moves */
.stars label {
  position: relative;
  width: calc(34 * var(--u));
  height: calc(34 * var(--u));
  border-radius: calc(8 * var(--u));
  cursor: pointer;
  transform-style: preserve-3d;
}

/* a ring that bursts once as the star lands gold */
.stars label::before {
  content: '';
  position: absolute;
  inset: calc(-5 * var(--u));
  border: calc(2 * var(--u)) solid #ffb547;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}

/* a warm glow behind a gold star */
.stars label::after {
  content: '';
  position: absolute;
  inset: calc(-10 * var(--u));
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(255 181 71 / 0.45), transparent);
  opacity: 0;
  pointer-events: none;
  transform: translateZ(calc(-6 * var(--u)));
  transition: opacity 0.3s;
}

.lift {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transform-style: preserve-3d;
  transition: transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1);
}

label:hover .lift {
  transform: translateZ(calc(12 * var(--u))) scale(1.14);
}

.star {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transition: transform 0.7s cubic-bezier(0.35, 1.45, 0.5, 1);
  transition-delay: calc((4 - var(--i)) * 60ms); /* turning back: the last star first */
}

/* five copies of one star shape, 1 unit apart */
.star i {
  position: absolute;
  inset: 0;
  clip-path: polygon(50% 2%, 61.5% 36%, 97% 36.5%, 68.5% 57.5%, 79% 92%, 50% 71%, 21% 92%, 31.5% 57.5%, 3% 36.5%, 38.5% 36%);
  background: color-mix(in srgb, #ffb547 40%, #1b1408); /* the edge */
}

.star i:nth-child(2) { transform: translateZ(calc(1 * var(--u))); }
.star i:nth-child(4) { transform: translateZ(calc(-1 * var(--u))); }

/* front: a darker star inside a lighter one, so it looks outlined */
.star i:nth-child(1) {
  background: color-mix(in srgb, #eceefb 38%, #141830);
  backface-visibility: hidden;
  transform: translateZ(calc(2 * var(--u)));
}

.star i:nth-child(1)::before {
  content: '';
  position: absolute;
  inset: calc(3 * var(--u));
  clip-path: inherit;
  background: color-mix(in srgb, #eceefb 12%, #141830);
}

/* back: gold, facing the other way */
.star i:nth-child(5) {
  background: linear-gradient(160deg, #ffe2b8 10%, #ffb547 45%, #e58a2e 90%);
  backface-visibility: hidden;
  transform: rotateY(180deg) translateZ(calc(2 * var(--u)));
}

/* rating r turns stars 1 to r, one after another */
#star-1:checked ~ .stars label:nth-child(-n + 1) .star,
#star-2:checked ~ .stars label:nth-child(-n + 2) .star,
#star-3:checked ~ .stars label:nth-child(-n + 3) .star,
#star-4:checked ~ .stars label:nth-child(-n + 4) .star,
#star-5:checked ~ .stars label:nth-child(-n + 5) .star {
  transform: rotateY(180deg);
  transition-delay: calc(var(--i) * 90ms);
}

#star-1:checked ~ .stars label:nth-child(-n + 1)::after,
#star-2:checked ~ .stars label:nth-child(-n + 2)::after,
#star-3:checked ~ .stars label:nth-child(-n + 3)::after,
#star-4:checked ~ .stars label:nth-child(-n + 4)::after,
#star-5:checked ~ .stars label:nth-child(-n + 5)::after {
  opacity: 1;
  transition-delay: calc(var(--i) * 90ms + 200ms);
}

/* a newly matching rule starts its animation: the ring bursts once per star lit */
#star-1:checked ~ .stars label:nth-child(-n + 1)::before,
#star-2:checked ~ .stars label:nth-child(-n + 2)::before,
#star-3:checked ~ .stars label:nth-child(-n + 3)::before,
#star-4:checked ~ .stars label:nth-child(-n + 4)::before,
#star-5:checked ~ .stars label:nth-child(-n + 5)::before {
  animation: burst 0.65s calc(var(--i) * 90ms + 250ms) ease-out both;
}

@keyframes burst {
  from { opacity: 0.9; transform: translateZ(calc(-4 * var(--u))) scale(0.5); }
  to { opacity: 0; transform: translateZ(calc(-4 * var(--u))) scale(1.5); }
}

/* keyboard focus: ring the star whose radio has focus */
#star-1:focus-visible ~ .stars label:nth-child(1),
#star-2:focus-visible ~ .stars label:nth-child(2),
#star-3:focus-visible ~ .stars label:nth-child(3),
#star-4:focus-visible ~ .stars label:nth-child(4),
#star-5:focus-visible ~ .stars label:nth-child(5) {
  outline: calc(2 * var(--u)) solid #2ee6d6;
  outline-offset: calc(5 * var(--u));
}

/* the caption: all words in one grid cell, only the chosen one shows */
.words {
  display: grid;
  margin: 0;
  /* a deep gold, 3:1 or more on both stages (this is large text): the stars' bright #ffb547 was
     1.6:1 on the light one */
  color: #c47a00;
  font-size: calc(26 * var(--u));
  font-weight: 800;
}

.words span {
  grid-area: 1 / 1;
  justify-self: center;
  opacity: 0;
  transform: translateY(calc(5 * var(--u)));
  transition: opacity 0.25s, transform 0.25s;
}

.words span:first-child {
  color: inherit; /* the stage's own ink: a fixed pale lavender vanished on the light stage */
  opacity: 1;
  transform: none;
}

.rating input:checked ~ .words span:first-child {
  opacity: 0;
  transform: translateY(calc(-5 * var(--u)));
}

#star-1:checked ~ .words span:nth-child(2),
#star-2:checked ~ .words span:nth-child(3),
#star-3:checked ~ .words span:nth-child(4),
#star-4:checked ~ .words span:nth-child(5),
#star-5:checked ~ .words span:nth-child(6) {
  opacity: 1;
  transform: none;
}
Standalone snippet · plain CSS, no build step, no dependencies.