Inputs
Rating
A star rating built on native radio buttons, with a read-only image form for showing averages.
- Built on
<input type="radio">:has()clip-path- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.4 kB
- Dependencies
- React
4.5 from 212 reviews
Try its options
Loading its options.
Code
import { Rating } from "@/components/equip/rating";
export default function Demo() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Rating label="Rate this release" defaultValue={4} />
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
<Rating label="Average rating" readOnly allowHalf size="s" defaultValue={4.5} />
<span>4.5 from 212 reviews</span>
</div>
</div>
);
}Your versionchange its options in Look, This part
Examples
Sizes
Small stars keep a 24px hit area each, so they stay easy to tap.
Code
import { Rating } from "@/components/equip/rating";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 24, alignItems: "flex-end" }}>
<Rating label="Support experience" size="s" defaultValue={3} />
<Rating label="Support experience" defaultValue={3} />
</div>
);
}Read-only scores
Each row is one image named like "Ease of setup: 4.5 out of 5", so a screen reader hears the score once.
- Ease of setup
- Documentation
- Value for money
Code
import { Rating } from "@/components/equip/rating";
const scores = [
{ label: "Ease of setup", value: 4.5 },
{ label: "Documentation", value: 3.8 },
{ label: "Value for money", value: 4 },
];
export default function Example() {
return (
<dl style={{ display: "grid", gridTemplateColumns: "auto auto", gap: "8px 16px", margin: 0, fontSize: 13 }}>
{scores.map((s) => (
<div key={s.label} style={{ display: "contents" }}>
<dt>{s.label}</dt>
<dd style={{ margin: 0 }}>
<Rating label={s.label} readOnly allowHalf size="s" defaultValue={s.value} />
</dd>
</div>
))}
</dl>
);
}Controlled, with a word
Choose the current star again, or press Space on it, to clear the rating.
Not rated
Code
"use client";
import { useState } from "react";
import { Rating } from "@/components/equip/rating";
const words = ["Not rated", "Poor", "Fair", "Good", "Very good", "Excellent"];
export default function Example() {
const [value, setValue] = useState(0);
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: 12 }}>
<Rating label="How was the onboarding call?" value={value} onValueChange={setValue} name="onboarding" />
<span style={{ fontSize: 13, lineHeight: "28px" }}>{words[value]}</span>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the group. Shown above the stars, or read as part of the image when read-only. |
value | number | none | Controlled value. 0 means not rated. |
defaultValue | number | 0 | Starting value when uncontrolled, or the value shown when read-only. |
onValueChange | (value: number) => void | none | Called with the new value, or 0 when the current star is chosen again. |
max | number | 5 | Number of stars. |
readOnly | boolean | false | Renders an image named like "4 out of 5" instead of a radio group. |
allowHalf | boolean | false | Read-only only. Rounds to the nearest half star, e.g. 4.5. |
name | string | none | Radio group name for forms. Defaults to a unique name per instance. |
size | "s" | "m" | "m" | Star size. Both keep at least a 24px target. |
...rest | Omit<ComponentProps<"div">, "defaultValue" | "onChange"> | none | Every native div attribute, applied to the root. |
Anatomy
| root | .eq-rating | role radiogroup, or role img when read-only. |
|---|---|---|
| label | .eq-rating-label | |
| stars | .eq-rating-stars | Holds the stars; hovering it previews a value. |
| star | .eq-rating-star | A label around one radio, or a span when read-only. |
| input | .eq-rating-input | The native radio, invisible and covering the star. |
| outline | .eq-rating-outline | The empty star. |
| fill | .eq-rating-fill | The filled star, clipped to none, half or all of it. |
Keyboard
| Tab | Moves to the chosen star, or the first one when none is chosen. |
|---|---|
| ArrowRightArrowUp | Chooses the next star. Provided by the browser. |
| ArrowLeftArrowDown | Chooses the previous star. Provided by the browser. |
| Space | Chooses the focused star, or clears the rating when it is already chosen. |
Notes
- Each star is a native radio named like "4 stars", so forms, required and screen reader group announcements work as usual.
- Choosing the current star again clears the rating to 0; the radio group has no other way to be emptied.
- Filled stars use the ink colour, not the accent, so a rating never competes with the primary action.
- Hover previews a value in a lighter ink; the arrow keys are the keyboard way to try values.
- The read-only form is one image whose name includes the label and the rounded value, so the stars are not read one by one.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/rating.tsx107 lines
"use client";
import { useId, useState } from "react";
import type { ComponentProps } from "react";
import "./rating.css";
export type RatingProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
/** Names the group. Visible when interactive; read as part of the image when read-only. */
label: string;
value?: number;
defaultValue?: number;
/** Called with the new value, or 0 when the current star is chosen again to clear it. */
onValueChange?: (value: number) => void;
max?: number;
/** Shows a value as an image instead of a radio group. */
readOnly?: boolean;
/** Read-only only: rounds to the nearest half star instead of the nearest whole one. */
allowHalf?: boolean;
/** Radio group name, for forms. Defaults to a unique name per instance. */
name?: string;
size?: "s" | "m";
};
function Star() {
return (
<>
<svg className="eq-rating-outline" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" aria-hidden="true">
<path d="m12 3.5 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.3-4.1 5.9-.9Z" />
</svg>
<svg className="eq-rating-fill" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" aria-hidden="true">
<path d="m12 3.5 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.3-4.1 5.9-.9Z" />
</svg>
</>
);
}
export function Rating({
label,
value,
defaultValue = 0,
onValueChange,
max = 5,
readOnly = false,
allowHalf = false,
name,
size = "m",
className,
...rest
}: RatingProps) {
const id = useId();
const [inner, setInner] = useState(defaultValue);
// The star just chosen, so only it pops. Set from the handler, never on first render.
const [popped, setPopped] = useState<number | null>(null);
const clamp = (n: number) => Math.max(0, Math.min(max, n));
const current = clamp(value ?? inner);
const stars = Array.from({ length: max }, (_, i) => i + 1);
const root = ["eq-rating", className].filter(Boolean).join(" ");
if (readOnly) {
const shown = allowHalf ? Math.round(current * 2) / 2 : Math.round(current);
return (
<div role="img" aria-label={`${label}: ${shown} out of ${max}`} {...rest} className={root} data-size={size} data-readonly="">
{stars.map((n) => (
<span key={n} className="eq-rating-star" data-fill={shown >= n ? "full" : shown >= n - 0.5 ? "half" : undefined}>
<Star />
</span>
))}
</div>
);
}
const choose = (n: number) => {
if (value === undefined) setInner(n);
setPopped(n || null);
onValueChange?.(n);
};
const group = name ?? `${id}-rating`;
const labelId = `${id}-label`;
return (
<div role="radiogroup" aria-labelledby={labelId} {...rest} className={root} data-size={size}>
<span className="eq-rating-label" id={labelId}>
{label}
</span>
<span className="eq-rating-stars">
{stars.map((n) => (
<label key={n} className="eq-rating-star" data-fill={current >= n ? "full" : undefined} data-popped={popped === n || undefined}>
<input
type="radio"
className="eq-rating-input"
name={group}
value={n}
checked={current === n}
aria-label={`${n} ${n === 1 ? "star" : "stars"}`}
onChange={() => choose(n)}
// A checked radio fires no change, so choosing the current value again clears it here.
onClick={() => {
if (current === n) choose(0);
}}
/>
<Star />
</label>
))}
</span>
</div>
);
}components/equip/rating.css129 lines
@layer equip {
.eq-rating {
--_icon: 22px;
--_hit: var(--eq-h-s);
display: inline-flex;
flex-direction: column;
gap: var(--eq-space-1);
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-rating[data-size="s"] {
--_icon: 16px;
--_hit: 24px;
}
.eq-rating[data-readonly] {
flex-direction: row;
align-items: center;
gap: 2px;
vertical-align: middle;
}
.eq-rating-label {
width: fit-content;
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-rating-stars {
display: flex;
/* The hit areas touch; the star sits inside each one with room to spare. */
margin-inline-start: calc((var(--_icon) - var(--_hit)) / 2);
}
.eq-rating-star {
/* How much of the filled star is hidden from the end: 100% none, 50% half, 0% full. */
--_clip: 100%;
--_fill: var(--eq-ink);
position: relative;
flex: none;
display: grid;
place-items: center;
width: var(--_hit);
height: var(--_hit);
border-radius: var(--eq-radius-s);
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-rating[data-readonly] .eq-rating-star {
width: var(--_icon);
height: var(--_icon);
}
.eq-rating-star[data-fill="full"] {
--_clip: 0%;
}
.eq-rating-star[data-fill="half"] {
--_clip: 50%;
}
.eq-rating-outline,
.eq-rating-fill {
grid-area: 1 / 1;
width: var(--_icon);
height: var(--_icon);
}
.eq-rating-outline {
color: var(--eq-line-strong);
}
.eq-rating-fill {
color: var(--_fill);
clip-path: inset(0 var(--_clip) 0 0);
transition:
clip-path var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease);
}
.eq-rating-star[data-popped] .eq-rating-fill {
animation: eq-rating-pop var(--eq-dur-move) var(--eq-spring);
}
@keyframes eq-rating-pop {
from {
scale: 0.5;
}
}
.eq-rating-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
appearance: none;
cursor: pointer;
}
/* Hovering previews the value the pointer would set, in a lighter ink. */
@media (hover: hover) {
.eq-rating-stars:hover .eq-rating-star {
--_clip: 100%;
}
.eq-rating-stars .eq-rating-star:is(:hover, :has(~ .eq-rating-star:hover)) {
--_clip: 0%;
--_fill: var(--eq-ink-2);
}
/* Stars already chosen keep full ink under the preview. */
.eq-rating-stars .eq-rating-star[data-fill="full"]:is(:hover, :has(~ .eq-rating-star:hover)) {
--_fill: var(--eq-ink);
}
}
.eq-rating-stars .eq-rating-star:active {
scale: var(--eq-press);
}
.eq-rating-star:has(.eq-rating-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
}