22 CSS Star Ratings13 / 22

Pure CSSMIT licensed

Heart-Based Favorite Rating

A ruby-red movie-rating card where hearts replace stars — featuring a CSS heartbeat spring-scale animation on the clicked heart, a fluid fill cascade, and :has()-driven sentiment text.

Published

Live Demo
Try it

The code

<div class="sr-13">
  <div class="sr-13__card">
    <div class="sr-13__banner">
      <div class="sr-13__movie-poster">🎬</div>
      <div class="sr-13__movie-title">Eternal Embers</div>
      <div class="sr-13__movie-meta">2024 · Drama · 128 min · ★ 8.2 IMDb</div>
    </div>

    <div class="sr-13__body">
      <div class="sr-13__prompt">How much did you love it?</div>

      <form class="sr-13__hearts-form">
        <div class="sr-13__hearts">
          <input type="radio" id="sr-13-h5" name="sr-13-heart" value="5">
          <label for="sr-13-h5">5</label>
          <input type="radio" id="sr-13-h4" name="sr-13-heart" value="4">
          <label for="sr-13-h4">4</label>
          <input type="radio" id="sr-13-h3" name="sr-13-heart" value="3">
          <label for="sr-13-h3">3</label>
          <input type="radio" id="sr-13-h2" name="sr-13-heart" value="2">
          <label for="sr-13-h2">2</label>
          <input type="radio" id="sr-13-h1" name="sr-13-heart" value="1">
          <label for="sr-13-h1">1</label>
        </div>
      </form>

      <div class="sr-13__label-row">
        <span class="sr-13__label-item">Hate</span>
        <span class="sr-13__label-item">Dislike</span>
        <span class="sr-13__label-item">Okay</span>
        <span class="sr-13__label-item">Like</span>
        <span class="sr-13__label-item">Love!</span>
      </div>

      <div class="sr-13__status"></div>

      <button class="sr-13__save">❤️ Save to Favourites</button>
    </div>
  </div>
</div>
.sr-13,
.sr-13 *,
.sr-13 *::before,
.sr-13 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.sr-13 ::selection {
  background: #e11d48;
  color: #fff;
}

.sr-13 {
  --rose: #e11d48;
  --rose-light: #fb7185;
  --rose-glow: rgba(225,29,72,0.4);
  --rose-pale: #fff1f2;
  --bg: #fff;
  --bg-soft: #fafafa;
  --text: #1c1917;
  --text-muted: #78716c;
  --border: #e7e5e4;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: var(--bg-soft);
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 32px 16px;
}

.sr-13__card {
  background: var(--bg);
  border-radius: 24px;
  border: 1px solid var(--border);
  max-width: 440px;
  width: 100%;
  overflow: hidden;
  box-shadow: 0 8px 32px rgba(225,29,72,0.08), 0 2px 8px rgba(0,0,0,0.04);
}

