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

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.

  • 198 lines of CSS
  • 16 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. Each star is five copies of one clip-path star stacked 1px apart: the front (plain) and back (gold) faces with three edge layers between them, so a turning star shows a real thickness.
  2. The back is placed with rotateY(180deg) translateZ(2px): 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.
  3. 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.
  4. 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.
  5. 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

  • radio:nth-of-type(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 198 lines

.rating {
  display: grid;
  justify-items: center;
  gap: 12px;
  perspective: 800px;
  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 {
  color: #949bc0;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.stars {
  display: flex;
  gap: 9px;
  transform-style: preserve-3d;
}

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

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

/* a warm glow behind a gold star */
.stars label::after {
  content: '';
  position: absolute;
  inset: -10px;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(255 181 71 / 0.45), transparent);
  opacity: 0;
  pointer-events: none;
  transform: translateZ(-6px);
  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(12px) 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, 1px 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(1px); }
.star i:nth-child(4) { transform: translateZ(-1px); }

/* 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(2px);
}

.star i:nth-child(1)::before {
  content: '';
  position: absolute;
  inset: 3px;
  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(2px);
}

/* 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(-4px) scale(0.5); }
  to { opacity: 0; transform: translateZ(-4px) 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: 2px solid #2ee6d6;
  outline-offset: 5px;
}

/* the caption: all words in one grid cell, only the chosen one shows */
.words {
  display: grid;
  margin: 0;
  color: #ffb547;
  font-size: 14px;
  font-weight: 800;
}

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

.words span:first-child {
  color: #949bc0;
  opacity: 1;
  transform: none;
}

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

#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;
}

Sass source 201 lines

$n: 5; // stars
$size: 34px;
$star: 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%);

.d-rating {
  display: grid;
  justify-items: center;
  gap: 12px;
  color: var(--text);
  transform-style: preserve-3d;

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

  &__title {
    color: var(--muted);
    font-size: 11px;
    font-weight: 800;
    letter-spacing: 0.14em;
    text-transform: uppercase;
  }

  &__stars {
    display: flex;
    gap: 9px;
    transform-style: preserve-3d;
  }

  // The label is the static hit target; only the star inside it moves.
  label {
    position: relative;
    width: $size;
    height: $size;
    border-radius: 8px;
    cursor: pointer;
    transform-style: preserve-3d;

    // a ring that bursts out once when the star lands gold (see the @for below)
    &::before {
      content: '';
      position: absolute;
      inset: -5px;
      border: 2px solid var(--warm);
      border-radius: 50%;
      opacity: 0;
      pointer-events: none;
    }

    // a warm glow behind a gold star
    &::after {
      content: '';
      position: absolute;
      inset: -10px;
      border-radius: 50%;
      background: radial-gradient(closest-side, color-mix(in srgb, var(--warm) 45%, transparent), transparent);
      opacity: 0;
      pointer-events: none;
      transform: translateZ(-6px);
      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(12px) scale(1.14);
  }

  // One star: five copies of the same clip-path shape stacked 1px apart. The front and back are
  // the faces; the three in between are the edge you see while it turns.
  &__star {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    transition: transform 0.7s cubic-bezier(0.35, 1.45, 0.5, 1);
    // Going back to plain, the last star turns first: the row empties from the right.
    transition-delay: calc((#{$n - 1} - var(--i)) * 60ms);

    i {
      position: absolute;
      inset: 0;
      clip-path: $star;
      background: color-mix(in srgb, var(--warm) 40%, #1b1408);
    }

    // front: a plain star, drawn as a darker star inside a lighter one to look outlined
    i:nth-child(1) {
      background: color-mix(in srgb, var(--text) 38%, var(--surface-solid));
      backface-visibility: hidden;
      transform: translateZ(2px);

      &::before {
        content: '';
        position: absolute;
        inset: 3px;
        clip-path: $star;
        background: color-mix(in srgb, var(--text) 12%, var(--surface-solid));
      }
    }

    i:nth-child(2) {
      transform: translateZ(1px);
    }

    i:nth-child(4) {
      transform: translateZ(-1px);
    }

    // back: gold, turned to face the other way so it reads right once the star is flipped
    i:nth-child(5) {
      background:
        linear-gradient(160deg, color-mix(in srgb, var(--warm), #fff 55%) 10%, var(--warm) 45%, color-mix(in srgb, var(--warm), #b33c00 35%) 90%);
      backface-visibility: hidden;
      transform: rotateY(180deg) translateZ(2px);
    }
  }

  // the caption under the stars: every word sits in the same cell, only the chosen one shows
  &__words {
    display: grid;
    margin: 0;
    color: var(--warm);
    font-size: 14px;
    font-weight: 800;

    span {
      grid-area: 1 / 1;
      justify-self: center;
      opacity: 0;
      transform: translateY(5px);
      transition:
        opacity 0.25s,
        transform 0.25s;
    }

    span:first-child {
      color: var(--muted);
      opacity: 1;
      transform: none;
    }
  }

  input:checked ~ &__words span:first-child {
    opacity: 0;
    transform: translateY(-5px);
  }
}

// Rating r lights stars 1 to r. The chosen radio is the r-th input; ~ reaches the stars after it.
@for $r from 1 through $n {
  .d-rating input:nth-of-type(#{$r}):checked ~ .d-rating__stars label:nth-child(-n + #{$r}) {
    &::before {
      animation: d-rating-burst 0.65s calc(var(--i) * 90ms + 250ms) ease-out both;
    }

    &::after {
      opacity: 1;
      transition-delay: calc(var(--i) * 90ms + 200ms);
    }

    .d-rating__star {
      transform: rotateY(180deg);
      transition-delay: calc(var(--i) * 90ms); // lighting up: left to right
    }
  }

  .d-rating input:nth-of-type(#{$r}):checked ~ .d-rating__words span:nth-child(#{$r + 1}) {
    opacity: 1;
    transform: none;
  }

  .d-rating input:nth-of-type(#{$r}):focus-visible ~ .d-rating__stars label:nth-child(#{$r}) {
    outline: 2px solid var(--accent-2);
    outline-offset: 5px;
  }
}

@keyframes d-rating-burst {
  from {
    opacity: 0.9;
    transform: translateZ(-4px) scale(0.5);
  }

  to {
    opacity: 0;
    transform: translateZ(-4px) scale(1.5);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.