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>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* 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 the rating in the status belonging to the event's own rating instance:
// the one the user commits (`change`) and the one a change on the page moves it
// to (`reconcile`).
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);
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// a rating the page moved — a new `value`, a symbol added or removed, a clamp —
// is reported as `stimeo--rating:reconcile`. This demo reads both events, so the
// readout follows the user's moves and the page's.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSources = demoStage.querySelectorAll("[data-stimeo--rating-target='field']");
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll("[data-stimeo--rating-target='field']"))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--rating:reconcile", showSubmitted);
showSubmitted();
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
|
Requested rating (default 0). It is shown normalized to the live scale and left as written, so a clamped rating comes back once the scale allows it; a rating the user sets is written here. | 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. detail.reason reports user / focus / pointer / api. |
stimeo--rating:change |
reconcile
|
Fires once per batch when a change on the page side — a new value, symbols added or removed, clearable — moves the rating shown; { value } without reason. Not fired on connect. |
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). |