Rating
stimeo--rating
A star rating stepped with the arrow keys, stopping at the ends, and clearable.
Chooses a level out of symbols such as stars, and how many are chosen is announced. The arrow keys step it, but stop at the ends rather than wrapping, because jumping from five to one on a scale would be disorienting. Hovering or focusing previews the fill up to that point. You can change your mind freely, and pressing the star you already chose clears it. It can also be rendered as a read-only display. The value is mirrored into a hidden field so your form submits it as usual. The stars themselves are drawn by this demo's CSS.
Keyboard
| Key | Action |
|---|---|
| → / ↑ | Raise the rating by one (stops at the max). Under RTL only the horizontal pair swaps. |
| ← / ↓ | Lower the rating by one (down to 0 when clearable). Under RTL only the horizontal pair swaps. |
| Home / End | Jump to the minimum / maximum. |
| Space / Enter | Select the focused symbol (no change if it is already the value). |
| Delete / Backspace | Clear the rating back to 0 when clearable. |
<%# Markup for the rating (APG Radio Group, ordinal scale) demo.
Each symbol is role="radio" with an aria-label ("3 stars", etc.). Selection is
aria-checked; the currently filled range is data-rating-hover (the selected value or
a hover/focus preview). DOM order defines values 1..N. The star look is the consumer's CSS. %>
<div class="rating-demo-wrap">
<p class="rating-demo__hint"><%= t("components.rating.demo.hint") %></p>
<div class="rating-demo" role="radiogroup" aria-label="<%= t('components.rating.demo.label') %>"
data-controller="stimeo--rating"
data-stimeo--rating-value-value="0">
<% (1..5).each do |n| %>
<span class="rating-demo__star" role="radio" aria-checked="false"
aria-label="<%= t('components.rating.demo.star', count: n) %>"
tabindex="<%= n == 1 ? 0 : -1 %>"
data-stimeo--rating-target="symbol"
data-action="click->stimeo--rating#select
mouseenter->stimeo--rating#preview
mouseleave->stimeo--rating#endPreview
focus->stimeo--rating#preview
blur->stimeo--rating#endPreview
keydown->stimeo--rating#onKeydown">
<svg class="rating-demo__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2l2.9 6.26 6.86.6-5.2 4.52 1.56 6.72L12 17.1 5.88 20.6l1.56-6.72-5.2-4.52
6.86-.6z"></path>
</svg>
</span>
<% end %>
<input type="hidden" name="rating" value="0" data-stimeo--rating-target="field" />
<%# Subscribe to committed and reconciled values to update this instance's status. %>
<span class="rating-demo__status" aria-live="polite"
data-empty-text="<%= t('components.rating.demo.empty') %>"
data-rated-template="<%= t('components.rating.demo.rated') %>"><%= t(
'components.rating.demo.empty'
) %></span>
</div>
</div>
/*
* Presentation-only styles for the rating demo.
*
* Two fills, because two things are being shown. [data-rating-hover] marks the
* range the controller is currently painting — which is the committed value at
* rest, but the pointer or focus position while previewing. [aria-checked] marks
* the value that is actually committed. Painting both the same way would make a
* star look chosen as soon as it is focused, and Space would appear to do
* nothing; the preview is therefore drawn at reduced strength.
*/
.rating-demo-wrap {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.rating-demo__hint {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.rating-demo {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.rating-demo__star {
display: inline-flex;
padding: 0.125rem;
border-radius: 0.25rem;
cursor: pointer;
line-height: 0;
}
.rating-demo__star:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.rating-demo__icon {
width: 1.75rem;
height: 1.75rem;
fill: var(--slate-300);
transition:
fill 0.12s ease,
fill-opacity 0.12s ease;
}
/* Preview: the range the pointer or focus is over, not yet committed. */
.rating-demo__star[data-rating-hover] .rating-demo__icon {
fill: var(--amber-500);
fill-opacity: 0.4;
}
/* Committed: the checked symbol and every symbol before it in the scale. */
.rating-demo__star:has(~ .rating-demo__star[aria-checked="true"]) .rating-demo__icon,
.rating-demo__star[aria-checked="true"] .rating-demo__icon {
fill: var(--amber-500);
fill-opacity: 1;
}
.rating-demo__status {
margin-left: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
// Shows committed and controller-reconciled values in the status belonging to
// the event's own rating instance.
function updateRatingStatus(event) {
const rating = event.target instanceof Element ? event.target.closest('.rating-demo') : null;
const status = rating?.querySelector('.rating-demo__status');
if (!status) return;
const value = event.detail.value;
if (value === 0) {
status.textContent = status.dataset.emptyText || '';
} else {
status.textContent = (status.dataset.ratedTemplate || '').replace('{n}', String(value));
}
}
document.addEventListener('stimeo--rating:change', updateRatingStatus);
document.addEventListener('stimeo--rating:reconcile', updateRatingStatus);
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--rating"
Targets
| Name | Description | Attribute |
|---|---|---|
symbol
required
|
A rating symbol (role=radio); live DOM order defines its 1-based ordinal. |
data-stimeo--rating-target="symbol" |
field
|
Optional hidden input mirroring the current numeric value. | data-stimeo--rating-target="field" |
Values
| Name | Description | Attribute |
|---|---|---|
value
|
Current rating (default 0). | data-stimeo--rating-value-value |
clearable
|
When true (default), re-clicking the selected symbol clears to 0 and min becomes 0. | data-stimeo--rating-clearable-value |
readonly
|
When true, renders a non-interactive role=img snapshot (default false). |
data-stimeo--rating-readonly-value |
Actions
| Name | Description | Action |
|---|---|---|
endPreview
|
Restores the fill range to the selected value (on mouseleave/blur). | stimeo--rating#endPreview |
onKeydown
|
Arrow/Home/End/Space-Enter keyboard control, clamped without wrapping. | stimeo--rating#onKeydown |
preview
|
Previews a fill range on hover/focus via data-rating-hover. |
stimeo--rating#preview |
select
|
Selects the clicked symbol, or clears to 0 when clearable and already selected. | stimeo--rating#select |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched when a user operation changes the committed rating, with the new value in detail. | stimeo--rating:change |
reconcile
|
Fires when DOM or configuration reconciliation clamps the committed value; same detail as change. | stimeo--rating:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-checked |
Symbol | "true" on the selected symbol only. |
data-rating-hover |
Symbol | Present on symbols within the shown fill range (selection or preview). |
tabindex |
Symbol | 0 on the selected (or first) symbol, -1 on the rest (roving). |