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):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/star-rating-radioUsage
<StarRatingRadio />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | The currently selected value (controlled). |
| onChange | (value: string) => void | — | Fired when the user selects a star. |
| 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" }, ] | Option definitions — defaults to five stars 1–5. |
| name | string | star-rating | Shared `name` attribute that groups the radio inputs. |
| disabled | boolean | false | When true, all inputs are disabled. |
| className | string | Additional class names merged onto the root element. |
Source
"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