.sr-13__banner {
  background: linear-gradient(135deg, #fff1f2, #fce7f3);
  padding: 28px 28px 24px;
  text-align: center;
  border-bottom: 1px solid #fce7f3;
}

.sr-13__movie-poster {
  width: 80px;
  height: 80px;
  border-radius: 18px;
  background: linear-gradient(135deg, #881337, #e11d48);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 40px;
  margin: 0 auto 14px;
  box-shadow: 0 8px 24px rgba(225,29,72,0.3);
}

.sr-13__movie-title {
  font-size: 1.1rem;
  font-weight: 800;
  color: var(--text);
  margin-bottom: 4px;
}

.sr-13__movie-meta {
  font-size: 0.78rem;
  color: var(--text-muted);
}
/* Heart rating area */

.sr-13__body {
  padding: 28px;
}

.sr-13__prompt {
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--text);
  margin-bottom: 20px;
  text-align: center;
}

.sr-13__hearts {
  display: flex;
  flex-direction: row-reverse;
  justify-content: center;
  gap: 10px;
  margin-bottom: 24px;
}

.sr-13__hearts input[type="radio"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.sr-13__hearts label {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  cursor: pointer;
  /* Hide the digit text content — heart shape is drawn via SVG inside the label. */
  font-size: 0;
}
/* Heart shape via SVG path in CSS */

.sr-13__hearts label::before {
  content: '';
  display: block;
  width: 40px;
  height: 40px;
  background: #f5f5f4;
  /* Heart clip-path */
  clip-path: path('M20 34.4C20 34.4 4 24.2 4 13.4C4 8.4 8 4.4 13 4.4C16 4.4 18.6 5.8 20 8C21.4 5.8 24 4.4 27 4.4C32 4.4 36 8.4 36 13.4C36 24.2 20 34.4 20 34.4Z');
  transition: background 0.2s ease, transform 0.25s cubic-bezier(.34,1.56,.64,1), filter 0.25s ease;
}
/* Hover cascade fill */

.sr-13__hearts label:hover::before,
.sr-13__hearts label:hover ~ label::before {
  background: var(--rose-light);
  filter: drop-shadow(0 0 8px rgba(251,113,133,0.5));
}
/* Checked cascade fill */

.sr-13__hearts input:checked ~ label::before {
  background: var(--rose);
  filter: drop-shadow(0 0 8px var(--rose-glow));
}
/* The directly-checked heart gets the pulse */

.sr-13__hearts input:checked + label::before {
  background: var(--rose);
  filter: drop-shadow(0 0 14px var(--rose-glow));
  animation: sr-13-heartbeat 0.6s ease-out forwards;
}

.sr-13__hearts label:hover {
}

.sr-13__hearts label:hover::before {
  transform: scale(1.15);
}

@keyframes sr-13-heartbeat {
  0% {
    transform: scale(1);
  }

  30% {
    transform: scale(1.35);
    filter: drop-shadow(0 0 18px rgba(225,29,72,0.7));
  }

  60% {
    transform: scale(0.95);
  }

  80% {
    transform: scale(1.12);
  }

  100% {
    transform: scale(1.1);
  }
}
/* Selection label text — mirrors the .sr-13__hearts layout
   exactly (justify-content: center; gap: 10px) so each label sits
   directly under its heart. Without matching geometry, the
   hearts cluster centered ~300px wide while the labels stretch
   to the parent's full width via space-between, misaligning. */

.sr-13__label-row {
  display: flex;
  justify-content: center;
  gap: 10px;
  margin-bottom: 20px;
}

.sr-13__label-item {
  text-align: center;
  font-size: 0.65rem;
  color: var(--text-muted);
  width: 52px;
}
/* Status message */

.sr-13__status {
  text-align: center;
  font-size: 0.88rem;
  font-weight: 600;
  color: var(--text-muted);
  min-height: 24px;
  margin-bottom: 20px;
  transition: color 0.2s;
}

.sr-13__hearts-form:has(#sr-13-h1:checked) ~ .sr-13__status {
  color: var(--rose);
}

.sr-13__hearts-form:has(#sr-13-h1:checked) ~ .sr-13__status::after {
  content: 'Not for me 💔';
}

.sr-13__hearts-form:has(#sr-13-h2:checked) ~ .sr-13__status {
  color: var(--rose-light);
}

.sr-13__hearts-form:has(#sr-13-h2:checked) ~ .sr-13__status::after {
  content: 'It was okay 🤷';
}

.sr-13__hearts-form:has(#sr-13-h3:checked) ~ .sr-13__status {
  color: #f97316;
}

.sr-13__hearts-form:has(#sr-13-h3:checked) ~ .sr-13__status::after {
  content: 'Pretty good 👍';
}

.sr-13__hearts-form:has(#sr-13-h4:checked) ~ .sr-13__status {
  color: #22c55e;
}

.sr-13__hearts-form:has(#sr-13-h4:checked) ~ .sr-13__status::after {
  content: 'Really loved it ❤️';
}

.sr-13__hearts-form:has(#sr-13-h5:checked) ~ .sr-13__status {
  color: var(--rose);
}

.sr-13__hearts-form:has(#sr-13-h5:checked) ~ .sr-13__status::after {
  content: 'Absolute favourite 💖';
}
/* The form must precede these */

.sr-13__inner {
  position: relative;
}
/* Save button */

.sr-13__save {
  width: 100%;
  padding: 13px;
  border: none;
  border-radius: 14px;
  background: linear-gradient(135deg, var(--rose), #f43f5e);
  color: #fff;
  font-weight: 700;
  font-size: 0.9rem;
  cursor: pointer;
  transition: transform 0.15s, box-shadow 0.15s;
  box-shadow: 0 6px 20px rgba(225,29,72,0.3);
}

.sr-13__save:hover {
  transform: translateY(-2px);
  box-shadow: 0 10px 28px rgba(225,29,72,0.4);
}

@media (prefers-reduced-motion: reduce) {
  .sr-13__hearts label::before {
    transition: background 0.1s;
    animation: none !important;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Star Rating from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Heart-Based Favorite Rating
Source: https://codefronts.com/components/css-star-ratings/heart-based-favorite-rating/

A ruby-red movie-rating card where hearts replace stars — featuring a CSS heartbeat spring-scale animation on the clicked heart, a fluid fill cascade, and :has()-driven sentiment text.
## HTML
```html
<div class="sr-13">
  <div class="sr-13__card">
    <div class="sr-13__banner">
      <div class="sr-13__movie-poster">🎬</div>
      <div class="sr-13__movie-title">Eternal Embers</div>
      <div class="sr-13__movie-meta">2024 · Drama · 128 min · ★ 8.2 IMDb</div>
    </div>

    <div class="sr-13__body">
      <div class="sr-13__prompt">How much did you love it?</div>

      <form class="sr-13__hearts-form">
        <div class="sr-13__hearts">
          <input type="radio" id="sr-13-h5" name="sr-13-heart" value="5">
          <label for="sr-13-h5">5</label>
          <input type="radio" id="sr-13-h4" name="sr-13-heart" value="4">
          <label for="sr-13-h4">4</label>
          <input type="radio" id="sr-13-h3" name="sr-13-heart" value="3">
          <label for="sr-13-h3">3</label>
          <input type="radio" id="sr-13-h2" name="sr-13-heart" value="2">
          <label for="sr-13-h2">2</label>
          <input type="radio" id="sr-13-h1" name="sr-13-heart" value="1">
          <label for="sr-13-h1">1</label>
        </div>
      </form>

      <div class="sr-13__label-row">
        <span class="sr-13__label-item">Hate</span>
        <span class="sr-13__label-item">Dislike</span>
        <span class="sr-13__label-item">Okay</span>
        <span class="sr-13__label-item">Like</span>
        <span class="sr-13__label-item">Love!</span>
      </div>

      <div class="sr-13__status"></div>

      <button class="sr-13__save">❤️ Save to Favourites</button>
    </div>
  </div>
</div>
```
## CSS
```css
.sr-13,
.sr-13 *,
.sr-13 *::before,
.sr-13 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.sr-13 ::selection {
  background: #e11d48;
  color: #fff;
}

.sr-13 {
  --rose: #e11d48;
  --rose-light: #fb7185;
  --rose-glow: rgba(225,29,72,0.4);
  --rose-pale: #fff1f2;
  --bg: #fff;
  --bg-soft: #fafafa;
  --text: #1c1917;
  --text-muted: #78716c;
  --border: #e7e5e4;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: var(--bg-soft);
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 32px 16px;
}

.sr-13__card {
  background: var(--bg);
  border-radius: 24px;
  border: 1px solid var(--border);
  max-width: 440px;
  width: 100%;
  overflow: hidden;
  box-shadow: 0 8px 32px rgba(225,29,72,0.08), 0 2px 8px rgba(0,0,0,0.04);
}

.sr-13__banner {
  background: linear-gradient(135deg, #fff1f2, #fce7f3);
  padding: 28px 28px 24px;
  text-align: center;
  border-bottom: 1px solid #fce7f3;
}

.sr-13__movie-poster {
  width: 80px;
  height: 80px;
  border-radius: 18px;
  background: linear-gradient(135deg, #881337, #e11d48);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 40px;
  margin: 0 auto 14px;
  box-shadow: 0 8px 24px rgba(225,29,72,0.3);
}

.sr-13__movie-title {
  font-size: 1.1rem;
  font-weight: 800;
  color: var(--text);
  margin-bottom: 4px;
}

.sr-13__movie-meta {
  font-size: 0.78rem;
  color: var(--text-muted);
}
/* Heart rating area */

.sr-13__body {
  padding: 28px;
}

.sr-13__prompt {
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--text);
  margin-bottom: 20px;
  text-align: center;
}

.sr-13__hearts {
  display: flex;
  flex-direction: row-reverse;
  justify-content: center;
  gap: 10px;
  margin-bottom: 24px;
}

.sr-13__hearts input[type="radio"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.sr-13__hearts label {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  cursor: pointer;
  /* Hide the digit text content — heart shape is drawn via SVG inside the label. */
  font-size: 0;
}
/* Heart shape via SVG path in CSS */

.sr-13__hearts label::before {
  content: '';
  display: block;
  width: 40px;
  height: 40px;
  background: #f5f5f4;
  /* Heart clip-path */
  clip-path: path('M20 34.4C20 34.4 4 24.2 4 13.4C4 8.4 8 4.4 13 4.4C16 4.4 18.6 5.8 20 8C21.4 5.8 24 4.4 27 4.4C32 4.4 36 8.4 36 13.4C36 24.2 20 34.4 20 34.4Z');
  transition: background 0.2s ease, transform 0.25s cubic-bezier(.34,1.56,.64,1), filter 0.25s ease;
}
/* Hover cascade fill */

.sr-13__hearts label:hover::before,
.sr-13__hearts label:hover ~ label::before {
  background: var(--rose-light);
  filter: drop-shadow(0 0 8px rgba(251,113,133,0.5));
}
/* Checked cascade fill */

.sr-13__hearts input:checked ~ label::before {
  background: var(--rose);
  filter: drop-shadow(0 0 8px var(--rose-glow));
}
/* The directly-checked heart gets the pulse */

.sr-13__hearts input:checked + label::before {
  background: var(--rose);
  filter: drop-shadow(0 0 14px var(--rose-glow));
  animation: sr-13-heartbeat 0.6s ease-out forwards;
}

.sr-13__hearts label:hover {
}

.sr-13__hearts label:hover::before {
  transform: scale(1.15);
}

@keyframes sr-13-heartbeat {
  0% {
    transform: scale(1);
  }

  30% {
    transform: scale(1.35);
    filter: drop-shadow(0 0 18px rgba(225,29,72,0.7));
  }

  60% {
    transform: scale(0.95);
  }

  80% {
    transform: scale(1.12);
  }

  100% {
    transform: scale(1.1);
  }
}
/* Selection label text — mirrors the .sr-13__hearts layout
   exactly (justify-content: center; gap: 10px) so each label sits
   directly under its heart. Without matching geometry, the
   hearts cluster centered ~300px wide while the labels stretch
   to the parent's full width via space-between, misaligning. */

.sr-13__label-row {
  display: flex;
  justify-content: center;
  gap: 10px;
  margin-bottom: 20px;
}

.sr-13__label-item {
  text-align: center;
  font-size: 0.65rem;
  color: var(--text-muted);
  width: 52px;
}
/* Status message */

.sr-13__status {
  text-align: center;
  font-size: 0.88rem;
  font-weight: 600;
  color: var(--text-muted);
  min-height: 24px;
  margin-bottom: 20px;
  transition: color 0.2s;
}

.sr-13__hearts-form:has(#sr-13-h1:checked) ~ .sr-13__status {
  color: var(--rose);
}

.sr-13__hearts-form:has(#sr-13-h1:checked) ~ .sr-13__status::after {
  content: 'Not for me 💔';
}

.sr-13__hearts-form:has(#sr-13-h2:checked) ~ .sr-13__status {
  color: var(--rose-light);
}

.sr-13__hearts-form:has(#sr-13-h2:checked) ~ .sr-13__status::after {
  content: 'It was okay 🤷';
}

.sr-13__hearts-form:has(#sr-13-h3:checked) ~ .sr-13__status {
  color: #f97316;
}

.sr-13__hearts-form:has(#sr-13-h3:checked) ~ .sr-13__status::after {
  content: 'Pretty good 👍';
}

.sr-13__hearts-form:has(#sr-13-h4:checked) ~ .sr-13__status {
  color: #22c55e;
}

.sr-13__hearts-form:has(#sr-13-h4:checked) ~ .sr-13__status::after {
  content: 'Really loved it ❤️';
}

.sr-13__hearts-form:has(#sr-13-h5:checked) ~ .sr-13__status {
  color: var(--rose);
}

.sr-13__hearts-form:has(#sr-13-h5:checked) ~ .sr-13__status::after {
  content: 'Absolute favourite 💖';
}
/* The form must precede these */

.sr-13__inner {
  position: relative;
}
/* Save button */

.sr-13__save {
  width: 100%;
  padding: 13px;
  border: none;
  border-radius: 14px;
  background: linear-gradient(135deg, var(--rose), #f43f5e);
  color: #fff;
  font-weight: 700;
  font-size: 0.9rem;
  cursor: pointer;
  transition: transform 0.15s, box-shadow 0.15s;
  box-shadow: 0 6px 20px rgba(225,29,72,0.3);
}

.sr-13__save:hover {
  transform: translateY(-2px);
  box-shadow: 0 10px 28px rgba(225,29,72,0.4);
}

@media (prefers-reduced-motion: reduce) {
  .sr-13__hearts label::before {
    transition: background 0.1s;
    animation: none !important;
  }
}
```

How this works

Hearts are drawn via clip-path: path(...) on label::before pseudo-elements using an SVG path string for the classic heart silhouette. The reversed flex layout enables the same sibling-combinator cascade fill as star ratings. On :checked, the directly-selected label's ::before fires @keyframes sr-13-heartbeat: a three-stage scale sequence (1 → 1.35 → 0.95 → 1.12 → 1.1) that mimics the biological two-phase lub-dub heartbeat rhythm, with an intensifying filter: drop-shadow glow at peak expansion.

Sentiment text is displayed using :has(#id:checked) ~ .sr-13__status — the status element is a sibling of the form, and the ~ combinator reaches it from the form's checked state detected by :has(). Each sentiment string is a span with display: none by default; the matching :has() rule sets it to display: block while hiding the default prompt via the same mechanism.

Make it yours

  • Change the heartbeat rhythm by editing the @keyframes sr-13-heartbeat percentage stops — increase the 30% scale to 1.5 for a dramatic thump, or 1.2 for a subtle flutter.
  • Replace the heart clip-path: path() string with a different shape — a thumbs-up, lightning bolt, or custom monogram glyph — while keeping all other animation logic intact.
  • Add a 'remove favourite' empty-heart toggle by providing a sixth radio with value 0 and label::before set to a white fill with a red border, positioned before the first heart in reverse order.
  • Animate the sentiment text entrance by adding animation: fadeSlideIn 0.3s ease to the revealed span elements so each message slides up when it appears.
  • Wire the save button to a real favourites API by adding a JS submit listener that reads the checked radio value and POSTs to your endpoint, then shows a success toast via a transient CSS class.

Gotchas — read before shipping

  • The clip-path: path() function uses pixel coordinates so the heart does not scale proportionally with width/height changes — to resize hearts, multiply all coordinate values in the path string by a uniform scale factor.
  • The @keyframes sr-13-heartbeat animation only plays once on state change — if the user selects the same heart twice, it won't replay. Add a JS click handler to briefly remove and re-apply the animation property if replay on re-click is needed.
  • The status text siblings use the ~ combinator and require being at the same DOM level as the form element — nesting the status div inside the form will break the selector.

Browser support

ChromeSafariFirefoxEdge
105+15.4+121+105+

clip-path: path() requires Chrome 88+/Safari 13.1+/Firefox 71+. :has() for sentiment text requires 105+/15.4+/121+. Animation works in all modern browsers.

Techniques used in this demo

Search CodeFronts

Loading…