Rating
A star-based rating input that lets users select a score. Supports hover preview, keyboard navigation, readonly and disabled modes, and HTML form integration.
Features
Keyboard Navigation — ←/→arrows adjust value;Home/Endjump to extremes6 Semantic Colors — primary, secondary, info, success, warning, error 3 Sizes — sm, md, lg Readonly & Disabled — readonly shows a non-interactive score; disabled removes from tab order Solid Fill Mode — selected stars can render as solid-filled via solidForm-Associated — nameattribute & native formresetsupportHover Preview — stars fill on hover before selection is committed
Source Code
View Source Code
ts
import { define, useField, html, inject, prop } from '@vielzeug/craft';
import { computed, signal } from '@vielzeug/ripple';
import type { ComponentSize, ThemeColor } from '../../types';
import { createSliderControl } from '../../headless';
import '../../content/icon/icon';
import { disablableBundle, sizableBundle, themableBundle } from '../../shared';
import { coarsePointerMixin, colorThemeMixin, reducedMotionMixin, sizeVariantMixin } from '../../styles';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import styles from './rating.css?inline';
export type SgRatingEvents = {
change: { originalEvent?: Event; value: number };
};
/** Rating props */
export type SgRatingProps = {
/** Theme color */
color?: ThemeColor;
/** Disable interaction */
disabled?: boolean;
/** Accessible group label */
label?: string;
/** Maximum rating (number of stars) */
max?: number;
/** Form field name */
name?: string;
/** Make rating read-only */
readonly?: boolean;
/** Component size */
size?: ComponentSize;
/** Render selected stars as solid-filled instead of outline-only */
solid?: boolean;
/** Current rating value */
value?: number;
};
/**
* A star rating input.
*
* @element sg-rating
*
* @attr {number} value - Current rating value (default: 0)
* @attr {number} max - Maximum number of stars (default: 5)
* @attr {boolean} readonly - Read-only display mode
* @attr {boolean} disabled - Disabled state
* @attr {string} label - aria-label for the group (default: 'Rating')
* @attr {string} color - Theme color for filled stars: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {string} size - 'sm' | 'md' | 'lg'
* @attr {string} name - Form field name
* @attr {boolean} solid - Fill selected stars (outline remains default when omitted)
*
* @fires change - Emitted when value changes. detail: { value: number, originalEvent?: Event }
*
* @cssprop --rating-star-size - Star diameter
* @cssprop --rating-color-empty - Empty star color
* @cssprop --rating-color-filled - Filled star color
* @cssprop --rating-gap - Gap between stars
*
* @part stars - Stars container.
* @part star - Star item element.
* @example
* ```html
* <sg-rating value="3" max="5" color="warning"></sg-rating>
* <sg-rating value="4" solid></sg-rating>
* ```
*/
export const RATING_TAG = 'sg-rating' as const;
define<SgRatingProps, SgRatingEvents>(RATING_TAG, {
formAssociated: true,
props: {
...themableBundle,
...sizableBundle,
...disablableBundle,
label: prop.string('Rating'),
max: prop.number(5),
name: prop.string(),
readonly: prop.bool(false),
solid: prop.bool(false),
value: prop.number(0),
},
setup(props, { bind, el, emit }) {
const formCtx = inject(FORM_CTX);
const fCtxProps = useFormContext(bind, props, formCtx);
const normalizedValue = computed(() => {
const max = Math.max(1, Number(props.max!.value) || 5);
const raw = Number(props.value!.value);
const safe = Number.isFinite(raw) ? raw : 0;
return Math.min(max, Math.max(0, safe));
});
const fd = useField({
disabled: fCtxProps.disabled,
value: computed(() => String(normalizedValue.value || 0)),
});
const triggerValidation = (on: 'blur' | 'change') => {
if (formCtx?.validateOn?.value === on) {
fd.reportValidity();
}
};
const isInteractive = computed(() => !props.readonly!.value && !fCtxProps.disabled.value);
const hovered = signal<number | null>(null);
const displayValue = computed(() => hovered.value ?? normalizedValue.value);
const getStarButtons = () => {
return [...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>('[data-star]') ?? [])];
};
const ratingControl = createSliderControl({
max: computed(() => Number(props.max!.value) || 5),
min: signal(1),
step: signal(1),
});
function spawnSparkles(star: number) {
const layer = el.shadowRoot?.querySelector<HTMLElement>('.sparkle-layer');
const btn = el.shadowRoot?.querySelector<HTMLElement>(`[data-star="${star}"]`);
if (!layer || !btn) return;
const cx = btn.offsetLeft + btn.offsetWidth / 2;
const cy = btn.offsetTop + btn.offsetHeight / 2;
const count = 10;
for (let i = 0; i < count; i++) {
const p = document.createElement('span');
const angle = (360 / count) * i + (Math.random() * 30 - 15);
const dist = 18 + Math.random() * 20;
const size = 3 + Math.random() * 4;
const dur = 380 + Math.random() * 220;
p.className = 'sparkle';
p.style.cssText = [
`left:${cx}px`,
`top:${cy}px`,
`--_angle:${angle}deg`,
`--_dist:${dist}px`,
`width:${size}px`,
`height:${size}px`,
`--_dur:${dur}ms`,
`animation-delay:${Math.random() * 60}ms`,
].join(';');
layer.appendChild(p);
p.addEventListener('animationend', () => p.remove(), { once: true });
}
}
function select(star: number, originalEvent?: Event) {
if (!isInteractive.value) return;
const max = Math.max(1, Number(props.max!.value) || 5);
const nextValue = Math.min(max, Math.max(0, star));
if (nextValue === normalizedValue.value) return;
// Write through the host attribute; craft handles host reflection.
el.setAttribute('value', String(nextValue));
emit('change', { originalEvent, value: nextValue });
triggerValidation('change');
spawnSparkles(nextValue);
}
function handleKeydown(e: KeyboardEvent, star: number) {
const next = ratingControl.nextFromKey(e.key, star);
if (next == null) return;
e.preventDefault();
select(next, e);
const buttons = getStarButtons();
buttons[next - 1]?.focus();
}
const stars = computed(() => {
const max = Number(props.max!.value) || 5;
return Array.from({ length: max }, (_, i) => i + 1);
});
bind({ attr: { size: fCtxProps.size } });
return html`
<div class="stars" part="stars" role="radiogroup" :aria-label="${props.label}" :aria-required="${() => null}">
${() =>
stars.value.map(
(star) =>
html`<button
class="star-btn"
part="star"
type="button"
role="radio"
:aria-label="${() => `${star} ${star === 1 ? 'star' : 'stars'}`}"
:aria-checked="${() => String(star === normalizedValue.value)}"
:data-star="${star}"
?data-filled="${() => star <= displayValue.value}"
:disabled="${() => (!isInteractive.value ? true : null)}"
@click="${(e: Event) => select(star, e)}"
@pointerenter="${() => {
if (isInteractive.value) hovered.value = star;
}}"
@pointerleave="${() => {
hovered.value = null;
}}"
@keydown="${(e: KeyboardEvent) => handleKeydown(e, star)}">
<sg-icon name="star" size="var(--_star-size)" stroke-width="1.5" aria-hidden="true"></sg-icon>
</button>`,
)}
<div class="sparkle-layer"></div>
</div>
`;
},
styles: [colorThemeMixin, sizeVariantMixin({}), coarsePointerMixin, reducedMotionMixin, styles],
});Basic Usage
html
<sg-rating label="Product rating" value="3"></sg-rating>Listen for changes:
html
<sg-rating id="rating" label="Rate this article" color="warning"></sg-rating>
<script type="module">
document.getElementById('rating').addEventListener('change', (e) => {
console.log('Rating:', e.detail.value);
});
</script>Colors
Sizes
Custom Max
Readonly
Use readonly to display a rating without allowing user interaction — useful for showing review scores.
Solid Stars
Use solid to render selected stars as filled shapes instead of outline-only.
Disabled
API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | number | 0 | Current selected rating |
max | number | 5 | Total number of stars |
readonly | boolean | false | Prevents user interaction; shows value only |
solid | boolean | false | Fills selected stars instead of outline-only |
disabled | boolean | false | Disables the rating input |
label | string | 'Rating' | Accessible label for the rating group |
name | string | — | Form field name |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Star highlight color |
size | 'sm' | 'md' | 'lg' | 'md' | Star size |
Events
| Event | Detail | Description |
|---|---|---|
change | { value: number; originalEvent?: Event } | Fired when the user selects a rating |
Parts
| Part | Description |
|---|---|
stars | Stars container element |
star | Individual star button |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--rating-star-size | var(--size-7) | Size of each star icon |
--rating-color-empty | var(--color-contrast-200) | Color of unselected stars |
--rating-color-filled | var(--color-warning) (themed) | Color of selected / hovered stars |
--rating-gap | var(--size-0_5) | Gap between stars |
Accessibility
The rating component follows WCAG 2.1 Level AA standards.
sg-rating
←/→arrow keys move and commit the selection.Home/Endjump to 1 / max;Tabmoves focus in and out.
- The group uses
role="radiogroup"; each star usesrole="radio"witharia-checkedreflecting the current selection. - The group
aria-labelcomes from thelabelattribute (default:'Rating'). aria-disabledreflects the disabled state;aria-readonlyreflects the readonly state.- Hover previews stars visually without committing the value.
- In
forced-colorsenvironments unfilled stars useButtonTextand filled stars useHighlight, ensuring visible distinction without relying on color alone.
- The sparkle particle animation is suppressed when
prefers-reduced-motion: reduceis active.
Sparkle Effect
When a user selects a star, a burst of particle sparks radiates from the chosen star. The animation uses the current filled color and respects prefers-reduced-motion — particles are hidden entirely when the user has requested reduced motion.
Best Practices
Do:
- Always provide a
labelattribute so screen readers announce the context (e.g."Product rating"). - Use
readonlyrather thandisabledwhen showing an existing score that the user cannot change —readonlykeeps the element accessible in the reading order. - Use colour together with label text to reinforce meaning (e.g.
color="warning"for a gold star aesthetic).
Don't:
- Use rating for non-numeric preference input — a
sg-selectorsg-radio-groupconveys options more clearly. - Omit the
labelattribute — an unlabelled rating group is inaccessible.
Related Components
- Slider — drag-based numeric value picker