Skip to content

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/End jump to extremes
  • 6 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 solid
  • Form-Associatedname attribute & native form reset support
  • Hover 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

PreviewCode
RTL

Sizes

PreviewCode
RTL

Custom Max

PreviewCode
RTL

Readonly

Use readonly to display a rating without allowing user interaction — useful for showing review scores.

PreviewCode
RTL

Solid Stars

Use solid to render selected stars as filled shapes instead of outline-only.

PreviewCode
RTL

Disabled

PreviewCode
RTL

API Reference

Attributes

AttributeTypeDefaultDescription
valuenumber0Current selected rating
maxnumber5Total number of stars
readonlybooleanfalsePrevents user interaction; shows value only
solidbooleanfalseFills selected stars instead of outline-only
disabledbooleanfalseDisables the rating input
labelstring'Rating'Accessible label for the rating group
namestringForm field name
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Star highlight color
size'sm' | 'md' | 'lg''md'Star size

Events

EventDetailDescription
change{ value: number; originalEvent?: Event }Fired when the user selects a rating

Parts

PartDescription
starsStars container element
starIndividual star button

CSS Custom Properties

PropertyDefaultDescription
--rating-star-sizevar(--size-7)Size of each star icon
--rating-color-emptyvar(--color-contrast-200)Color of unselected stars
--rating-color-filledvar(--color-warning) (themed)Color of selected / hovered stars
--rating-gapvar(--size-0_5)Gap between stars

Accessibility

The rating component follows WCAG 2.1 Level AA standards.

sg-rating

Keyboard Navigation
  • / arrow keys move and commit the selection.
  • Home / End jump to 1 / max; Tab moves focus in and out.
Screen Readers
  • The group uses role="radiogroup"; each star uses role="radio" with aria-checked reflecting the current selection.
  • The group aria-label comes from the label attribute (default: 'Rating').
  • aria-disabled reflects the disabled state; aria-readonly reflects the readonly state.
  • Hover previews stars visually without committing the value.
Forced Colors
  • In forced-colors environments unfilled stars use ButtonText and filled stars use Highlight, ensuring visible distinction without relying on color alone.
Reduced Motion
  • The sparkle particle animation is suppressed when prefers-reduced-motion: reduce is 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 label attribute so screen readers announce the context (e.g. "Product rating").
  • Use readonly rather than disabled when showing an existing score that the user cannot change — readonly keeps 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-select or sg-radio-group conveys options more clearly.
  • Omit the label attribute — an unlabelled rating group is inaccessible.
  • Slider — drag-based numeric value picker