Inputs

Rating

A star rating built on native radio buttons, with a read-only image form for showing averages.

Built on
<input type="radio">:has()clip-path
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.4 kB
Dependencies
React
Rate this release
4.5 from 212 reviews
Try its options

Loading its options.

Code
import { Rating } from "@/components/equip/rating";

export default function Demo() {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
      <Rating label="Rate this release" defaultValue={4} />
      <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
        <Rating label="Average rating" readOnly allowHalf size="s" defaultValue={4.5} />
        <span>4.5 from 212 reviews</span>
      </div>
    </div>
  );
}
Your versionchange its options in Look, This part

Examples

Sizes

Small stars keep a 24px hit area each, so they stay easy to tap.

Support experience
Support experience
Code
import { Rating } from "@/components/equip/rating";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 24, alignItems: "flex-end" }}>
      <Rating label="Support experience" size="s" defaultValue={3} />
      <Rating label="Support experience" defaultValue={3} />
    </div>
  );
}

Read-only scores

Each row is one image named like "Ease of setup: 4.5 out of 5", so a screen reader hears the score once.

Ease of setup
Documentation
Value for money
Code
import { Rating } from "@/components/equip/rating";

const scores = [
  { label: "Ease of setup", value: 4.5 },
  { label: "Documentation", value: 3.8 },
  { label: "Value for money", value: 4 },
];

export default function Example() {
  return (
    <dl style={{ display: "grid", gridTemplateColumns: "auto auto", gap: "8px 16px", margin: 0, fontSize: 13 }}>
      {scores.map((s) => (
        <div key={s.label} style={{ display: "contents" }}>
          <dt>{s.label}</dt>
          <dd style={{ margin: 0 }}>
            <Rating label={s.label} readOnly allowHalf size="s" defaultValue={s.value} />
          </dd>
        </div>
      ))}
    </dl>
  );
}

Controlled, with a word

Choose the current star again, or press Space on it, to clear the rating.

How was the onboarding call?
Not rated
Code
"use client";

import { useState } from "react";
import { Rating } from "@/components/equip/rating";

const words = ["Not rated", "Poor", "Fair", "Good", "Very good", "Excellent"];

