<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')
    })
}

External CSS

  1. https://codepen.io/VAggrippino/pen/GRbeaGN.css

External JavaScript

This Pen doesn't use any external JavaScript resources.