Star Rating Without jQuery
Loading jQuery for a simple star widget is like renting a truck to deliver a letter. Alpine.js provides everything a complete, accessible rating widget needs with x-data, x-on and x-bind, in under 50 lines, without npm, without a build step, directly in the template.
Table of Contents
- 1. Why Alpine.js Instead of jQuery for a Rating Widget?
- 2. The Basic Structure: x-data and Reactive State
- 3. Hover State: Star Preview on Mouseover
- 4. Click Persistence: Saving and Displaying a Rating
- 5. Half Stars with SVG Clip and Alpine Logic
- 6. Accessibility: ARIA Attributes and Keyboard Control
- 7. Integration into Magento 2 and Hyvä Themes
- 8. Sending a Rating to an API via Fetch
- 9. Alpine.js vs. jQuery Rating: A Direct Comparison
- 10. Summary
- 11. FAQ
1. Why Alpine.js Instead of jQuery for a Rating Widget?
In many Magento projects you still find jQuery plugins like jQuery Star Rating or Raty.js, which bring along a full DOM manipulation layer just so five stars can react to clicks and hovers. The result is extra HTTP requests, global namespace pollution, and markup that gets generated in the DOM afterward by JavaScript, which makes server side rendering and SEO harder. Alpine.js solves the same problem declaratively: the state lives directly in the HTML attribute, no external package gets loaded, and the DOM stays fully readable on the server side.
The decisive advantage of Alpine.js for this use case is the x-data directive, which creates a locally scoped reactive data object. All of the widget's data, the current rating, the hover preview, and the loading state, lives inside this object. No global variables, no event delegation across the entire document body, no risk of conflicts with other widgets on the same page. Every rating widget on the page is fully isolated, even though they all use the same template.
For Hyvä Themes in Magento 2, Alpine.js is the native choice: Hyvä loads Alpine.js as the default JavaScript layer and excludes jQuery from the frontend stack entirely. Reloading jQuery just for a rating widget would deliberately undermine Hyvä's performance strategy. The Alpine native implementation fits seamlessly into Hyvä's existing event system and benefits from CSP compliant inline script registration.
2. The Basic Structure: x-data and Reactive State
The rating widget starts with an x-data object that holds all the required state variables: rating for the saved rating (initially 0), hoverRating for the preview on mouseover (initially 0), submitted as a boolean flag after submission, and loading for the API call state. This object is the single state container; Alpine.js automatically makes sure the DOM updates whenever any of these values change, without any manual DOM traversal.
The stars themselves are rendered with an x-for loop over an array [1, 2, 3, 4, 5]. Each star is a <button> element with x-on:click, x-on:mouseenter, and x-on:mouseleave. The active class, whether a star renders as filled or empty, is computed with x-bind:class: a star is active if its index is less than or equal to the currently displayed value, meaning i <= (hoverRating || rating). This single formula drives both the hover preview and the persisted rating.
// Alpine.js Rating Widget, minimal state, full reactivity
function ratingWidget() {
return {
rating: 0, // persisted selection
hoverRating: 0, // preview on hover
submitted: false,
loading: false,
stars: [1, 2, 3, 4, 5],
// Returns true if star i should render as filled
isActive(i) {
return i <= (this.hoverRating || this.rating);
},
setHover(i) { this.hoverRating = i; },
clearHover() { this.hoverRating = 0; },
select(i) {
this.rating = i;
this.$dispatch('rating-selected', { value: i });
}
};
}
3. Hover State: Star Preview on Mouseover
The hover state is the most visually prominent element of a rating widget, and technically the point where naive implementations become fragile. The typical jQuery pattern manipulates classes on all sibling elements; with five stars that means five separate DOM operations per mouse event. With Alpine.js the principle is reversed: instead of touching the DOM, only a single variable hoverRating changes. Alpine then calculates independently for each star element whether it gets the active class, declaratively instead of imperatively.
The x-on:mouseenter="setHover(i)" directive sets hoverRating to the value of the current star. x-on:mouseleave="clearHover()" resets it back to 0. The visual class is bound with :class="{ 'text-yellow-400': isActive(i), 'text-slate-300': !isActive(i) }". The result: when the user hovers over star 3, the widget immediately shows three yellow and two gray stars, without DOM traversal, without querySelectorAll, without a manual loop.
An important detail for correct UX: mouseleave needs to be registered on the container of the entire widget, not on each individual star. Otherwise the hover state flickers whenever the cursor sits between two stars and briefly touches neither of them. With x-on:mouseleave.self="clearHover()" on the container element, clearHover() only fires when the mouse pointer actually leaves the container area, not when moving between star buttons.
4. Click Persistence: Saving and Displaying a Rating
Saving a rating in Alpine.js means setting the rating variable and optionally persisting it in localStorage. The select(i) method sets this.rating = i and dispatches a custom event. For optional persistence in localStorage, useful when a rating should only be submitted once per session, a single line localStorage.setItem call inside the same handler is enough. When the widget initializes, init() reads the saved value back out and sets this.rating accordingly.
After submitting, the widget switches into a read only mode: the buttons get disabled with :disabled="submitted", the hover state no longer applies, and a confirmation message is shown with x-show="submitted". This state transition is fully declarative in Alpine.js: you set submitted to true, and every dependent directive, x-show, :disabled, :class, updates automatically. No manual DOM traversal required.
// Extended rating widget with localStorage persistence
function ratingWidget(productId) {
return {
rating: 0,
hoverRating: 0,
submitted: false,
loading: false,
stars: [1, 2, 3, 4, 5],
storageKey: `rating_${productId}`,
init() {
const saved = localStorage.getItem(this.storageKey);
if (saved) {
this.rating = parseInt(saved, 10);
this.submitted = true; // already rated this session
}
},
isActive(i) { return i <= (this.hoverRating || this.rating); },
setHover(i) { if (!this.submitted) this.hoverRating = i; },
clearHover() { this.hoverRating = 0; },
select(i) {
if (this.submitted) return;
this.rating = i;
localStorage.setItem(this.storageKey, i);
}
};
}
5. Half Stars with SVG Clip and Alpine Logic
Half stars look great for displaying an average value (for example 3.7 out of 5) and require a slightly extended Alpine logic. The implementation uses SVG stars with a <clipPath> element: each star consists of two overlaid SVG paths, a gray background and a yellow foreground. The foreground is revealed at 50% or 100% through a dynamic clip-path, depending on whether the displayed value contains a half or full rating for that star.
The Alpine helper function starFill(i, value) returns 'full', 'half', or 'empty': if value >= i the star is full, if value >= i - 0.5 it is half, otherwise empty. In the template this is used with :style="{ clipPath: starFill(i, displayValue) === 'half' ? 'inset(0 50% 0 0)' : 'none' }". Half stars are typically only used to display the average value; input stays limited to whole numbers, since users should not be able to submit a half star rating.
6. Accessibility: ARIA Attributes and Keyboard Control
An accessible rating widget needs ARIA attributes that communicate the current state to screen readers. Each star button gets :aria-label="`${i} of 5 stars`" and :aria-pressed="rating === i". The container gets role="radiogroup" and aria-label="Product rating". With these attributes, a screen reader announces how many stars are active while navigating between the buttons, without any visually visible text that would clutter the design.
Keyboard control is already built into native <button> elements: tab navigation, enter, and space trigger click. For arrow navigation within the star group, as ARIA recommends for radio groups, you add x-on:keydown.arrow-right.prevent="select(Math.min(rating + 1, 5))" and x-on:keydown.arrow-left.prevent="select(Math.max(rating - 1, 1))" on the container. Alpine.js supports keyboard modifiers natively, so no manual event listener registration is needed.
<!-- Accessible Alpine.js rating widget markup -->
<div
x-data="ratingWidget('prod-42')"
role="radiogroup"
aria-label="Product rating"
x-on:mouseleave.self="clearHover()"
x-on:keydown.arrow-right.prevent="select(Math.min(rating + 1, 5))"
x-on:keydown.arrow-left.prevent="select(Math.max(rating - 1, 1))"
class="flex items-center gap-1"
>
<template x-for="i in stars" :key="i">
<button
type="button"
x-on:click="select(i)"
x-on:mouseenter="setHover(i)"
:aria-label="`${i} of 5 stars`"
:aria-pressed="rating === i"
:disabled="submitted"
:class="isActive(i) ? 'text-yellow-400' : 'text-slate-300'"
class="text-2xl transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500 rounded"
>★</button>
</template>
<span x-show="submitted" x-cloak class="ml-3 text-sm text-teal-700 font-semibold">Thank you!</span>
</div>
7. Integration into Magento 2 and Hyvä Themes
In Hyvä Themes, the rating widget is included as a .phtml template. The Alpine script is placed as an inline script at the end of the template and registered for the Content Security Policy with $hyvaCsp->registerInlineScript(). The widget itself lives in the template markup and uses the Alpine.js instance already provided by Hyvä; no additional <script src> is needed. The product ID is interpolated into the x-data attribute as a PHP variable: x-data="ratingWidget('<?= $escaper->escapeHtmlAttr($productId) ?>')".
To display existing ratings from Magento data, you pass the average value as a PHP variable and render the read only widget with a separate template block. The write mode (input) is only visible to logged in customers, controlled through $customerSession->isLoggedIn() in the block view model. This separation between read and write mode keeps the template clean and allows different caching strategies for each variant.
8. Sending a Rating to an API via Fetch
When the user selects a star and clicks "Submit rating," the submit() method sends the data to a REST endpoint via fetch. During the API call, loading is set to true, the template shows a spinner and disables the submit button to prevent double submissions. On success, submitted = true is set, which moves the entire widget into the read only state. On an error, an error message is written to errorMessage, which is displayed with x-show="errorMessage".
Alpine.js does not ship a built in HTTP client like $fetch; the native browser fetch API is used directly. That is not a drawback: the native fetch API is available in every modern browser, needs no additional polyfill, and returns promises that work smoothly inside async/await methods of Alpine components. The Magento REST endpoint for product reviews is /rest/V1/reviews and expects a JSON object with productId, rating, and optionally nickname.
// Submit method with fetch, loading state and error handling
async submit() {
if (!this.rating || this.submitted || this.loading) return;
this.loading = true;
this.errorMessage = '';
try {
const response = await fetch('/rest/V1/reviews', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
productId: this.productId,
rating: this.rating,
nickname: this.nickname || 'Anonymous'
})
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
this.submitted = true;
localStorage.setItem(this.storageKey, this.rating);
this.$dispatch('rating-submitted', { productId: this.productId, rating: this.rating });
} catch (err) {
this.errorMessage = 'Rating could not be saved. Please try again.';
console.error('[RatingWidget]', err);
} finally {
this.loading = false;
}
}
9. Alpine.js vs. jQuery Rating: A Direct Comparison
Comparing a jQuery based and an Alpine.js based rating implementation shows clear differences in code volume, DOM dependency, and maintainability. Both approaches achieve the same visual result but differ fundamentally in their architecture.
| Criterion | jQuery Rating (Raty.js) | Alpine.js Rating | Result |
|---|---|---|---|
| Dependencies | jQuery + plugin (~90 KB gzip) | Alpine.js (~15 KB gzip) | Alpine is 6x smaller |
| DOM Generation | JS generates markup at runtime | Markup fully rendered server side | Alpine is more SEO friendly |
| Multiple Widgets | Global selectors, conflicts possible | Isolated x-data scope | Alpine is conflict free |
| Accessibility | Must be added manually | ARIA native in directives | Alpine is more maintainable |
| Hyvä Compatibility | Requires reloading jQuery | Native, no extra script | Alpine is recommended |
The difference in DOM generation is especially relevant. jQuery based rating plugins replace a simple input element with generated DOM code; stars, images, or SVGs get inserted at runtime. This makes server side rendering and crawling harder and prevents CSS from applying correctly to the initial render state. Alpine.js, by contrast, renders the full markup in the template and only activates the reactive bindings, so the initial render state is always correct, even without JavaScript.
Mironsoft
Alpine.js frontend development for Hyvä Themes and Magento 2
Alpine.js widgets for your Magento shop?
We build performant, accessible Alpine.js components for Hyvä Themes: rating widgets, product configurators, filters and more. No jQuery, no build step overhead, fully CSP compliant.
Widget Development
Rating, gallery, configurator: Alpine.js components without any jQuery dependency
jQuery Migration
Migrate existing jQuery plugins to Alpine.js and improve performance
Hyvä Integration
Seamless, CSP compliant integration into your existing Hyvä theme structure
10. Summary
An Alpine.js rating widget without jQuery is not a compromise, it is an improvement over jQuery based plugins. The state lives entirely in x-data, the DOM stays fully server rendered, and ARIA attributes make the widget accessible to every user. Hover state, click persistence, localStorage integration, and API connectivity fit into under 80 lines of JavaScript, with no external dependencies, no build step, and native browser fetch.
For Hyvä Themes projects in Magento 2, Alpine.js is the only sensible choice: it is already loaded, it can be used in a CSP compliant way, and it resolves the contradiction between a jQuery free frontend and jQuery dependent widgets. The pattern presented here, an isolated x-data scope, declarative bindings, async/await for API calls, transfers directly to other interactive elements and forms a solid foundation for an entire Alpine.js component stack.
Alpine.js Rating Widget: The Essentials at a Glance
State Management
Everything lives in x-data: rating, hoverRating, submitted, loading. No global state, no namespace conflicts with multiple widgets on one page.
Hover Without DOM Traversal
Change just one variable, hoverRating, and Alpine automatically computes the correct CSS class for every star. No querySelectorAll, no manual loop.
Accessibility
role="radiogroup", aria-pressed, aria-label via x-bind, and arrow key navigation via x-on:keydown, all declarative right in the template.
Hyvä & CSP
Alpine.js ships natively with Hyvä. Register inline scripts with $hyvaCsp->registerInlineScript(). No jQuery to reload, no performance loss.