Six star rating pieces covering both jobs a rating does: showing a score and collecting one. For display there is a quarter-precision star row that clips a filled layer over an empty one, and a review summary that works out the average from raw counts and lets shoppers filter by level. For input there is a star picker on native radio buttons with hover preview, a half-star picker with two hit areas per star, a version that needs no JavaScript and uses the :has() selector, and a dark numbered score picker built as a custom radio group. The read-only row and the CSS version also come as plain HTML. Every target is at least 44px, and the layouts stay inside a 360px screen.
import { Fragment, useEffect, useRef, useState, type KeyboardEvent, type ReactElement } from "react";
/*
Every piece below is written once: the function renders the live demo and the
string next to it is that same source file, so the code you copy is the code you see.
*/
type Lang = "react" | "html";
type Piece = {
id: string;
name: string;
blurb: string;
group: string;
Demo: () => ReactElement;
react: string;
html?: string;
stage: string;
};
const s1Demo = (() => {
const STAR = "M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z";
const items = [
{ name: "Walnut serving board", rating: 4.3, reviews: 218 },
{ name: "Enamel camping mug", rating: 3.75, reviews: 64 },
{ name: "Wool picnic blanket", rating: 2.5, reviews: 12 },
];
// Rounds to the nearest quarter star, then clips a row of filled stars to that width.
function Stars({ value, size = "h-5 w-5" }: { value: number; size?: string }) {
const shown = Math.round(value * 4) / 4;
const row = (cls: string) =>
Array.from({ length: 5 }, (_, i) => (
<svg key={i} viewBox="0 0 24 24" aria-hidden="true" className={`${size} shrink-0 ${cls}`}>
<path d={STAR} />
</svg>
));
return (
<span role="img" aria-label={`Rated ${value} out of 5`} className="relative inline-flex">
<span className="flex">{row("fill-stone-200")}</span>
<span className="absolute inset-y-0 left-0 flex overflow-hidden" style={{ width: `${(shown / 5) * 100}%` }}>
{row("fill-amber-500")}
</span>
</span>
);
}
function StarReadOnly() {
return (
<ul className="w-full max-w-xs divide-y divide-stone-200">
{items.map((it) => (
<li key={it.name} className="py-3 first:pt-0 last:pb-0">
<p className="text-sm font-semibold text-stone-900">{it.name}</p>
<div className="mt-1 flex items-center gap-2">
<Stars value={it.rating} />
<span className="text-sm tabular-nums text-stone-700">{it.rating}</span>
<a href="#" className="rounded text-sm text-stone-600 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600">
{it.reviews} reviews
</a>
</div>
</li>
))}
</ul>
);
}
return StarReadOnly;
})();
const s1React = "const STAR = \"M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z\";\n\nconst items = [\n { name: \"Walnut serving board\", rating: 4.3, reviews: 218 },\n { name: \"Enamel camping mug\", rating: 3.75, reviews: 64 },\n { name: \"Wool picnic blanket\", rating: 2.5, reviews: 12 },\n];\n\n// Rounds to the nearest quarter star, then clips a row of filled stars to that width.\nfunction Stars({ value, size = \"h-5 w-5\" }: { value: number; size?: string }) {\n const shown = Math.round(value * 4) / 4;\n const row = (cls: string) =>\n Array.from({ length: 5 }, (_, i) => (\n <svg key={i} viewBox=\"0 0 24 24\" aria-hidden=\"true\" className={`${size} shrink-0 ${cls}`}>\n <path d={STAR} />\n </svg>\n ));\n return (\n <span role=\"img\" aria-label={`Rated ${value} out of 5`} className=\"relative inline-flex\">\n <span className=\"flex\">{row(\"fill-stone-200\")}</span>\n <span className=\"absolute inset-y-0 left-0 flex overflow-hidden\" style={{ width: `${(shown / 5) * 100}%` }}>\n {row(\"fill-amber-500\")}\n </span>\n </span>\n );\n}\n\nexport default function StarReadOnly() {\n return (\n <ul className=\"w-full max-w-xs divide-y divide-stone-200\">\n {items.map((it) => (\n <li key={it.name} className=\"py-3 first:pt-0 last:pb-0\">\n <p className=\"text-sm font-semibold text-stone-900\">{it.name}</p>\n <div className=\"mt-1 flex items-center gap-2\">\n <Stars value={it.rating} />\n <span className=\"text-sm tabular-nums text-stone-700\">{it.rating}</span>\n <a href=\"#\" className=\"rounded text-sm text-stone-600 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600\">\n {it.reviews} reviews\n </a>\n </div>\n </li>\n ))}\n </ul>\n );\n}\n";
const s1Html = `<!-- Set --rating on each element. Partial stars come from the gradient stop. -->
<p class="stars" style="--rating: 4.3" role="img" aria-label="Rated 4.3 out of 5"></p>
<p class="stars" style="--rating: 3.75" role="img" aria-label="Rated 3.75 out of 5"></p>
<style>
.stars {
--size: 20px;
--on: #f59e0b;
--off: #e7e5e4;
margin: 4px 0;
width: calc(var(--size) * 5);
height: var(--size);
background: linear-gradient(90deg, var(--on) calc(var(--rating) / 5 * 100%), var(--off) 0);
/* One star shape, repeated five times, used as a mask. */
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z'/%3E%3C/svg%3E") 0 0 / var(--size) var(--size) repeat-x;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z'/%3E%3C/svg%3E") 0 0 / var(--size) var(--size) repeat-x;
}
</style>
`;
const s5Demo = (() => {
const STAR = "M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z";
// Review counts per star level, 5 down to 1. The average and percentages are worked out from these.
const COUNTS: Record<number, number> = { 5: 146, 4: 41, 3: 17, 2: 8, 1: 6 };
function ReviewSummaryBars() {
const [filter, setFilter] = useState<number | null>(null);
const total = Object.values(COUNTS).reduce((a, b) => a + b, 0);
const average = Object.entries(COUNTS).reduce((s, [k, v]) => s + Number(k) * v, 0) / total;
const avg = Math.round(average * 10) / 10;
return (
<div className="flex w-full max-w-sm flex-col gap-4 sm:flex-row sm:items-start">
<div className="shrink-0">
<p className="text-4xl font-semibold tracking-tight text-stone-900">{avg.toFixed(1)}</p>
<span role="img" aria-label={`Average ${avg} out of 5`} className="mt-1 flex">
{[1, 2, 3, 4, 5].map((n) => (
<svg key={n} viewBox="0 0 24 24" aria-hidden="true" className={`h-4 w-4 ${n <= Math.round(avg) ? "fill-amber-500" : "fill-stone-200"}`}>
<path d={STAR} />
</svg>
))}
</span>
<p className="mt-1 text-xs text-stone-600">{total} reviews</p>
</div>
<ul className="flex-1 space-y-0.5">
{[5, 4, 3, 2, 1].map((n) => {
const pct = Math.round((COUNTS[n] / total) * 100);
const on = filter === n;
return (
<li key={n}>
<button
type="button"
aria-pressed={on}
aria-label={`${n} stars: ${COUNTS[n]} reviews, ${pct} percent. Show only these`}
onClick={() => setFilter(on ? null : n)}
className={`flex h-10 w-full items-center gap-2 rounded-md px-1.5 text-xs text-stone-700 hover:bg-stone-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600 ${on ? "bg-amber-50" : ""}`}
>
<span className="w-3 tabular-nums">{n}</span>
<span className="h-2 flex-1 overflow-hidden rounded-full bg-stone-200">
<span className="block h-full rounded-full bg-amber-500" style={{ width: `${pct}%` }} />
</span>
<span className="w-8 text-right tabular-nums">{pct}%</span>
</button>
</li>
);
})}
</ul>
<p role="status" className="sr-only">
{filter ? `Showing ${COUNTS[filter]} ${filter}-star reviews` : ""}
</p>
</div>
);
}
return ReviewSummaryBars;
})();
const s5React = "import { useState } from \"react\";\n\nconst STAR = \"M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z\";\n// Review counts per star level, 5 down to 1. The average and percentages are worked out from these.\nconst COUNTS: Record<number, number> = { 5: 146, 4: 41, 3: 17, 2: 8, 1: 6 };\n\nexport default function ReviewSummaryBars() {\n const [filter, setFilter] = useState<number | null>(null);\n const total = Object.values(COUNTS).reduce((a, b) => a + b, 0);\n const average = Object.entries(COUNTS).reduce((s, [k, v]) => s + Number(k) * v, 0) / total;\n const avg = Math.round(average * 10) / 10;\n\n return (\n <div className=\"flex w-full max-w-sm flex-col gap-4 sm:flex-row sm:items-start\">\n <div className=\"shrink-0\">\n <p className=\"text-4xl font-semibold tracking-tight text-stone-900\">{avg.toFixed(1)}</p>\n <span role=\"img\" aria-label={`Average ${avg} out of 5`} className=\"mt-1 flex\">\n {[1, 2, 3, 4, 5].map((n) => (\n <svg key={n} viewBox=\"0 0 24 24\" aria-hidden=\"true\" className={`h-4 w-4 ${n <= Math.round(avg) ? \"fill-amber-500\" : \"fill-stone-200\"}`}>\n <path d={STAR} />\n </svg>\n ))}\n </span>\n <p className=\"mt-1 text-xs text-stone-600\">{total} reviews</p>\n </div>\n <ul className=\"flex-1 space-y-0.5\">\n {[5, 4, 3, 2, 1].map((n) => {\n const pct = Math.round((COUNTS[n] / total) * 100);\n const on = filter === n;\n return (\n <li key={n}>\n <button\n type=\"button\"\n aria-pressed={on}\n aria-label={`${n} stars: ${COUNTS[n]} reviews, ${pct} percent. Show only these`}\n onClick={() => setFilter(on ? null : n)}\n className={`flex h-10 w-full items-center gap-2 rounded-md px-1.5 text-xs text-stone-700 hover:bg-stone-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600 ${on ? \"bg-amber-50\" : \"\"}`}\n >\n <span className=\"w-3 tabular-nums\">{n}</span>\n <span className=\"h-2 flex-1 overflow-hidden rounded-full bg-stone-200\">\n <span className=\"block h-full rounded-full bg-amber-500\" style={{ width: `${pct}%` }} />\n </span>\n <span className=\"w-8 text-right tabular-nums\">{pct}%</span>\n </button>\n </li>\n );\n })}\n </ul>\n <p role=\"status\" className=\"sr-only\">\n {filter ? `Showing ${COUNTS[filter]} ${filter}-star reviews` : \"\"}\n </p>\n </div>\n );\n}\n";
const s2Demo = (() => {
const STAR = "M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z";
const WORDS = ["Poor", "Fair", "Good", "Very good", "Excellent"];
function StarRadioInput() {
const [value, setValue] = useState(4);
const [hover, setHover] = useState(0);
const lit = hover || value;
return (
<fieldset className="w-full max-w-xs">
<legend className="text-sm font-semibold text-stone-900">How was your stay?</legend>
<div className="mt-2 flex" onMouseLeave={() => setHover(0)}>
{WORDS.map((word, i) => {
const n = i + 1;
return (
<span key={n}>
<input
id={`stay-${n}`}
type="radio"
name="stay"
value={n}
checked={value === n}
onChange={() => setValue(n)}
className="peer sr-only"
/>
<label
htmlFor={`stay-${n}`}
onMouseEnter={() => setHover(n)}
className="grid h-11 w-11 cursor-pointer place-items-center rounded-lg peer-focus-visible:ring-2 peer-focus-visible:ring-amber-600"
>
<span className="sr-only">
{n} {n === 1 ? "star" : "stars"}, {word}
</span>
<svg viewBox="0 0 24 24" aria-hidden="true" className={`h-8 w-8 transition-colors ${n <= lit ? "fill-amber-500" : "fill-stone-200"}`}>
<path d={STAR} />
</svg>
</label>
</span>
);
})}
</div>
<p className="mt-1 text-sm text-stone-600" aria-hidden="true">
{WORDS[lit - 1]}
</p>
</fieldset>
);
}
return StarRadioInput;
})();
const s2React = "import { useState } from \"react\";\n\nconst STAR = \"M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z\";\nconst WORDS = [\"Poor\", \"Fair\", \"Good\", \"Very good\", \"Excellent\"];\n\nexport default function StarRadioInput() {\n const [value, setValue] = useState(4);\n const [hover, setHover] = useState(0);\n const lit = hover || value;\n\n return (\n <fieldset className=\"w-full max-w-xs\">\n <legend className=\"text-sm font-semibold text-stone-900\">How was your stay?</legend>\n <div className=\"mt-2 flex\" onMouseLeave={() => setHover(0)}>\n {WORDS.map((word, i) => {\n const n = i + 1;\n return (\n <span key={n}>\n <input\n id={`stay-${n}`}\n type=\"radio\"\n name=\"stay\"\n value={n}\n checked={value === n}\n onChange={() => setValue(n)}\n className=\"peer sr-only\"\n />\n <label\n htmlFor={`stay-${n}`}\n onMouseEnter={() => setHover(n)}\n className=\"grid h-11 w-11 cursor-pointer place-items-center rounded-lg peer-focus-visible:ring-2 peer-focus-visible:ring-amber-600\"\n >\n <span className=\"sr-only\">\n {n} {n === 1 ? \"star\" : \"stars\"}, {word}\n </span>\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" className={`h-8 w-8 transition-colors ${n <= lit ? \"fill-amber-500\" : \"fill-stone-200\"}`}>\n <path d={STAR} />\n </svg>\n </label>\n </span>\n );\n })}\n </div>\n <p className=\"mt-1 text-sm text-stone-600\" aria-hidden=\"true\">\n {WORDS[lit - 1]}\n </p>\n </fieldset>\n );\n}\n";
const s3Demo = (() => {
const STAR = "M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z";
function StarHalfInput() {
const [value, setValue] = useState(3.5);
const [hover, setHover] = useState(0);
const lit = hover || value;
return (
<fieldset className="w-full max-w-xs">
<legend className="text-sm font-semibold text-stone-900">Rate this recipe</legend>
<div className="mt-2 flex items-center gap-3">
<div className="flex" onMouseLeave={() => setHover(0)}>
{[1, 2, 3, 4, 5].map((star) => {
const fill = Math.max(0, Math.min(1, lit - (star - 1)));
return (
<div key={star} className="relative h-12 w-12">
<svg viewBox="0 0 24 24" aria-hidden="true" className="absolute inset-0 h-full w-full fill-stone-200">
<path d={STAR} />
</svg>
<div className="absolute inset-y-0 left-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-12 w-12 fill-rose-500">
<path d={STAR} />
</svg>
</div>
{[star - 0.5, star].map((v, half) => (
<span key={v}>
<input id={`recipe-${v}`} type="radio" name="recipe" value={v} checked={value === v} onChange={() => setValue(v)} className="peer sr-only" />
<label
htmlFor={`recipe-${v}`}
onMouseEnter={() => setHover(v)}
className={`absolute inset-y-0 w-1/2 cursor-pointer rounded-md peer-focus-visible:ring-2 peer-focus-visible:ring-rose-600 ${half ? "right-0" : "left-0"}`}
>
<span className="sr-only">{v} stars</span>
</label>
</span>
))}
</div>
);
})}
</div>
<span className="text-sm font-semibold tabular-nums text-stone-900" aria-hidden="true">
{lit.toFixed(1)}
</span>
</div>
<p className="mt-1 text-xs text-stone-600">Click the left half of a star for a half point.</p>
</fieldset>
);
}
return StarHalfInput;
})();
const s3React = "import { useState } from \"react\";\n\nconst STAR = \"M12 2.6l2.8 6 6.5.7-4.9 4.4 1.4 6.4L12 16.8l-5.8 3.3 1.4-6.4-4.9-4.4 6.5-.7z\";\n\nexport default function StarHalfInput() {\n const [value, setValue] = useState(3.5);\n const [hover, setHover] = useState(0);\n const lit = hover || value;\n\n return (\n <fieldset className=\"w-full max-w-xs\">\n <legend className=\"text-sm font-semibold text-stone-900\">Rate this recipe</legend>\n <div className=\"mt-2 flex items-center gap-3\">\n <div className=\"flex\" onMouseLeave={() => setHover(0)}>\n {[1, 2, 3, 4, 5].map((star) => {\n const fill = Math.max(0, Math.min(1, lit - (star - 1)));\n return (\n <div key={star} className=\"relative h-12 w-12\">\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" className=\"absolute inset-0 h-full w-full fill-stone-200\">\n <path d={STAR} />\n </svg>\n <div className=\"absolute inset-y-0 left-0 overflow-hidden\" style={{ width: `${fill * 100}%` }}>\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" className=\"h-12 w-12 fill-rose-500\">\n <path d={STAR} />\n </svg>\n </div>\n {[star - 0.5, star].map((v, half) => (\n <span key={v}>\n <input id={`recipe-${v}`} type=\"radio\" name=\"recipe\" value={v} checked={value === v} onChange={() => setValue(v)} className=\"peer sr-only\" />\n <label\n htmlFor={`recipe-${v}`}\n onMouseEnter={() => setHover(v)}\n className={`absolute inset-y-0 w-1/2 cursor-pointer rounded-md peer-focus-visible:ring-2 peer-focus-visible:ring-rose-600 ${half ? \"right-0\" : \"left-0\"}`}\n >\n <span className=\"sr-only\">{v} stars</span>\n </label>\n </span>\n ))}\n </div>\n );\n })}\n </div>\n <span className=\"text-sm font-semibold tabular-nums text-stone-900\" aria-hidden=\"true\">\n {lit.toFixed(1)}\n </span>\n </div>\n <p className=\"mt-1 text-xs text-stone-600\">Click the left half of a star for a half point.</p>\n </fieldset>\n );\n}\n";
const s4Demo = (() => {
// Pure CSS: no state at all. Inputs and labels must be direct siblings for ~ and :has() to work.
const css = `
.css-rate { border: 0; margin: 0; padding: 0; font-family: system-ui, sans-serif; }
.css-rate legend { font-size: 14px; font-weight: 600; color: #1c1917; }
.css-rate-stars { display: flex; margin-top: 8px; }
.css-rate-stars input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.css-rate-stars label { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 8px; cursor: pointer; }
.css-rate-stars label::before {
content: ""; width: 30px; height: 30px; background: #d6d3d1;
clip-path: polygon(50% 4%, 61% 37%, 96% 37%, 68% 58%, 79% 92%, 50% 71%, 21% 92%, 32% 58%, 4% 37%, 39% 37%);
transition: background-color .15s;
}
.css-rate-stars input:checked + label::before,
.css-rate-stars label:has(~ input:checked)::before,
.css-rate-stars label:hover::before,
.css-rate-stars label:has(~ label:hover)::before { background: #0d9488; }
.css-rate-stars input:focus-visible + label { outline: 2px solid #0f766e; outline-offset: 1px; }
.css-rate-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .css-rate-stars label::before { transition: none; } }
`;
function StarPureCss() {
return (
<fieldset className="css-rate">
<style>{css}</style>
<legend>Rate the delivery</legend>
<div className="css-rate-stars">
{[1, 2, 3, 4, 5].map((n) => (
<Fragment key={n}>
<input type="radio" id={`deliv-${n}`} name="delivery" value={n} defaultChecked={n === 3} />
<label htmlFor={`deliv-${n}`}>
<span className="css-rate-sr">{n} {n === 1 ? "star" : "stars"}</span>
</label>
</Fragment>
))}
</div>
</fieldset>
);
}
return StarPureCss;
})();
const s4React = "import { Fragment } from \"react\";\n\n// Pure CSS: no state at all. Inputs and labels must be direct siblings for ~ and :has() to work.\nconst css = `\n.css-rate { border: 0; margin: 0; padding: 0; font-family: system-ui, sans-serif; }\n.css-rate legend { font-size: 14px; font-weight: 600; color: #1c1917; }\n.css-rate-stars { display: flex; margin-top: 8px; }\n.css-rate-stars input { position: absolute; opacity: 0; width: 1px; height: 1px; }\n.css-rate-stars label { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 8px; cursor: pointer; }\n.css-rate-stars label::before {\n content: \"\"; width: 30px; height: 30px; background: #d6d3d1;\n clip-path: polygon(50% 4%, 61% 37%, 96% 37%, 68% 58%, 79% 92%, 50% 71%, 21% 92%, 32% 58%, 4% 37%, 39% 37%);\n transition: background-color .15s;\n}\n.css-rate-stars input:checked + label::before,\n.css-rate-stars label:has(~ input:checked)::before,\n.css-rate-stars label:hover::before,\n.css-rate-stars label:has(~ label:hover)::before { background: #0d9488; }\n.css-rate-stars input:focus-visible + label { outline: 2px solid #0f766e; outline-offset: 1px; }\n.css-rate-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .css-rate-stars label::before { transition: none; } }\n`;\n\nexport default function StarPureCss() {\n return (\n <fieldset className=\"css-rate\">\n <style>{css}</style>\n <legend>Rate the delivery</legend>\n <div className=\"css-rate-stars\">\n {[1, 2, 3, 4, 5].map((n) => (\n <Fragment key={n}>\n <input type=\"radio\" id={`deliv-${n}`} name=\"delivery\" value={n} defaultChecked={n === 3} />\n <label htmlFor={`deliv-${n}`}>\n <span className=\"css-rate-sr\">{n} {n === 1 ? \"star\" : \"stars\"}</span>\n </label>\n </Fragment>\n ))}\n </div>\n </fieldset>\n );\n}\n";
const s4Html = `<fieldset class="css-rate">
<legend>Rate the delivery</legend>
<div class="css-rate-stars">
<input type="radio" id="deliv-1" name="delivery" value="1"><label for="deliv-1"><span class="css-rate-sr">1 star</span></label>
<input type="radio" id="deliv-2" name="delivery" value="2"><label for="deliv-2"><span class="css-rate-sr">2 stars</span></label>
<input type="radio" id="deliv-3" name="delivery" value="3" checked><label for="deliv-3"><span class="css-rate-sr">3 stars</span></label>
<input type="radio" id="deliv-4" name="delivery" value="4"><label for="deliv-4"><span class="css-rate-sr">4 stars</span></label>
<input type="radio" id="deliv-5" name="delivery" value="5"><label for="deliv-5"><span class="css-rate-sr">5 stars</span></label>
</div>
</fieldset>
<style>
.css-rate { border: 0; margin: 0; padding: 0; font-family: system-ui, sans-serif; }
.css-rate legend { font-size: 14px; font-weight: 600; color: #1c1917; }
.css-rate-stars { display: flex; margin-top: 8px; }
.css-rate-stars input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.css-rate-stars label { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 8px; cursor: pointer; }
.css-rate-stars label::before {
content: ""; width: 30px; height: 30px; background: #d6d3d1;
clip-path: polygon(50% 4%, 61% 37%, 96% 37%, 68% 58%, 79% 92%, 50% 71%, 21% 92%, 32% 58%, 4% 37%, 39% 37%);
transition: background-color .15s;
}
.css-rate-stars input:checked + label::before,
.css-rate-stars label:has(~ input:checked)::before,
.css-rate-stars label:hover::before,
.css-rate-stars label:has(~ label:hover)::before { background: #0d9488; }
.css-rate-stars input:focus-visible + label { outline: 2px solid #0f766e; outline-offset: 1px; }
.css-rate-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .css-rate-stars label::before { transition: none; } }
</style>
`;
const s6Demo = (() => {
const LABELS = ["Not useful", "Slightly useful", "Useful", "Very useful", "Essential"];
// Custom radio group with roving tabindex, following the WAI-ARIA radio group pattern.
function StarRovingTiles() {
const [value, setValue] = useState(4);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const focusable = value ? value - 1 : 0;
function choose(i: number) {
setValue(i + 1);
refs.current[i]?.focus();
}
function onKeyDown(i: number, e: KeyboardEvent<HTMLButtonElement>) {
const last = LABELS.length - 1;
const next =
e.key === "ArrowRight" || e.key === "ArrowDown" ? (i === last ? 0 : i + 1)
: e.key === "ArrowLeft" || e.key === "ArrowUp" ? (i === 0 ? last : i - 1)
: null;
if (next === null) return;
e.preventDefault();
choose(next);
}
return (
<div className="w-full max-w-xs rounded-xl bg-stone-900 p-4 text-stone-100">
<p id="guide-q" className="text-sm font-semibold">
Was this guide useful?
</p>
<div role="radiogroup" aria-labelledby="guide-q" className="mt-3 grid grid-cols-5 gap-1.5">
{LABELS.map((label, i) => {
const checked = value === i + 1;
const lit = i < value;
return (
<button
key={label}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-label={`${i + 1} of 5, ${label}`}
tabIndex={i === focusable ? 0 : -1}
onClick={() => choose(i)}
onKeyDown={(e) => onKeyDown(i, e)}
className={`flex h-12 flex-col items-center justify-center rounded-lg text-xs font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 ${
lit ? "bg-amber-400 text-stone-950" : "bg-stone-800 text-stone-300 hover:bg-stone-700"
}`}
>
{i + 1}
</button>
);
})}
</div>
<div className="mt-3 flex min-h-[40px] items-center justify-between">
<p className="text-sm text-stone-300">{value ? LABELS[value - 1] : "Pick a score from 1 to 5"}</p>
{value > 0 && (
<button type="button" onClick={() => setValue(0)} className="h-10 rounded-md px-3 text-sm text-stone-200 hover:bg-stone-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300">
Clear
</button>
)}
</div>
</div>
);
}
return StarRovingTiles;
})();
const s6React = "import { useRef, useState, type KeyboardEvent } from \"react\";\n\nconst LABELS = [\"Not useful\", \"Slightly useful\", \"Useful\", \"Very useful\", \"Essential\"];\n\n// Custom radio group with roving tabindex, following the WAI-ARIA radio group pattern.\nexport default function StarRovingTiles() {\n const [value, setValue] = useState(4);\n const refs = useRef<(HTMLButtonElement | null)[]>([]);\n const focusable = value ? value - 1 : 0;\n\n function choose(i: number) {\n setValue(i + 1);\n refs.current[i]?.focus();\n }\n\n function onKeyDown(i: number, e: KeyboardEvent<HTMLButtonElement>) {\n const last = LABELS.length - 1;\n const next =\n e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? (i === last ? 0 : i + 1)\n : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? (i === 0 ? last : i - 1)\n : null;\n if (next === null) return;\n e.preventDefault();\n choose(next);\n }\n\n return (\n <div className=\"w-full max-w-xs rounded-xl bg-stone-900 p-4 text-stone-100\">\n <p id=\"guide-q\" className=\"text-sm font-semibold\">\n Was this guide useful?\n </p>\n <div role=\"radiogroup\" aria-labelledby=\"guide-q\" className=\"mt-3 grid grid-cols-5 gap-1.5\">\n {LABELS.map((label, i) => {\n const checked = value === i + 1;\n const lit = i < value;\n return (\n <button\n key={label}\n ref={(el) => {\n refs.current[i] = el;\n }}\n type=\"button\"\n role=\"radio\"\n aria-checked={checked}\n aria-label={`${i + 1} of 5, ${label}`}\n tabIndex={i === focusable ? 0 : -1}\n onClick={() => choose(i)}\n onKeyDown={(e) => onKeyDown(i, e)}\n className={`flex h-12 flex-col items-center justify-center rounded-lg text-xs font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 ${\n lit ? \"bg-amber-400 text-stone-950\" : \"bg-stone-800 text-stone-300 hover:bg-stone-700\"\n }`}\n >\n {i + 1}\n </button>\n );\n })}\n </div>\n <div className=\"mt-3 flex min-h-[40px] items-center justify-between\">\n <p className=\"text-sm text-stone-300\">{value ? LABELS[value - 1] : \"Pick a score from 1 to 5\"}</p>\n {value > 0 && (\n <button type=\"button\" onClick={() => setValue(0)} className=\"h-10 rounded-md px-3 text-sm text-stone-200 hover:bg-stone-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300\">\n Clear\n </button>\n )}\n </div>\n </div>\n );\n}\n";
const PIECES: Piece[] = [
{ id: "s1", name: "Read-only score, quarter-star fill", blurb: "A clipped row of filled stars over an empty row, labelled once as an image.", group: "display", Demo: s1Demo, react: s1React, html: s1Html, stage: "bg-[#faf8f4] ring-1 ring-inset ring-stone-200/80" },
{ id: "s5", name: "Review summary with bars", blurb: "Average worked out from the counts, with bars that filter by star level.", group: "display", Demo: s5Demo, react: s5React, stage: "bg-[#faf8f4] ring-1 ring-inset ring-stone-200/80" },
{ id: "s2", name: "Star input on native radios", blurb: "Hover preview, a word for each level and real radio keyboard behaviour.", group: "input", Demo: s2Demo, react: s2React, stage: "bg-[#faf8f4] ring-1 ring-inset ring-stone-200/80" },
{ id: "s3", name: "Half-star input", blurb: "Two hit areas per star give scores from 0.5 to 5.", group: "input", Demo: s3Demo, react: s3React, stage: "bg-[#faf8f4] ring-1 ring-inset ring-stone-200/80" },
{ id: "s4", name: "Pure CSS radio rating", blurb: "No state or script. Radios in their natural order coloured with :has().", group: "input", Demo: s4Demo, react: s4React, html: s4Html, stage: "bg-[#faf8f4] ring-1 ring-inset ring-stone-200/80" },
{ id: "s6", name: "Numbered score tiles", blurb: "A custom radio group with roving focus and a clear button.", group: "input", Demo: s6Demo, react: s6React, stage: "bg-[#faf8f4] ring-1 ring-inset ring-stone-200/80" }
];
const GROUPS: { key: string; label: string }[] = [{"key":"display","label":"Display only"},{"key":"input","label":"Rating inputs"}];
const TEXT: { eyebrow: string; title: string; lead: string; refTitle: string; refNote: string } = {"eyebrow":"React · pure CSS · HTML","title":"Star ratings you can read and set","lead":"Six rating pieces: a read-only score with quarter-star fill, a review summary with filter bars, a radio-based star input, a half-star input, a version with no script at all and a numbered score picker.","refTitle":"Which markup to use","refNote":"Roles and keys follow the radio group pattern in the WAI-ARIA Authoring Practices Guide. Support for :has() and CSS masks is per MDN's compatibility data."};
const REF_HEAD: string[] = ["Case","Markup","Keyboard"];
const REF_ROWS: string[][] = [
[
"Showing a score",
"One element with role=\"img\" and aria-label=\"Rated 4.3 out of 5\". The drawn stars are aria-hidden.",
"Not focusable; nothing to operate."
],
[
"Choosing a score",
"Native radio inputs in a fieldset with a legend; each label holds a star and hidden text.",
"Tab enters the group, arrow keys move and select, as with any radio set."
],
[
"Half stars",
"Ten radios, two per star, each label covering one half.",
"Arrow keys step by half a point."
],
[
"No JavaScript",
"Radios plus :has() to colour the stars before the checked one. Needs a browser with :has() support.",
"Native radio keys."
],
[
"Custom buttons",
"role=\"radiogroup\" with role=\"radio\" buttons, aria-checked and a roving tabindex.",
"Arrows move, check and wrap; only one button is in the tab order."
],
[
"Summary bars",
"Buttons with aria-pressed to filter reviews by star level.",
"Tab and Enter or Space."
]
];
const COPY_ICON = "M9 9h10v10H9zM5 15V5h10";
const CHECK_ICON = "M5 12.5l4.5 4.5L19 7.5";
const CODE_ICON = "M8 7l-5 5 5 5M16 7l5 5-5 5";
export default function StarRatingCollection() {
const [group, setGroup] = useState("all");
const [langs, setLangs] = useState<Record<string, Lang>>({});
const [open, setOpen] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [announce, setAnnounce] = useState("");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[]
);
const shown = group === "all" ? PIECES : PIECES.filter((p) => p.group === group);
const langOf = (p: Piece): Lang => (p.html ? langs[p.id] ?? "react" : "react");
const codeOf = (p: Piece) => (langOf(p) === "html" && p.html ? p.html : p.react);
const langName = (l: Lang) => (l === "html" ? "HTML, CSS and JS" : "React and Tailwind");
async function copy(p: Piece) {
let ok = false;
try {
await navigator.clipboard.writeText(codeOf(p));
ok = true;
} catch {
ok = false;
}
if (!ok) setOpen(p.id);
setCopied(ok ? p.id : null);
setAnnounce(ok ? `${p.name}: ${langName(langOf(p))} copied` : "Copy was blocked, so the code is shown under the demo for you to select");
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(null), 1600);
}
const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600 focus-visible:ring-offset-2";
const chip = `inline-flex min-h-[40px] items-center gap-1.5 whitespace-nowrap rounded-full px-4 text-[13px] font-medium transition-colors ${ring}`;
return (
<section
className="bg-white px-4 py-12 sm:px-8 sm:py-14"
style={{ fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif' }}
>
<div className="mx-auto max-w-6xl">
<header className="max-w-3xl">
<p className="font-mono text-[12px] uppercase tracking-[0.18em] text-stone-600">{TEXT.eyebrow}</p>
<h2 className="mt-3 text-[32px] font-semibold leading-[1.08] tracking-[-0.03em] text-stone-950 sm:text-[42px]">{TEXT.title}</h2>
<p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-stone-600">{TEXT.lead}</p>
</header>
<div role="group" aria-label="Filter the collection" className="mt-8 flex flex-wrap gap-1.5 border-y border-stone-300/70 py-4">
{[{ key: "all", label: "All" }, ...GROUPS, { key: "ref", label: "Reference table" }].map((g) => {
const on = group === g.key;
const n = g.key === "ref" ? REF_ROWS.length : g.key === "all" ? PIECES.length : PIECES.filter((p) => p.group === g.key).length;
return (
<button
key={g.key}
type="button"
aria-pressed={on}
onClick={() => setGroup(g.key)}
className={`${chip} ${on ? "bg-stone-900 text-white" : "bg-white text-stone-700 ring-1 ring-inset ring-stone-300 hover:bg-stone-100"}`}
>
{g.label}
<span className={`font-mono text-[11px] ${on ? "opacity-75" : "text-stone-500"}`}>{n}</span>
</button>
);
})}
</div>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{group !== "ref" && (
<ul className="mt-6 grid grid-cols-1 items-start gap-4 md:grid-cols-2 lg:grid-cols-3">
{shown.map((p) => {
const isOpen = open === p.id;
const l = langOf(p);
return (
<li
key={p.id}
className={`flex min-w-0 flex-col rounded-2xl border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.05)] ${isOpen ? "md:col-span-2 lg:col-span-3" : ""}`}
>
<div className="px-4 pt-4">
<h3 className="text-[14px] font-semibold text-stone-950">{p.name}</h3>
<p className="mt-0.5 text-[13px] leading-snug text-stone-600">{p.blurb}</p>
</div>
<div className={`relative mx-3 mt-3 flex min-w-0 items-start justify-center rounded-xl px-3 py-6 sm:px-5 ${p.stage}`}>
<p.Demo />
</div>
<div className="flex flex-wrap items-center gap-2 px-3 py-3">
{p.html && (
<div role="group" aria-label={`Code version for ${p.name}`} className="inline-flex rounded-lg bg-stone-100 p-0.5">
{(["react", "html"] as Lang[]).map((x) => (
<button
key={x}
type="button"
aria-pressed={l === x}
onClick={() => setLangs({ ...langs, [p.id]: x })}
className={`h-9 rounded-md px-2.5 text-[12px] font-semibold ${ring} ${l === x ? "bg-white text-stone-950 shadow-sm" : "text-stone-600 hover:text-stone-900"}`}
>
{x === "react" ? "React" : "HTML"}
</button>
))}
</div>
)}
<span className="flex-1" />
<button
type="button"
aria-expanded={isOpen}
aria-controls={`code-${p.id}`}
onClick={() => setOpen(isOpen ? null : p.id)}
className={`inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-[12.5px] font-semibold text-stone-700 hover:bg-stone-100 ${ring}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
<path d={CODE_ICON} />
</svg>
{isOpen ? "Hide code" : "Code"}
</button>
<button
type="button"
onClick={() => copy(p)}
aria-label={`Copy ${langName(l)} for ${p.name}`}
className={`inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-[12.5px] font-semibold transition-colors ${ring} ${copied === p.id ? "bg-emerald-700 text-white" : "bg-stone-900 text-white hover:bg-stone-700"}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-3.5 w-3.5">
<path d={copied === p.id ? CHECK_ICON : COPY_ICON} />
</svg>
{copied === p.id ? "Copied" : "Copy"}
</button>
</div>
{isOpen && (
<pre
id={`code-${p.id}`}
tabIndex={0}
aria-label={`${langName(l)} for ${p.name}`}
className="max-h-[440px] overflow-auto rounded-b-2xl border-t border-stone-800 bg-[#121316] p-4 font-mono text-[12px] leading-[1.65] text-stone-200"
>
<code>{codeOf(p)}</code>
</pre>
)}
</li>
);
})}
</ul>
)}
{group === "ref" && (
<div className="mt-6">
<h3 className="text-[17px] font-semibold tracking-tight text-stone-950">{TEXT.refTitle}</h3>
<p className="mt-1 max-w-3xl text-[13px] leading-relaxed text-stone-600">{TEXT.refNote}</p>
<div className="mt-4 overflow-x-auto rounded-xl border border-stone-200 bg-white" tabIndex={0} role="region" aria-label={TEXT.refTitle}>
<table className="w-full min-w-[600px] text-left text-[13px]">
<thead className="bg-stone-50 text-stone-700">
<tr>
{REF_HEAD.map((h) => (
<th key={h} scope="col" className="px-4 py-2.5 font-semibold">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{REF_ROWS.map((r, i) => (
<tr key={i} className="border-t border-stone-200 align-top">
{r.map((c, j) =>
j === 0 ? (
<th key={j} scope="row" className="whitespace-nowrap px-4 py-2.5 font-mono text-[12px] font-medium text-stone-900">
{c}
</th>
) : (
<td key={j} className="px-4 py-2.5 leading-relaxed text-stone-700">
{c}
</td>
)
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/star-rating-collection-with-half-stars-pure-css-radio-version-and-review-summary-in-react-and-html.json
Use one radio input per star with a label after each, hide the inputs visually, and draw the star on the label. The usual trick reverses the order with flex-direction: row-reverse so the sibling selector can colour lower stars, but that makes the arrow keys move the opposite way to what people see. The version here keeps the natural order and colours earlier labels with label:has(~ input:checked), which needs a browser that supports :has().
Stack two rows of five stars and clip the top, filled row to a width of rating divided by five. The React piece does this with an overflow-hidden wrapper; the HTML piece uses a gradient stop and a repeating star mask on one element.
Radio buttons give you correct semantics and keyboard support for free, and they submit with a form. Plain buttons can work, but then you must add role=radio, aria-checked, a roving tabindex and arrow key handling yourself, as the numbered tiles show.
Only for ratings that are genuinely collected and shown on that page. Search engines have rules on review markup and penalise invented or self-written ratings, so never mark up placeholder numbers like the demo data here.
Related: Quantity Input Collection with Plus and Minus Stepper, Spinbutton Keys and Cart Line in React and HTML, Tailwind Product Card Row with a Photoreal Ceramic Cup, Glass Carafe and Linen Napkins in Window Light, HTML Form Examples with Every Input Type, Copyable as HTML + CSS or Tailwind.
More inputs or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.