Zoeken...  ⌘K GitHub

Rating UI Elements

Ster-rating display of interactief. Half-sterren, review count, klikbaar input mode.

/rating
src/components/ui/Rating.astro
---
/**
 * Rating
 * Ster-rating: display-only of interactief (input).
 * Half-sterren optioneel voor display mode.
 */
interface Props {
  value?: number;        /** 0-5 */
  max?: number;
  interactive?: boolean;
  name?: string;
  size?: 'sm' | 'md' | 'lg';
  showValue?: boolean;
  label?: string;
  count?: number;        /** aantal reviews (tonen naast sterren) */
}

const {
  value = 0,
  max = 5,
  interactive = false,
  name = 'rating',
  size = 'md',
  showValue = false,
  label,
  count,
} = Astro.props;
---

{interactive ? (
  <!-- Interactive (input) -->
  <div
    class:list={['rt', `rt--${size}`, 'rt--interactive']}
    data-component="rating"
    data-value={value}
  >
    {label && <span class="rt__label">{label}</span>}
    <div class="rt__stars" role="radiogroup" aria-label={label ?? 'Beoordeling'}>
      {Array.from({ length: max }).map((_, i) => (
        <label class="rt__star-label" title={`${i + 1} ster${i > 0 ? 'ren' : ''}`}>
          <input
            type="radio"
            name={name}
            value={i + 1}
            class="rt__input"
            checked={i + 1 === Math.round(value)}
          />
          <svg class="rt__star" viewBox="0 0 24 24" fill="none" stroke="currentColor">
            <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
          </svg>
        </label>
      ))}
    </div>
    {showValue && <span class="rt__value">{value}/{max}</span>}
  </div>
) : (
  <!-- Display only -->
  <div class:list={['rt', `rt--${size}`]} data-component="rating" data-value={value} aria-label={`${value} van ${max} sterren`}>
    {label && <span class="rt__label">{label}</span>}
    <div class="rt__stars-display">
      {Array.from({ length: max }).map((_, i) => {
        const filled = value >= i + 1;
        const half = !filled && value > i;
        return (
          <svg class:list={['rt__star', { 'rt__star--filled': filled, 'rt__star--half': half }]} viewBox="0 0 24 24">
            <defs>
              <linearGradient id={`half-${i}`}>
                <stop offset="50%" stop-color="currentColor"/>
                <stop offset="50%" stop-color="transparent"/>
              </linearGradient>
            </defs>
            <path
              d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
              fill={filled ? 'currentColor' : half ? `url(#half-${i})` : 'none'}
              stroke="currentColor"
              stroke-width="1.5"
            />
          </svg>
        );
      })}
    </div>
    <div class="rt__meta">
      {showValue && <span class="rt__value">{value}</span>}
      {count !== undefined && <span class="rt__count">({count.toLocaleString('nl-NL')})</span>}
    </div>
  </div>
)}

<script>
  document.querySelectorAll<HTMLElement>('[data-component="rating"].rt--interactive').forEach(el => {
    const stars = el.querySelectorAll<HTMLLabelElement>('.rt__star-label');
    const inputs = el.querySelectorAll<HTMLInputElement>('.rt__input');

    function updateVisual(hoveredIndex: number | null) {
      const activeVal = hoveredIndex !== null
        ? hoveredIndex
        : Number(el.dataset.value);

      stars.forEach((label, i) => {
        const star = label.querySelector<SVGElement>('.rt__star');
        if (!star) return;
        star.classList.toggle('rt__star--filled', i < activeVal);
      });
    }

    stars.forEach((label, i) => {
      label.addEventListener('mouseenter', () => updateVisual(i + 1));
      label.addEventListener('mouseleave', () => updateVisual(null));
    });

    inputs.forEach((input, i) => {
      input.addEventListener('change', () => {
        el.dataset.value = String(i + 1);
        updateVisual(null);
      });
    });

    updateVisual(null);
  });
</script>

<style>
  .rt { display: inline-flex; flex-direction: column; gap: 0.375rem; }

  .rt__label {
    font-size: 0.875rem;
    font-weight: 600;
    color: var(--color-primary, #0a0a0a);
  }

  /* Stars container */
  .rt__stars, .rt__stars-display {
    display: flex;
    gap: 0.25rem;
    align-items: center;
  }

  .rt__meta {
    display: flex;
    align-items: center;
    gap: 0.375rem;
  }

  .rt__value {
    font-size: 0.875rem;
    font-weight: 700;
    color: var(--color-primary, #0a0a0a);
  }

  .rt__count {
    font-size: 0.8125rem;
    color: var(--color-muted, #6b7280);
  }

  /* Star SVG */
  .rt__star {
    color: rgba(0,0,0,0.15);
    transition: color 0.1s, transform 0.1s;
    flex-shrink: 0;
  }

  .rt--sm .rt__star { width: 14px; height: 14px; }
  .rt--md .rt__star { width: 20px; height: 20px; }
  .rt--lg .rt__star { width: 28px; height: 28px; }

  .rt__star--filled { color: #f59e0b; }
  .rt__star--half  { color: #f59e0b; }

  /* Interactive */
  .rt--interactive .rt__star-label {
    cursor: pointer;
    display: flex;
  }

  .rt--interactive .rt__input {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
  }

  .rt--interactive .rt__star-label:hover .rt__star,
  .rt--interactive .rt__star-label:hover ~ .rt__star-label .rt__star {
    color: #fcd34d;
    transform: scale(1.1);
  }
</style>

Props

Prop Type Default Beschrijving
value number 0 Rating waarde (0 - max)
max number 5 Maximum sterren
interactive boolean false Input mode — klikbaar
showValue boolean false Toon numerieke waarde
count number Review count naast sterren
size 'sm' | 'md' | 'lg' 'md' Grootte
name string 'rating' Form name voor interactieve mode

* = verplicht