Behive Tech Registry

Rating Stars

v0.0.0

A read-only star rating. Fractional values are drawn with a clipped fill rather than rounded to a half, so 4.3 and 4.7 look different. The stars are decorative — the value is announced once through an `aria-label`.

pnpm add @acme/atoms.rating-stars

registry/acme/atoms/rating-stars · depends on @behivetech/get-class-name

rating-stars.tsx
1import { type HTMLAttributes } from "react";
2import { getClassName } from "@behivetech/get-class-name";
3import styles from "./rating-stars.module.scss";
4
5export type RatingStarsSize = "sm" | "md" | "lg";
6
7export interface RatingStarsProps extends Omit<HTMLAttributes<HTMLSpanElement>, "children"> {
8 /** The rating to show, from 0 to `max`; fractions render as partially filled stars */
9 value: number;
10 /** Number of stars; defaults to 5 */
11 max?: number;
12 /** Optional review count shown after the stars, e.g. "(128)" */
13 count?: number;
14 /** Controls the star size; defaults to md */
15 size?: RatingStarsSize;
16 /** Additional class names to merge with the component root element */
17 className?: string;
18}
19
20/**
21 * A read-only star rating.
22 *
23 * Fractional values are drawn with a clipped fill rather than rounded to a
24 * half, so 4.3 and 4.7 look different. The stars are decorative; the value is
25 * announced once through the root's `aria-label`.
26 */
27export const RatingStars = ({ value, max = 5, count, size = "md", className, ...rest }: RatingStarsProps) => {
28 const [rootClass, getChildClass] = getClassName({
29 className,
30 rootClass: "ACME__RatingStars",
31 modifiers: { [size]: true },
32 styles,
33 });
34
35 const clamped = Math.min(Math.max(value, 0), max);
36 const label = `${clamped.toFixed(1).replace(/\.0$/, "")} out of ${max} stars${count !== undefined ? `, ${count} reviews` : ""}`;
37
38 return (
39 <span {...rest} className={rootClass} role="img" aria-label={label}>
40 <span className={getChildClass("stars")} aria-hidden>
41 {Array.from({ length: max }, (_, index) => {
42 const fill = Math.min(Math.max(clamped - index, 0), 1);
43 return (
44 <span
45 key={index}
46 className={getChildClass("star")}
47 // The one value CSS cannot know: how much of THIS star is filled.
48 style={{ "--acme-star-fill": `${fill * 100}%` } as React.CSSProperties}
49 >
50 ★
51 </span>
52 );
53 })}
54 </span>
55 {count !== undefined && <span className={getChildClass("count")}>({count.toLocaleString()})</span>}
56 </span>
57 );
58};
rating-stars.module.scss
1.ACME__RatingStars {
2 display: inline-flex;
3 align-items: center;
4 gap: 0.4em;
5 font-family: var(--md-sys-typescale-body-font);
6 color: var(--md-sys-color-on-surface-variant);
7
8 &--sm {
9 font-size: 0.875rem;
10 }
11
12 &--md {
13 font-size: 1.125rem;
14 }
15
16 &--lg {
17 font-size: 1.5rem;
18 }
19
20 &__stars {
21 display: inline-flex;
22 gap: 0.1em;
23 line-height: 1;
24 }
25
26 &__star {
27 // The outline star, with the filled star painted over the left
28 // `--acme-star-fill` percent of it. One glyph, two colors, no images.
29 position: relative;
30 color: var(--md-sys-color-outline-variant);
31
32 &::before {
33 content: "★";
34 position: absolute;
35 inset: 0;
36 width: var(--acme-star-fill, 0%);
37 overflow: hidden;
38 color: var(--md-sys-color-tertiary);
39 }
40 }
41
42 &__count {
43 font-size: 0.8em;
44 }
45}
index.ts
1export { RatingStars } from './rating-stars.js';
2export type { RatingStarsProps } from './rating-stars.js';
rating-stars.composition.tsx
1import { RatingStars } from "./rating-stars.js";
2
3export const BasicRatingStars = () => <RatingStars value={4} />;
4
5export const FractionalRatingStars = () => <RatingStars value={4.3} count={128} />;
6
7export const TenStarRating = () => <RatingStars value={7.5} max={10} />;
8
9export const RatingStarsSizes = () => (
10 <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
11 <RatingStars size="sm" value={3.5} count={12} />
12 <RatingStars size="md" value={3.5} count={12} />
13 <RatingStars size="lg" value={3.5} count={12} />
14 </div>
15);
rating-stars.spec.tsx
1import { render, screen } from "@testing-library/react";
2import { RatingStars } from "./rating-stars.js";
3
4describe("RatingStars", () => {
5 it("announces the rating once, as text, rather than star by star", () => {
6 render(<RatingStars value={4.3} count={128} />);
7 expect(screen.getByRole("img", { name: "4.3 out of 5 stars, 128 reviews" })).toBeTruthy();
8 });
9
10 // Each star carries its fill on a style variable; that's the stable handle,
11 // since CSS-module class names are hashed under test.
12 const starsOf = (container: HTMLElement) =>
13 container.querySelectorAll<HTMLElement>('[style*="--acme-star-fill"]');
14
15 it("renders one star per point of max", () => {
16 const { container } = render(<RatingStars value={7.5} max={10} />);
17 expect(starsOf(container)).toHaveLength(10);
18 });
19
20 it("fills a fractional star by its fraction", () => {
21 const { container } = render(<RatingStars value={2.25} />);
22 const stars = starsOf(container);
23 expect(stars[1]?.style.getPropertyValue("--acme-star-fill")).toBe("100%");
24 expect(stars[2]?.style.getPropertyValue("--acme-star-fill")).toBe("25%");
25 expect(stars[3]?.style.getPropertyValue("--acme-star-fill")).toBe("0%");
26 });
27
28 it("clamps out-of-range values", () => {
29 render(<RatingStars value={9} />);
30 expect(screen.getByRole("img", { name: "5 out of 5 stars" })).toBeTruthy();
31 });
32});