Zero-dependency rating input and display that follows your page's theme on its own. Hover, click, or Tab in and use the arrow keys — every card is live.
Hover the left half of a star for .5. One tab stop, arrow keys step by 0.5.
new Rating('#basic', {
value: 3.5,
onChange: v => console.log(v)
})
Built-in heart icon, same gold fill — or pass your own
{ empty, full } SVG pair.
new Rating('#hearts', {
icon: 'heart', value: 2
})
Fractions render exactly — the fill is a clipped overlay, so a 4.3 average shows a 30% fifth star. No focus, no interaction.
new Rating('#avg', {
value: 4.3, readOnly: true, size: 18
})
showValue prints the live number after the stars;
keys 1–9 jump straight to a value.
new Rating('#ten', {
max: 10, value: 7, half: false,
size: 18, showValue: true
})
Enhancing an <input> hides and syncs it — it still
submits, still fires change, and destroy() restores it.
<input id="score" name="score" value="3">
new Rating('#score') // submits score=3.5
Clicking the value that's already committed clears back to 0
(default). Set clearable: false to keep ratings sticky —
try both below.
new Rating(el, { clearable: false })
The complete Rating API — constructor modes, every option,
instance methods, statics, events, declarative attributes, theming and
accessibility.
new Rating(target, options?) // → instance
Rating.create(target, options?) // same as new
target is a selector string or an element, in one of two modes:
name to get a hidden input for form submission, and an
aria-label on the container to name the slider.<input>: it
is hidden and kept in sync (so the form still submits it and native
change events still fire), and fully restored on
destroy(). Its current value seeds the rating (unless
value is passed explicitly), its disabled state is
inherited, and a <label for> pointing at it becomes the
widget's accessible name.The submitted value is the number as text ("3.5"), or the empty
string when the rating is 0/cleared. Constructing on an element that already
holds an instance destroys the old one first; a missing target throws; without
a DOM (SSR) the constructor returns an inert no-op instance.
new Rating('#stars', { onChange: (v) => console.log(v) })
<input id="score" name="score" value="3">
new Rating('#score') // submits score=3.5
Every key of Rating.defaults, with its default value.
| Name | Type | Default | Description |
|---|---|---|---|
max | number | 5 |
Number of stars (minimum 1, floored to an integer). |
value | number | 0 |
Initial value, clamped to [0, max]. In input mode the input's own value wins unless value is passed explicitly. |
half | boolean | true |
Half-star steps on hover (left half of a star = .5), click and keyboard (step 0.5 instead of 1). |
icon | string | object | 'star' |
'star' | 'heart', or an { empty, full } pair of TRUSTED SVG strings (injected verbatim — never user input). Unknown names fall back to 'star'. |
size | number | 22 |
Icon size in px (minimum 8). |
readOnly | boolean | false |
Display mode: no interaction, no focus stop — the group renders role="img" with the labels text. |
clearable | boolean | true |
Clicking the value that is already committed clears back to 0. |
name | string | null | null |
Adds a hidden input for form submission (container mode). |
showValue | boolean | false |
Live value text ('3.5') after the stars. |
disabled | boolean | false |
Start disabled (input mode inherits the input's own disabled unless set explicitly). |
labels | function | (v, max) => `${v} of ${max}` |
Builds the aria-valuetext (interactive) or aria-label (read-only) — e.g. "3.5 of 5". Customize for i18n. |
theme | string | 'auto' |
'auto' | 'light' | 'dark'. Auto re-resolves live against the page theme. |
styles | boolean | true |
false = headless: no CSS is injected — style the .vrt-* markup yourself. |
onChange | function | null | null |
fn(value) — fired when a value is committed (click, keyboard, non-silent setValue). |
onHover | function | null | null |
fn(value) while previewing on hover, fn(null) on mouse-leave. |
| Method | Returns | Description |
|---|---|---|
getValue() | number | The committed value (0 when cleared). |
setValue(v, {silent}?) | instance | Any number 0..max — fractions are fine and render exactly via the clipped overlay (setValue(4.3) shows a 30% fifth star). Clamped; fires onChange and rating:change only when the value actually changed and not with {silent: true}. |
enable() / disable() | instance | Toggle interactivity. disable() takes the slider out of the tab order (tabindex="-1", aria-disabled) and, in input mode, mirrors disabled onto the native input so a disabled control never submits. |
destroy() | instance | Remove everything the widget built and restore the target completely — an enhanced input reappears un-hidden, keeping its synced value. |
| Static | Description |
|---|---|
Rating.create(target, opts) |
Same as new Rating(target, opts). |
Rating.get(target) |
The instance previously bound to an element (or null). |
Rating.autoInit(root?) |
Initialize every [data-vrt] under root (default: document); returns the created instances. Runs automatically on DOMContentLoaded. |
Rating.defaults |
The live defaults object — mutate before constructing (e.g. Rating.defaults.styles = false). |
Rating.css |
The full stylesheet as a string (rendered with the current salt) — a starting point for headless styling. |
Rating.salt |
CSS isolation token, default 'vc1'. Set your own token or false before the first instance. |
Rating.version |
Version string, '1.0.0'. |
onChange(value) — the committed value, after click,
keyboard, or a non-silent setValue() that changed the value.onHover(value | null) — the preview value while hovering,
null on leave (the committed value is repainted).rating:change — a bubbling CustomEvent
dispatched from the widget root on every committed change, with
detail: { value, rating }.change event on the enhanced input, like a user edit would.document.getElementById('stars').addEventListener('rating:change', (e) => {
console.log(e.detail.value) // the new value
console.log(e.detail.rating) // the instance
})
Add data-vrt to a container or input — instances are built on
DOMContentLoaded (or call Rating.autoInit(root)
after inserting markup).
<div data-vrt data-value="3.5" data-name="score"></div>
| Attribute | Maps to | Notes |
|---|---|---|
data-vrt | — | Marks the element. |
data-max / data-value / data-size | max/value/size | Numbers (data-value accepts fractions). |
data-half / data-clearable / data-show-value / data-disabled / data-styles | half/clearable/showValue/disabled/styles | Booleans; "false" and "0" are false. |
data-read-only (or data-readonly) | readOnly | Boolean; both spellings accepted. |
data-icon | icon | star | heart. |
data-name | name | Hidden form input name. |
data-theme | theme | auto | light | dark. |
Auto light/dark with the family's resolution order:
<html data-theme> / data-bs-theme /
.dark class → prefers-color-scheme, re-resolved live.
Pin per instance with theme: 'dark'. All colors are CSS custom
properties; Rating is the one family component with a non-accent fill color —
a warm star gold — themeable separately:
.vrt {
--vrt-fill: #f5a623; /* the star fill (gold; dark theme: #f7b84d) */
--vrt-accent: #5b5bd6; /* focus ring */
--vrt-text: …; --vrt-muted: …; --vrt-faint: …;
--vrt-radius: 8px; --vrt-font: …;
}
With the VC core loaded, VC.config({ accent: '#b45309' }) themes
the focus ring and every other family component in one call (the gold fill
stays put unless you override --vrt-fill). Widgets render as
class="vrt vc1"; structural rules ship salted so host design
systems can't override them, while the unsalted --vrt-* variable
definitions keep page overrides working. Change the token with
Rating.salt = 'acme' (or false) before the first
instance.
Headless: Rating.defaults.styles = false (or
per instance) injects no CSS — you keep the full behavior (hover preview,
keyboard, ARIA, form sync) and style this markup contract yourself
(Rating.css is the reference stylesheet as a string):
.vrt[data-theme=dark].vrt-readonly.vrt-disabled
.vrt-stars[role=slider|img] ← .vrt-static when non-interactive
.vrt-star ← × max, sized inline
.vrt-star-empty ← outline SVG
.vrt-star-fill ← overlay clipped to N% width
.vrt-star-full ← filled SVG, fixed width
.vrt-value ← only when showValue
input[type=hidden] ← only when name given (container mode)
The whole group is a single tab stop with role="slider",
aria-orientation="horizontal", aria-valuemin/max/now
and an aria-valuetext built by labels
(e.g. "3.5 of 5").
| Key | Action |
|---|---|
| ArrowRight / ArrowUp | + step (step = half ? 0.5 : 1); lands on the nearest grid mark upward — a programmatic 4.3 becomes 4.5 |
| ArrowLeft / ArrowDown | − step; nearest grid mark downward — 4.3 becomes 4 |
| Home | 0 (clear) |
| End | max |
| 1–9 | Jump straight to that value (capped at max) |
readOnly renders role="img" with the
labels text and takes no focus stop.disable() keeps the slider out of the tab order
(tabindex="-1", aria-disabled="true").<label for> pointing at the original
input is adopted as the slider's accessible name; in container mode put an
aria-label on the container.With styles: false Rating injects no CSS at all — the full
behavior (hover preview, keyboard, form sync), the role="slider"
ARIA contract and the stable .vrt-* class hooks all remain; you
bring the stylesheet. The demos below each run in their own
<iframe>: the styled examples above have already injected
the component stylesheet into this page, so only a separate, clean document
can show what headless truly looks like.
styles: false)Zero injected CSS — no colors, no sizes, no transitions. The frame adds only the five structural lines below (the overlay-clip geometry every design needs), and hover, click, keyboard and half-stars all work.
new Rating('#stars', {
value: 3.5,
styles: false, // ← zero CSS injected
onChange: function (v) { /* … */ }
})
/* the component injected nothing — this structural minimum
is the page's own (no colors, no sizes anywhere): */
.vrt-star { display: inline-block; position: relative; }
.vrt-star svg { display: block; width: 100%; height: 100%; }
.vrt-star-empty, .vrt-star-fill { position: absolute; inset: 0; }
.vrt-star-fill { overflow: hidden; }
.vrt-star-full { display: block; height: 100%; }
The same class hooks mapped to a deliberately different design with Tailwind utilities — hearts instead of stars, a rose palette, a scale-up hover. Nothing loads from the CDN until you click.
<style type="text/tailwindcss">
.vrt { @apply inline-flex items-center gap-3; }
.vrt-stars { @apply inline-flex gap-1 rounded-xl bg-white/70 p-2
shadow ring-1 ring-rose-100 outline-none
focus-visible:ring-2 focus-visible:ring-rose-400; }
.vrt-star { @apply relative inline-block cursor-pointer text-rose-200
transition-transform hover:scale-125; }
.vrt-star svg { @apply block h-full w-full; }
.vrt-star-empty, .vrt-star-fill { @apply absolute inset-0; }
.vrt-star-fill { @apply overflow-hidden text-rose-500 drop-shadow; }
.vrt-star-full { @apply block h-full; }
.vrt-value { @apply text-sm font-bold tabular-nums text-rose-600; }
</style>