export default function Example() {
  const [value, setValue] = useState(0);
  return (
    <div style={{ display: "flex", alignItems: "flex-end", gap: 12 }}>
      <Rating label="How was the onboarding call?" value={value} onValueChange={setValue} name="onboarding" />
      <span style={{ fontSize: 13, lineHeight: "28px" }}>{words[value]}</span>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the group. Shown above the stars, or read as part of the image when read-only.
valuenumbernoneControlled value. 0 means not rated.
defaultValuenumber0Starting value when uncontrolled, or the value shown when read-only.
onValueChange(value: number) => voidnoneCalled with the new value, or 0 when the current star is chosen again.
maxnumber5Number of stars.
readOnlybooleanfalseRenders an image named like "4 out of 5" instead of a radio group.
allowHalfbooleanfalseRead-only only. Rounds to the nearest half star, e.g. 4.5.
namestringnoneRadio group name for forms. Defaults to a unique name per instance.
size"s" | "m""m"Star size. Both keep at least a 24px target.
...restOmit<ComponentProps<"div">, "defaultValue" | "onChange">noneEvery native div attribute, applied to the root.

Anatomy

root.eq-ratingrole radiogroup, or role img when read-only.
label.eq-rating-label
stars.eq-rating-starsHolds the stars; hovering it previews a value.
star.eq-rating-starA label around one radio, or a span when read-only.
input.eq-rating-inputThe native radio, invisible and covering the star.
outline.eq-rating-outlineThe empty star.
fill.eq-rating-fillThe filled star, clipped to none, half or all of it.

Keyboard

TabMoves to the chosen star, or the first one when none is chosen.
ArrowRightArrowUpChooses the next star. Provided by the browser.
ArrowLeftArrowDownChooses the previous star. Provided by the browser.
SpaceChooses the focused star, or clears the rating when it is already chosen.

Notes

  • Each star is a native radio named like "4 stars", so forms, required and screen reader group announcements work as usual.
  • Choosing the current star again clears the rating to 0; the radio group has no other way to be emptied.
  • Filled stars use the ink colour, not the accent, so a rating never competes with the primary action.
  • Hover previews a value in a lighter ink; the arrow keys are the keyboard way to try values.
  • The read-only form is one image whose name includes the label and the rounded value, so the stars are not read one by one.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/rating.tsx107 lines
"use client";

import { useId, useState } from "react";
import type { ComponentProps } from "react";
import "./rating.css";

export type RatingProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** Names the group. Visible when interactive; read as part of the image when read-only. */
  label: string;
  value?: number;
  defaultValue?: number;
  /** Called with the new value, or 0 when the current star is chosen again to clear it. */
  onValueChange?: (value: number) => void;
  max?: number;
  /** Shows a value as an image instead of a radio group. */
  readOnly?: boolean;
  /** Read-only only: rounds to the nearest half star instead of the nearest whole one. */
  allowHalf?: boolean;
  /** Radio group name, for forms. Defaults to a unique name per instance. */
  name?: string;
  size?: "s" | "m";
};

function Star() {
  return (
    <>
      <svg className="eq-rating-outline" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" aria-hidden="true">
        <path d="m12 3.5 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.3-4.1 5.9-.9Z" />
      </svg>
      <svg className="eq-rating-fill" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" aria-hidden="true">
        <path d="m12 3.5 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.3-4.1 5.9-.9Z" />
      </svg>
    </>
  );
}

export function Rating({
  label,
  value,
  defaultValue = 0,
  onValueChange,
  max = 5,
  readOnly = false,
  allowHalf = false,
  name,
  size = "m",
  className,
  ...rest
}: RatingProps) {
  const id = useId();
  const [inner, setInner] = useState(defaultValue);
  // The star just chosen, so only it pops. Set from the handler, never on first render.
  const [popped, setPopped] = useState<number | null>(null);
  const clamp = (n: number) => Math.max(0, Math.min(max, n));
  const current = clamp(value ?? inner);
  const stars = Array.from({ length: max }, (_, i) => i + 1);
  const root = ["eq-rating", className].filter(Boolean).join(" ");

  if (readOnly) {
    const shown = allowHalf ? Math.round(current * 2) / 2 : Math.round(current);
    return (
      <div role="img" aria-label={`${label}: ${shown} out of ${max}`} {...rest} className={root} data-size={size} data-readonly="">
        {stars.map((n) => (
          <span key={n} className="eq-rating-star" data-fill={shown >= n ? "full" : shown >= n - 0.5 ? "half" : undefined}>
            <Star />
          </span>
        ))}
      </div>
    );
  }

  const choose = (n: number) => {
    if (value === undefined) setInner(n);
    setPopped(n || null);
    onValueChange?.(n);
  };
  const group = name ?? `${id}-rating`;
  const labelId = `${id}-label`;

  return (
    <div role="radiogroup" aria-labelledby={labelId} {...rest} className={root} data-size={size}>
      <span className="eq-rating-label" id={labelId}>
        {label}
      </span>
      <span className="eq-rating-stars">
        {stars.map((n) => (
          <label key={n} className="eq-rating-star" data-fill={current >= n ? "full" : undefined} data-popped={popped === n || undefined}>
            <input
              type="radio"
              className="eq-rating-input"
              name={group}
              value={n}
              checked={current === n}
              aria-label={`${n} ${n === 1 ? "star" : "stars"}`}
              onChange={() => choose(n)}
              // A checked radio fires no change, so choosing the current value again clears it here.
              onClick={() => {
                if (current === n) choose(0);
              }}
            />
            <Star />
          </label>
        ))}
      </span>
    </div>
  );
}
components/equip/rating.css129 lines
@layer equip {
  .eq-rating {
    --_icon: 22px;
    --_hit: var(--eq-h-s);

    display: inline-flex;
    flex-direction: column;
    gap: var(--eq-space-1);
    font-family: var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-rating[data-size="s"] {
    --_icon: 16px;
    --_hit: 24px;
  }

  .eq-rating[data-readonly] {
    flex-direction: row;
    align-items: center;
    gap: 2px;
    vertical-align: middle;
  }

  .eq-rating-label {
    width: fit-content;
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-rating-stars {
    display: flex;
    /* The hit areas touch; the star sits inside each one with room to spare. */
    margin-inline-start: calc((var(--_icon) - var(--_hit)) / 2);
  }

  .eq-rating-star {
    /* How much of the filled star is hidden from the end: 100% none, 50% half, 0% full. */
    --_clip: 100%;
    --_fill: var(--eq-ink);

    position: relative;
    flex: none;
    display: grid;
    place-items: center;
    width: var(--_hit);
    height: var(--_hit);
    border-radius: var(--eq-radius-s);
    transition: scale var(--eq-dur) var(--eq-ease);
  }

  .eq-rating[data-readonly] .eq-rating-star {
    width: var(--_icon);
    height: var(--_icon);
  }

  .eq-rating-star[data-fill="full"] {
    --_clip: 0%;
  }

  .eq-rating-star[data-fill="half"] {
    --_clip: 50%;
  }

  .eq-rating-outline,
  .eq-rating-fill {
    grid-area: 1 / 1;
    width: var(--_icon);
    height: var(--_icon);
  }

  .eq-rating-outline {
    color: var(--eq-line-strong);
  }

  .eq-rating-fill {
    color: var(--_fill);
    clip-path: inset(0 var(--_clip) 0 0);
    transition:
      clip-path var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease);
  }

  .eq-rating-star[data-popped] .eq-rating-fill {
    animation: eq-rating-pop var(--eq-dur-move) var(--eq-spring);
  }

  @keyframes eq-rating-pop {
    from {
      scale: 0.5;
    }
  }

  .eq-rating-input {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    margin: 0;
    opacity: 0;
    appearance: none;
    cursor: pointer;
  }

  /* Hovering previews the value the pointer would set, in a lighter ink. */
  @media (hover: hover) {
    .eq-rating-stars:hover .eq-rating-star {
      --_clip: 100%;
    }

    .eq-rating-stars .eq-rating-star:is(:hover, :has(~ .eq-rating-star:hover)) {
      --_clip: 0%;
      --_fill: var(--eq-ink-2);
    }

    /* Stars already chosen keep full ink under the preview. */
    .eq-rating-stars .eq-rating-star[data-fill="full"]:is(:hover, :has(~ .eq-rating-star:hover)) {
      --_fill: var(--eq-ink);
    }
  }

  .eq-rating-stars .eq-rating-star:active {
    scale: var(--eq-press);
  }

  .eq-rating-star:has(.eq-rating-input:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index