bottega

primitives

Star Rating Radio

A five-star (or custom n-star) rating group built on native visually-hidden `<input type="radio">` elements sharing a `name` attribute. Each star label renders a FontAwesome solid-star SVG that fills with `var(--primary)` and emits a token-tinted drop-shadow glow on hover and when selected. Hovering a star triggers a CSS pulse scale loop and a shimmer glow animation, plus a pair of decorative particle dot spans that burst in above and below the star via `particle-explosion` keyframes. The selected-and-below fill cascade is managed via a `labelFilled` className applied in JSX. Fully controlled (`value` + `onChange`) or uncontrolled; supports `disabled`, `name`, and a typed `options` array for custom labels and values. Focus-visible keyboard ring uses `var(--ring)` on the label adjacent to the focused input.

Ported from elijahgummer (MIT)


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/star-rating-radio

Usage

Usagetsx
<StarRatingRadio />

Props

PropTypeDefaultDescription
valuestringThe currently selected value (controlled).
onChange(value: string) => voidFired when the user selects a star.
optionsStarRatingOption[][ { value: "1", label: "1 star" }, { value: "2", label: "2 stars" }, { value: "3", label: "3 stars" }, { value: "4", label: "4 stars" }, { value: "5", label: "5 stars" }, ]Option definitions — defaults to five stars 1–5.
namestringstar-ratingShared `name` attribute that groups the radio inputs.
disabledbooleanfalseWhen true, all inputs are disabled.
classNamestringAdditional class names merged onto the root element.

Source

star-rating-radio.tsxtsx
"use client";

import { useState } from "react";
import type React from "react";
import styles from "./star-rating-radio.module.css";

/** A single option in the rating group. */
export type StarRatingOption = {
  value: string;
  label: string;
};

const DEFAULT_OPTIONS: StarRatingOption[] = [
  { value: "1", label: "1 star" },
  { value: "2", label: "2 stars" },
  { value: "3", label: "3 stars" },
  { value: "4", label: "4 stars" },
  { value: "5", label: "5 stars" },
];

/** FontAwesome solid star path (576×512 viewBox). */
const STAR_PATH =
  "M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z";

export type StarRatingRadioProps = {
  /** The currently selected value (controlled). */
  value?: string;
  /** Fired when the user selects a star. */
  onChange?: (value: string) => void;
  /** Option definitions — defaults to five stars 1–5. */
  options?: StarRatingOption[];
  /** Shared `name` attribute that groups the radio inputs. */
  name?: string;
  /** When true, all inputs are disabled. */
  disabled?: boolean;
  /** Additional class names merged onto the root element. */
  className?: string;
};

export function StarRatingRadio({
  value,
  onChange,
  options = DEFAULT_OPTIONS,
  name = "star-rating",
  disabled = false,
  className = "",
}: StarRatingRadioProps) {
  // Internal state for uncontrolled usage; ignored when `value` prop is provided.
  const [internalValue, setInternalValue] = useState<string>("");
  const selected = value !== undefined ? value : internalValue;

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const next = e.currentTarget.value;
    if (value === undefined) setInternalValue(next);
    onChange?.(next);
  }

  const rootClass = [styles.root, className].filter(Boolean).join(" ");

  return (
    <div className={rootClass} role="radiogroup">
      {options.map((opt) => {
        const id = `${name}-${opt.value}`;
        const isChecked = selected === opt.value;
        // Index of option in array (0-based) and selected option index
        const optIdx = options.findIndex((o) => o.value === opt.value);
        const selIdx = options.findIndex((o) => o.value === selected);
        // Highlight all stars up to and including the selected/hovered star
        // via CSS sibling selectors; we only need data attributes for the hover-fill cascade.
        // The CSS :checked + label ~ label pattern handles fill cascade.
        return (
          <span key={opt.value} className={styles.item}>
            {/* Visually-hidden but fully functional native radio */}
            <input
              type="radio"
              id={id}
              name={name}
              value={opt.value}
              checked={isChecked}
              disabled={disabled}
              onChange={handleChange}
              className={styles.input}
              aria-label={opt.label}
            />
            <label
              htmlFor={id}
              title={opt.label}
              className={[
                styles.label,
                // Mark stars that should be filled (selected index or below)
                selIdx >= 0 && optIdx <= selIdx ? styles.labelFilled : "",
              ]
                .filter(Boolean)
                .join(" ")}
            >
              {/* Decorative particle dots — aria-hidden */}
              <span aria-hidden="true" className={styles.particleTop} />
              <span aria-hidden="true" className={styles.particleBottom} />
              <svg
                xmlns="http://www.w3.org/2000/svg"
                height="1em"
                viewBox="0 0 576 512"
                aria-hidden="true"
                focusable="false"
                className={styles.star}
              >
                <path d={STAR_PATH} />
              </svg>
            </label>
          </span>
        );
      })}
    </div>
  );
}

Dependencies

  • @bottega/tokens