Rxova
Skip to content

About

The library owns geometry; you own appearance. An icon (and optional emptyIcon) can be any ReactNode, or a function of per-icon state.

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 string is a valid icon, including ZWJ sequences (the component never clips mid-sequence):

<Rating value={3.5} icon="" />
<Rating value={2.5} icon="👩‍🍳" />
<Rating value={4} icon={<img src="/badge.svg" alt="" width="24" height="24" />} />

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:

FieldMeaning
index0-based position in the row
fillFill ratio for this icon, 0..1
filledfill >= 1
emptyfill <= 0
partial0 < fill < 1
activeA 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:

PropPurpose
nameRadio group name; also posts a value to a native <form>
value / onChangeControlled value in, number out
onBlurFires when focus leaves the whole group, not between icons
invalidSets aria-invalid and data-invalid for error styling
aria-describedbyPoints the group at your external error / help text
requiredMarks the field required

Each library has a dedicated, complete recipe:

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>}
</>
)}
/>

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.

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.

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-filled

The 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.

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.

Editable — try changing it
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>
  )
}

The complete set. Every one has a fallback baked in, so you only override what you care about.

PropertyDefaultControls
--rfs-size1.25remIcon size. Any unit — it’s a font-size, so em/% cascade.
--rfs-gap0.125remHorizontal space between icons.
--rfs-color-filled#f5a623Colour of the filled (fill-layer) icon.
--rfs-color-empty#d8d8d8Colour of the empty (track) icon.
--rfs-color-hovervar(--rfs-color-filled)Colour of the hover/keyboard preview fill.
--rfs-empty-filtergrayscale(1) opacity(0.35)CSS filter on the implicit empty layer (see below).
--rfs-transition120msDuration of the fill/preview width transition.
--rfs-focus-ring2px solid Highlightoutline shorthand drawn on the focused icon.
--rfs-focus-ring-offset2pxoutline-offset of the focus ring.
--rfs-focus-ring-radius2pxborder-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 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.

Editable — try changing it
<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>

The space between icons. 0 packs them flush; larger values give an airier row.

Editable — try changing it
<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>

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.

Editable — try changing it
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>
  )
}

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.

Editable — try changing it
<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.

Editable — try changing it
<Rating
  value={2.5}
  style={{ '--rfs-size': '2rem' }}
  icon={<span></span>}
  emptyIcon={<span style={{ color: '#cbd5e1' }}></span>}
/>

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.

Editable — try changing it
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',
      }}
    />
  )
}
Hovering a rating with a slow transition and a distinct green hover colour

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.

Editable — try changing it
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',
      }}
    />
  )
}
PropertyMaps toDefault
--rfs-focus-ringoutline2px solid Highlight
--rfs-focus-ring-offsetoutline-offset2px
--rfs-focus-ring-radiusborder-radius2px

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.

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.

SelectorMarks
[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.

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:

Editable — try changing it
<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);
}

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.

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.

.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} />
Rating.module.css
.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} />
}

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);
}
}

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.

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;
}

Every image below is captured from the component itself, never hand-drawn:

A gallery of ratings styled with different sizes, gaps, colours, and empty-layer filters

No library needed. Pass name and the selected value posts natively, exactly like a group of radio inputs — because that is what it renders.

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
}

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 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" />

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, spreading field wires value, change, blur, name, and ref at once. Add precision, label, and error props alongside it.
  • Validation timing is correct for free. field.onBlur fires when focus leaves the whole group, not while arrowing between icons, so an onTouched/onBlur validation mode does not fire mid-choice.
  • min is the “required” rule. An unrated field is 0; a min: 1 rule rejects it with your message. For a stricter “must be set”, keep allowClear={false} so a chosen value cannot be cleared back to 0.

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.setValue matches onChange exactly — both speak number, so no adapter function is needed.
  • field.onBlur marks the field touched at the right moment: when focus leaves the whole group. Combined with meta.touched, error text does not appear until the user has finished choosing.
  • Pass name so the value also participates if the form is ever submitted natively.

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 : 0 keeps Rating on a numeric value even before the field has been touched.
  • input.onChange accepts the emitted number directly.
  • input.onBlur marks the field touched when focus leaves the whole group, so meta.touched-based errors appear only after the user finishes.

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.handleChange matches onChange exactly — both speak number, so you can pass it straight through with no adapter.
  • field.handleBlur marks the field touched when focus leaves the whole group, not while arrowing between icons — so blur-based validation does not fire mid-choice.
  • The onChange validator runs on change and again on submit, so an unrated field (0) blocks submission and surfaces the message.
  • field.state.meta.errors is an array and !field.state.meta.isValid is true whenever it is non-empty — drive both the message and the invalid prop from it.