<header>
<h1>Animated Star Rating</h1>
</header>
<main>
<div class="rating">
<button id="star1" class="star" aria-pressed="false" data-transition_delay="0" tabindex="0">
<span class="visually-hidden">One Star</span>
<svg class="star__shape" viewbox="0 0 300 300" role="img" aria-label="star1">
<polygon points="150 30, 180 120, 270 120, 210 180, 240 270, 150 210, 60 270, 90 180, 30 120, 120 120" />
</svg>
</button>
<button id="star2" class="star" aria-pressed="false" data-transition_delay="1" tabindex="0">
<span class="visually-hidden">Two Stars</span>
<svg class="star__shape" viewbox="0 0 300 300" role="img" aria-label="star2">
<polygon points="150 30, 180 120, 270 120, 210 180, 240 270, 150 210, 60 270, 90 180, 30 120, 120 120" />
</svg>
</button>
<button id="star3" class="star" aria-pressed="false" data-transition_delay="2" tabindex="0">
<span class="visually-hidden">Three Stars</span>
<svg class="star__shape" viewbox="0 0 300 300" role="img" aria-label="star3">
<polygon points="150 30, 180 120, 270 120, 210 180, 240 270, 150 210, 60 270, 90 180, 30 120, 120 120" />
</svg>
</button>
<button id="star4" class="star" aria-pressed="false" data-transition_delay="3" tabindex="0">
<span class="visually-hidden">Four Stars</span>
<svg class="star__shape" viewbox="0 0 300 300" role="img" aria-label="star4">
<polygon points="150 30, 180 120, 270 120, 210 180, 240 270, 150 210, 60 270, 90 180, 30 120, 120 120" />
</svg>
</button>
<button id="star5" class="star" aria-pressed="false" data-transition_delay="4" tabindex="0">
<span class="visually-hidden">Five Stars</span>
<svg class="star__shape" viewbox="0 0 300 300" role="img" aria-label="star5">
<polygon points="150 30, 180 120, 270 120, 210 180, 240 270, 150 210, 60 270, 90 180, 30 120, 120 120" />
</svg>
</button>
<div class="visually-hidden rating__current" aria-live="polite" tabindex="0">
Current Rating: <span class="rating__current__count">0 stars</span>
</div>
</div>
</main>
<footer>
<p>
Inspired by <a href="https://codepen.io/mariusgundersen/pen/jEOxqoe"
>a pen by Marius Gundersen</a> and <a href="https://adrianroselli.com/2023/03/css-only-widgets-are-inaccessible.html"
>CSS-only Widgets Are Inaccessible</a> by Adrian Roselli.
</p>
</footer>
@property --star-points {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
:root {
--transition-delay: 0ms;
}
.rating {
display: flex;
justify-content: center;
& .star {
padding: 0;
flex: 0 1 3.5rem;
}
}
button.star {
width: 5rem;
aspect-ratio: 1 / 1;
background-color: transparent;
border: none;
/* The spaces in the HTML are rendered and throw off the size of the element */
font-size: 0;
position: relative;
&::before {
content: '';
position: absolute;
inset: 5%;
z-index: -1;
background: conic-gradient(from -48deg, gold calc(var(--star-points) * 72deg), transparent calc(var(--star-points) * 72deg));
clip-path: polygon(50% 10%, 60% 40%, 90% 40%, 70% 60%, 80% 90%, 50% 70%, 20% 90%, 30% 60%, 10% 40%, 40% 40%);
transition-property: --star-points;
transition-duration: 300ms;
transition-delay: var(--transition-delay);
}
&[aria-pressed="true"]::before {
--star-points: 5;
}
& .star__shape {
& polygon {
fill: transparent;
stroke: gray;
stroke-width: 5;
stroke-linejoin: round;
}
}
}
[data-transition_delay="0"] { --transition-delay: 0 }
[data-transition_delay="1"] { --transition-delay: 100ms }
[data-transition_delay="2"] { --transition-delay: 200ms }
[data-transition_delay="3"] { --transition-delay: 300ms }
[data-transition_delay="4"] { --transition-delay: 400ms }
html {
text-align: center;
}
body {
/* Pattern generated using the CSS Background Patterns generator
* https://www.magicpattern.design/tools/css-backgrounds
* ... and tweaked by me.
*
* This is unrelated to the demo, but it does help to show that the stars
* can appear on any background.
*/
--color1: hsl(from #444cf7 h s l / 0.1);
--color2: snow;
background-color: var(--color2);
background-image:
linear-gradient(var(--color1) 2px, transparent 2px),
linear-gradient(90deg, var(--color1) 2px, transparent 2px),
linear-gradient(var(--color1) 1px, transparent 1px),
linear-gradient(90deg, var(--color1) 1px, var(--color2) 1px);
background-size: 50px 50px, 50px 50px, 10px 10px, 10px 10px;
background-position: -2px -2px, -2px -2px, -1px -1px, -1px -1px;
display: flex;
flex-direction: column;
min-height: 100vh;
& main {
flex: 100%;
}
}
console.clear()
let throttle
window.addEventListener('DOMContentLoaded', () => {
const stars = Array.from(document.querySelectorAll('button.star')).map((button) => {
const controls_id = button.getAttribute('aria-controls')
const controls = document.getElementById(controls_id)
const toggle = (onoff) => {
const value = onoff === 'on' ? 'true' : 'false'
button.ariaPressed = value
adjustDelays()
updateRating()
}
const press = () => toggle('on')
const unpress = () => toggle('off')
return {
element: button,
controls: controls,
press: press,
unpress: unpress,
}
})
stars.forEach((star, index) => {
star.element.addEventListener('click', (event) => {
const pressed = star.element.ariaPressed === 'true'
const last_pressed = Array.from(document.querySelectorAll('button.star[aria-pressed="true"]')).toReversed()[0]
if (pressed) {
// If the star button was pressed and it was the last one, unpress it.
// If it wasn't the last one, just unpress those that follow it.
if (star.element === last_pressed) {
star.unpress()
} else {
stars.slice(index + 1).forEach(other_star => other_star.unpress())
}
} else {
// If the star button wasn't pressed, press it and the ones before it.
stars.slice(0, index + 1).forEach(other_star => other_star.press())
}
})
})
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Understanding_WCAG/Keyboard#focusable_elements_should_have_interactive_semantics
const rating_field = document.querySelector('.rating__current')
const rating_field_handler = (event) => {
if (['Escape', 'Enter', 'Space'].includes(event.code)) {
rating_field.blur()
}
}
rating_field.addEventListener('keydown', rating_field_handler)
rating_field.addEventListener('keyup', rating_field_handler)
})
function adjustDelays() {
clearTimeout(throttle)
throttle = setTimeout(() => {
const star_buttons = Array.from(document.querySelectorAll('button.star'))
let delay = 0
star_buttons.forEach((button, index) => {
if (button.ariaPressed === 'true') {
button.dataset.transition_delay = '0'
} else {
button.dataset.transition_delay = delay
delay += 1;
}
})
}, 500)
}
function updateRating() {
const fields = document.querySelectorAll('.rating__current__count')
const selected = document.querySelectorAll('.star[aria-pressed="true"]')
fields.forEach((field) => {
field.innerHTML = selected.length + (selected.length === 1 ? ' star' : ' stars')
})
}
This Pen doesn't use any external JavaScript resources.