About
Custom icons
Section titled “Custom icons”The library owns geometry; you own appearance. An icon (and optional emptyIcon) can be any
ReactNode, or a function of per-icon state.
An SVG
Section titled “An SVG”Any SVG works. Size it in em so it scales with --rfs-size, and give it fill="currentColor" so
it picks up the fill color the component sets from --rfs-color-filled:
function Heart() { return ( <svg viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden="true"> <path d="M12 21s-8-4.9-8-10.4A4.6 4.6 0 0 1 12 7a4.6 4.6 0 0 1 8 3.6C20 16.1 12 21 12 21z" /> </svg> )}<Rating value={3.5} max={5} icon={<Heart />} style={{ '--rfs-color-filled': 'crimson' }} />Provide a distinct outline for the empty state, or omit emptyIcon to reuse the same icon dimmed:
<Rating value={3.5} icon={<HeartFilled />} emptyIcon={<HeartOutline />} />An emoji
Section titled “An emoji”An emoji string is a valid icon, including ZWJ sequences (the component never clips mid-sequence):
<Rating value={3.5} icon="⭐" /><Rating value={2.5} icon="👩🍳" />An image
Section titled “An image”<Rating value={4} icon={<img src="/badge.svg" alt="" width="24" height="24" />} />A render function
Section titled “A render function”For conditional icons, pass a function. It receives per-icon state and returns a node:
<Rating value={2.5} icon={(s) => <span>{s.partial ? '◐' : s.filled ? '●' : '○'}</span>} />The state object:
| Field | Meaning |
|---|---|
index | 0-based position in the row |
fill | Fill ratio for this icon, 0..1 |
filled | fill >= 1 |
empty | fill <= 0 |
partial | 0 < fill < 1 |
active | A hover/keyboard preview covers this icon |
Because the fill layer clips whatever you return by width, a render function still gets exact partial fills — you are choosing the glyph, not reimplementing the geometry.
onChange emits a number, not an event, so {...register('rating')} will not work directly.
Every major form library has a controlled adapter — that is the supported path, and it is one line
longer than a plain input.
The props that make a rating a well-behaved form field:
| Prop | Purpose |
|---|---|
name | Radio group name; also posts a value to a native <form> |
value / onChange | Controlled value in, number out |
onBlur | Fires when focus leaves the whole group, not between icons |
invalid | Sets aria-invalid and data-invalid for error styling |
aria-describedby | Points the group at your external error / help text |
required | Marks the field required |
Copy-paste recipes
Section titled “Copy-paste recipes”Each library has a dedicated, complete recipe:
- React Hook Form —
Controller+rules - Formik —
useField+ helpers - React Final Form —
Fieldrender prop - TanStack Form —
form.Field+ validators - Native
<form>— no library, posts vianame
The shape they share
Section titled “The shape they share”All three controlled adapters line up the same handful of props. React Hook Form is the shortest
because field already carries value, onChange, onBlur, name, and ref:
<Controller name="rating" control={control} rules={{ min: { value: 1, message: 'Please rate' } }} render={({ field, fieldState }) => ( <> <Rating {...field} // value, onChange, onBlur, name, ref all line up precision={0.5} invalid={fieldState.invalid} aria-describedby={fieldState.error ? 'rating-err' : undefined} /> {fieldState.error && <p id="rating-err">{fieldState.error.message}</p>} </> )}/>Native forms
Section titled “Native forms”With a plain <form>, no adapter is needed — pass name and the selected value posts natively under
that name:
<form method="post"> <Rating name="score" defaultValue={0} onChange={() => {}} precision={1} label="Score" /> <button type="submit">Submit</button></form>See Native forms for the uncontrolled and controlled variants.
Styling
Section titled “Styling”There is no stylesheet to import. Rating inlines only the handful of declarations that make the
layout work (the flex row, the two stacked layers, the clip that reveals a partial fill). Everything
visual — size, spacing, colour, motion, focus — is exposed as either a CSS custom property you
set, or a data-* hook you target from your own CSS.
That means you style it the way you style your own components: no wrapper, no !important, no
reaching into internals, no class-name lottery. Every knob on this page is live — edit any demo
and watch the component react.
The anatomy of one icon
Section titled “The anatomy of one icon”Knowing what the properties target makes the rest of the page obvious. Each icon slot is two absolutely-stacked layers inside a sized box:
[data-rfs-root] ← the flex row; --rfs-gap separates items └─ [data-rfs-item] ← one icon slot; --rfs-size sets its font-size (the box) ├─ [data-rfs-layer="empty"] ← the track, full width; --rfs-color-empty (+ --rfs-empty-filter) └─ [data-rfs-layer="fill"] ← the fill, clipped to the value's %; --rfs-color-filledThe fill layer sits on top of the empty layer and is clipped to a percentage width, so a 4.3
rating reveals 30% of the fifth icon. Because the box is sized by font-size, an emoji (which
scales by font-size, not width/height) sizes identically to an inline SVG.
Interactive playground
Section titled “Interactive playground”Drag the sliders and pick colours — the box at the top is a live, interactive Rating, and the
panel underneath is the exact CSS you’d paste into your own stylesheet.
function StylingPlayground() { const [size, setSize] = useState(2.5) const [gap, setGap] = useState(0.25) const [filled, setFilled] = useState('#f5a623') const [empty, setEmpty] = useState('#d8d8d8') const [hover, setHover] = useState('#f5a623') const [transition, setTransition] = useState(120) const [score, setScore] = useState(3.5) const vars = { '--rfs-size': `${size}rem`, '--rfs-gap': `${gap}rem`, '--rfs-color-filled': filled, '--rfs-color-empty': empty, '--rfs-color-hover': hover, '--rfs-transition': `${transition}ms`, } const Row = ({ label, children }) => ( <label style={{ display: 'grid', gridTemplateColumns: '7rem 1fr auto', alignItems: 'center', gap: '0.75rem', }} > <span style={{ fontSize: '0.85rem' }}>{label}</span> {children} </label> ) return ( <div style={{ display: 'grid', gap: '1.25rem' }}> <div style={{ minHeight: `${size * 1.4}rem`, display: 'flex', alignItems: 'center' }}> <Rating value={score} onChange={setScore} precision={0.5} style={vars} label="Playground" /> </div> <div style={{ display: 'grid', gap: '0.5rem', maxWidth: 460 }}> <Row label="size"> <input type="range" min={1} max={6} step={0.25} value={size} onChange={(e) => setSize(+e.target.value)} /> <code>{size}rem</code> </Row> <Row label="gap"> <input type="range" min={0} max={1.5} step={0.05} value={gap} onChange={(e) => setGap(+e.target.value)} /> <code>{gap}rem</code> </Row> <Row label="transition"> <input type="range" min={0} max={800} step={20} value={transition} onChange={(e) => setTransition(+e.target.value)} /> <code>{transition}ms</code> </Row> <Row label="filled"> <input type="color" value={filled} onChange={(e) => setFilled(e.target.value)} /> <code>{filled}</code> </Row> <Row label="empty"> <input type="color" value={empty} onChange={(e) => setEmpty(e.target.value)} /> <code>{empty}</code> </Row> <Row label="hover"> <input type="color" value={hover} onChange={(e) => setHover(e.target.value)} /> <code>{hover}</code> </Row> </div> <pre style={{ margin: 0 }}> {`[data-rfs-root] {\n${Object.entries(vars) .map(([k, v]) => ` ${k}: ${v};`) .join('\n')}\n}`} </pre> </div> ) }
Every custom property
Section titled “Every custom property”The complete set. Every one has a fallback baked in, so you only override what you care about.
| Property | Default | Controls |
|---|---|---|
--rfs-size | 1.25rem | Icon size. Any unit — it’s a font-size, so em/% cascade. |
--rfs-gap | 0.125rem | Horizontal space between icons. |
--rfs-color-filled | #f5a623 | Colour of the filled (fill-layer) icon. |
--rfs-color-empty | #d8d8d8 | Colour of the empty (track) icon. |
--rfs-color-hover | var(--rfs-color-filled) | Colour of the hover/keyboard preview fill. |
--rfs-empty-filter | grayscale(1) opacity(0.35) | CSS filter on the implicit empty layer (see below). |
--rfs-transition | 120ms | Duration of the fill/preview width transition. |
--rfs-focus-ring | 2px solid Highlight | outline shorthand drawn on the focused icon. |
--rfs-focus-ring-offset | 2px | outline-offset of the focus ring. |
--rfs-focus-ring-radius | 2px | border-radius of the focus ring. |
Because they’re custom properties, they cascade. Set them on [data-rfs-root], on the element’s
style prop, on a wrapper class, or on :root for the whole app — whatever scope you want.
Size — --rfs-size
Section titled “Size — --rfs-size”Size is a single property, in any unit. Set it inline for one instance, or on an ancestor to size a
whole group. Since it’s a font-size, relative units (em, %) inherit naturally.
<div style={{ display: 'grid', gap: '0.75rem' }}> <Rating value={3.5} style={{ '--rfs-size': '1rem' }} /> <Rating value={3.5} style={{ '--rfs-size': '1.75rem' }} /> <Rating value={3.5} style={{ '--rfs-size': '3rem' }} /> </div>
Gap — --rfs-gap
Section titled “Gap — --rfs-gap”The space between icons. 0 packs them flush; larger values give an airier row.
<div style={{ display: 'grid', gap: '0.75rem', fontSize: '1.75rem' }}> <Rating value={3.5} style={{ '--rfs-gap': '0' }} /> <Rating value={3.5} style={{ '--rfs-gap': '0.25rem' }} /> <Rating value={3.5} style={{ '--rfs-gap': '0.75rem' }} /> </div>
Colours — filled, empty, hover
Section titled “Colours — filled, empty, hover”Three independent colours. --rfs-color-hover defaults to --rfs-color-filled, so a rating with no
hover colour set previews in its fill colour; override it for a distinct “I’m about to pick this”
tint. Hover over the interactive one to see the hover colour.
function Colours() { const [score, setScore] = useState(3) const brand = { '--rfs-color-filled': '#6d28d9', '--rfs-color-empty': '#e5e7eb', '--rfs-color-hover': '#c026d3', '--rfs-size': '2rem', } return ( <div style={{ display: 'grid', gap: '0.75rem' }}> <Rating value={3.5} style={brand} /> <Rating value={score} onChange={setScore} precision={0.5} style={brand} label="Pick" /> </div> ) }
The empty layer — --rfs-empty-filter
Section titled “The empty layer — --rfs-empty-filter”When you don’t pass an emptyIcon, the track is the same glyph as the fill, dimmed by a CSS
filter so it reads as “empty”. A filter — unlike color — works on emoji and SVG alike, which is
why the default is grayscale(1) opacity(0.35) rather than a grey colour.
<div style={{ display: 'grid', gap: '0.75rem', fontSize: '2rem' }}> <Rating value={2.5} icon="⭐" /> <Rating value={2.5} icon="⭐" style={{ '--rfs-empty-filter': 'grayscale(1) opacity(0.2)' }} /> <Rating value={2.5} icon="⭐" style={{ '--rfs-empty-filter': 'sepia(1) opacity(0.5)' }} /> <Rating value={2.5} icon="⭐" style={{ '--rfs-empty-filter': 'none' }} /> </div>
If you pass an explicit emptyIcon, no filter is applied — that artwork is your own empty look,
so the component leaves it alone.
<Rating value={2.5} style={{ '--rfs-size': '2rem' }} icon={<span>●</span>} emptyIcon={<span style={{ color: '#cbd5e1' }}>○</span>} />
Motion — --rfs-transition
Section titled “Motion — --rfs-transition”The fill width animates when the value or hover preview changes; --rfs-transition is its duration.
Crank it up here and hover slowly to watch the fill glide.
function Motion() { const [score, setScore] = useState(2) return ( <Rating value={score} onChange={setScore} precision={0.5} label="Slow fill" style={{ '--rfs-size': '2.5rem', '--rfs-transition': '450ms', '--rfs-color-hover': '#22c55e', }} /> ) }
Focus ring — --rfs-focus-ring
Section titled “Focus ring — --rfs-focus-ring”When a rating is operated by keyboard, a focus ring is drawn on the focused icon. It’s an outline
(so the fill layer’s overflow: hidden can’t clip it), with a matching offset and corner radius. Tab
into the rating below and use the arrow keys.
function Focus() { const [score, setScore] = useState(0) return ( <Rating value={score} onChange={setScore} precision={1} label="Focus me with Tab, then arrow keys" style={{ '--rfs-size': '2.5rem', '--rfs-focus-ring': '3px solid #2563eb', '--rfs-focus-ring-offset': '4px', '--rfs-focus-ring-radius': '6px', }} /> ) }
| Property | Maps to | Default |
|---|---|---|
--rfs-focus-ring | outline | 2px solid Highlight |
--rfs-focus-ring-offset | outline-offset | 2px |
--rfs-focus-ring-radius | border-radius | 2px |
Highlight is the system focus colour, so the default ring matches the user’s OS accent out of the
box. Override the shorthand for a branded ring.
Data-attribute hooks
Section titled “Data-attribute hooks”For anything a custom property doesn’t cover, target these attributes from your own CSS. They are stable and covered by semver — safe to build a design system on.
| Selector | Marks |
|---|---|
[data-rfs-root] | The root element. |
[data-rfs-item] | One icon slot (its value is the 0-based index). |
[data-rfs-layer="fill"|"empty"] | The fill layer vs. the empty/track layer. |
[data-state="full"|"partial"|"empty"] | Per-icon fill state. |
[data-active] | Icons under the current hover/keyboard preview. |
[data-readonly] | Present in read-only (display) mode. |
[data-disabled] | Present when disabled. |
[data-invalid] | Present when invalid. |
Per-state theming
Section titled “Per-state theming”Because the state lives in the DOM, you can restyle a whole state by setting a variable from a
selector — no per-instance props. A red error look driven only by the invalid prop:
[data-rfs-root][data-invalid] { --rfs-color-filled: #dc2626; --rfs-color-empty: #fecaca;}
/* Fade the control while it's unavailable. */[data-rfs-root][data-disabled] { opacity: 0.5;}The same states, side by side:
<div style={{ display: 'grid', gap: '0.75rem', fontSize: '1.75rem' }}> <Rating value={3} invalid style={{ '--rfs-color-filled': '#dc2626', '--rfs-color-empty': '#fecaca' }} /> <Rating value={3} onChange={() => {}} disabled style={{ opacity: 0.5 }} /> <Rating value={3.5} /> </div>
You can also reach past variables into structure — e.g. a rule that targets only partially-filled icons, or only the icons under the pointer:
/* Nudge the icon currently being previewed. */[data-rfs-item][data-active] { transform: scale(1.15);}
/* Give the fill layer a subtle glow. */[data-rfs-layer='fill'] { filter: drop-shadow(0 0 2px currentColor);}Where to set the variables
Section titled “Where to set the variables”The same three scopes as any custom property — pick per use:
// Per instance — inline style:<Rating value={4.3} style={{ '--rfs-size': '2rem', '--rfs-color-filled': '#6d28d9' }} />
// Per theme — a class you control:<Rating value={4.3} className="brand-rating" />
// App-wide — on :root, so every rating inherits it::root { --rfs-color-filled: #6d28d9; --rfs-color-empty: #e5e7eb;}.brand-rating { --rfs-size: 2rem;}Tailwind, CSS Modules, and styled-components all work the same way — they only ever set variables. Those patterns are in the theming recipes below.
Theming recipes
Section titled “Theming recipes”Everything visual is a --rfs-* custom property (see the property list above for the full set).
That means you can theme the component with whatever styling tool you already use — no wrapper
component, no !important, no reaching into internals.
Plain CSS
Section titled “Plain CSS”.rating-brand { --rfs-size: 1.5rem; --rfs-gap: 0.25rem; --rfs-color-filled: #6d28d9; --rfs-color-empty: #e5e7eb;}<Rating className="rating-brand" value={4.3} />CSS Modules
Section titled “CSS Modules”.brand { --rfs-color-filled: #6d28d9; --rfs-color-hover: #7c3aed;}import styles from './Rating.module.css'
function Stars() { return <Rating className={styles.brand} value={4.3} />}Tailwind CSS
Section titled “Tailwind CSS”Set the custom properties with arbitrary properties, no plugin required:
<Rating value={4.3} className="[--rfs-color-filled:theme(colors.violet.600)] [--rfs-size:1.5rem]" />Or map them to your design tokens in a component layer:
@layer components { .rating { --rfs-color-filled: theme(colors.violet.600); --rfs-color-empty: theme(colors.gray.200); }}styled-components / Emotion
Section titled “styled-components / Emotion”Because the knobs are custom properties, a styled wrapper only needs to set variables — it never has to know the component’s internals:
import styled from 'styled-components'import { Rating } from '@rxova/react-rating-input'
const BrandRating = styled(Rating)` --rfs-color-filled: ${(p) => p.theme.colors.brand}; --rfs-color-empty: ${(p) => p.theme.colors.subtle}; --rfs-size: 1.5rem;`None of these add a runtime dependency to @rxova/react-rating-input — they are entirely your styling
layer setting variables the component reads.
Per-state theming (recipe)
Section titled “Per-state theming (recipe)”Target the semver-stable data-* hooks to theme states — for example, a red error look driven only
by the invalid prop:
[data-rfs-root][data-invalid] { --rfs-color-filled: #dc2626; --rfs-color-empty: #fecaca;}Gallery
Section titled “Gallery”Every image below is captured from the component itself, never hand-drawn:
See also
Section titled “See also”- Custom icons — swapping the glyph and the render-function API.
- Displaying a score — sizing in context, precision and rounding.
Form libraries
Section titled “Form libraries”Native forms
Section titled “Native forms”No library needed. Pass name and the selected value posts natively, exactly like a group of radio
inputs — because that is what it renders.
Uncontrolled
Section titled “Uncontrolled”Give it a name and a defaultValue. The chosen value is submitted under that name:
import { Rating } from '@rxova/react-rating-input'
function Feedback() { return ( <form method="post" action="/feedback"> <Rating name="score" defaultValue={0} onChange={() => {}} precision={1} label="Score" /> <button type="submit">Submit</button> </form> )}On submit, the form body includes score=<value>. Reading it back:
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault() const data = new FormData(event.currentTarget) const score = Number(data.get('score')) // the selected rating}Controlled
Section titled “Controlled”Hold the value in React state and still submit natively — name works in both modes:
function Feedback() { const [score, setScore] = useState(0) return ( <form onSubmit={(e) => { e.preventDefault() console.log(new FormData(e.currentTarget).get('score')) }} > <Rating name="score" value={score} onChange={setScore} precision={1} label="Score" /> <button type="submit">Submit</button> </form> )}Required
Section titled “Required”required marks the underlying radio group required, so the browser blocks submission of an unrated
field with its native validation message.
<Rating name="score" required onChange={() => {}} label="Score" />React Hook Form
Section titled “React Hook Form”onChange emits a number, not an event, so use a Controller. Its field object already carries
value, onChange, onBlur, name, and ref, which line up one-to-one with Rating’s props.
import { useForm, Controller } from 'react-hook-form'import { Rating } from '@rxova/react-rating-input'
type Values = { rating: number }
function ReviewForm() { const { control, handleSubmit } = useForm<Values>({ defaultValues: { rating: 0 } })
return ( <form onSubmit={handleSubmit((values) => console.log(values))}> <Controller name="rating" control={control} rules={{ min: { value: 1, message: 'Please rate before submitting' } }} render={({ field, fieldState }) => ( <> <Rating {...field} // value, onChange, onBlur, name, ref all line up precision={0.5} label="Overall" invalid={fieldState.invalid} aria-describedby={fieldState.error ? 'rating-error' : undefined} /> {fieldState.error && ( <p id="rating-error" role="alert"> {fieldState.error.message} </p> )} </> )} /> <button type="submit">Send review</button> </form> )}{...field}is the whole adapter. Because the prop names match, spreadingfieldwires value, change, blur, name, and ref at once. Addprecision,label, and error props alongside it.- Validation timing is correct for free.
field.onBlurfires when focus leaves the whole group, not while arrowing between icons, so anonTouched/onBlurvalidation mode does not fire mid-choice. minis the “required” rule. An unrated field is0; amin: 1rule rejects it with your message. For a stricter “must be set”, keepallowClear={false}so a chosen value cannot be cleared back to0.
Formik
Section titled “Formik”Bridge Formik’s useField helpers to Rating’s controlled props. helpers.setValue takes the
number that onChange emits directly.
import { Formik, Form, useField } from 'formik'import { Rating } from '@rxova/react-rating-input'
function RatingField({ name, label }: { name: string; label: string }) { const [field, meta, helpers] = useField<number>(name)
return ( <> <Rating name={name} value={field.value} onChange={helpers.setValue} onBlur={field.onBlur} precision={0.5} label={label} invalid={meta.touched && !!meta.error} aria-describedby={meta.touched && meta.error ? `${name}-error` : undefined} /> {meta.touched && meta.error && ( <p id={`${name}-error`} role="alert"> {meta.error} </p> )} </> )}
function ReviewForm() { return ( <Formik initialValues={{ rating: 0 }} validate={(v) => (v.rating < 1 ? { rating: 'Please rate before submitting' } : {})} onSubmit={(values) => console.log(values)} > <Form> <RatingField name="rating" label="Overall" /> <button type="submit">Send review</button> </Form> </Formik> )}helpers.setValuematchesonChangeexactly — both speaknumber, so no adapter function is needed.field.onBlurmarks the field touched at the right moment: when focus leaves the whole group. Combined withmeta.touched, error text does not appear until the user has finished choosing.- Pass
nameso the value also participates if the form is ever submitted natively.
React Final Form
Section titled “React Final Form”Use a Field render prop. The one thing to know: React Final Form represents an empty field as
'' (an empty string), so guard the value into a number before handing it to Rating.
import { Form, Field } from 'react-final-form'import { Rating } from '@rxova/react-rating-input'
function ReviewForm() { return ( <Form onSubmit={(values) => console.log(values)} validate={(v) => (Number(v.rating) >= 1 ? {} : { rating: 'Please rate before submitting' })} render={({ handleSubmit }) => ( <form onSubmit={handleSubmit}> <Field name="rating"> {({ input, meta }) => ( <> <Rating // RFF uses '' for an empty field, which is not a number. value={typeof input.value === 'number' ? input.value : 0} onChange={input.onChange} onBlur={input.onBlur} precision={1} label="Overall" invalid={meta.touched && !!meta.error} aria-describedby={meta.touched && meta.error ? 'rating-error' : undefined} /> {meta.touched && meta.error && ( <p id="rating-error" role="alert"> {meta.error} </p> )} </> )} </Field> <button type="submit">Send review</button> </form> )} /> )}- The
''guard is the whole trick.typeof input.value === 'number' ? input.value : 0keepsRatingon a numeric value even before the field has been touched. input.onChangeaccepts the emittednumberdirectly.input.onBlurmarks the field touched when focus leaves the whole group, someta.touched-based errors appear only after the user finishes.
TanStack Form
Section titled “TanStack Form”Bridge a TanStack Form field to Rating’s controlled props.
field.handleChange takes the number that onChange emits directly, and field.handleBlur marks
the field touched when focus leaves the whole group.
import { useForm } from '@tanstack/react-form'import { Rating } from '@rxova/react-rating-input'
function ReviewForm() { const form = useForm({ defaultValues: { rating: 0 }, onSubmit: ({ value }) => console.log(value), })
return ( <form onSubmit={(e) => { e.preventDefault() void form.handleSubmit() }} > <form.Field name="rating" validators={{ onChange: ({ value }) => (value < 1 ? 'Please rate before submitting' : undefined), }} > {(field) => ( <> <Rating name="rating" value={field.state.value} onChange={field.handleChange} onBlur={field.handleBlur} precision={0.5} label="Overall" invalid={!field.state.meta.isValid} aria-describedby={field.state.meta.isValid ? undefined : 'rating-error'} /> {!field.state.meta.isValid && ( <p id="rating-error" role="alert"> {field.state.meta.errors.join(', ')} </p> )} </> )} </form.Field> <button type="submit">Send review</button> </form> )}field.handleChangematchesonChangeexactly — both speaknumber, so you can pass it straight through with no adapter.field.handleBlurmarks the field touched when focus leaves the whole group, not while arrowing between icons — so blur-based validation does not fire mid-choice.- The
onChangevalidator runs on change and again on submit, so an unrated field (0) blocks submission and surfaces the message. field.state.meta.errorsis an array and!field.state.meta.isValidis true whenever it is non-empty — drive both the message and theinvalidprop from it.