Skip to content
Domphy

Rating

Apply rating to a <div> to render an interactive star widget. Manages its own star <button> children: click to set, arrow keys to adjust, hover to preview. Pass readOnly to disable interaction.

Props

PropTypeDefault
valueValueOrState<number>0
maxnumber5
onChange(value: number) => void
readOnlybooleanfalse
colorThemeColor"warning"
Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import {
  type DomphyElement,
  type Listener,
  type PartialElement,
  rawHtml,
  toState,
  type ValueOrState,
} from "@domphy/core";
import {
  type ThemeColor,
  themeColor,
  themeSize,
  themeSpacing,
} from "@domphy/theme";
import { focusRing } from "../utils/focusRing.js";

const STAR_FILLED =
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em">` +
  `<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>` +
  `</svg>`;
const STAR_EMPTY =
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em">` +
  `<path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/>` +
  `</svg>`;

/**
 * Interactive star rating applied to a container `<div>`. Manages its own star
 * children: click to set, Arrow keys to adjust, hover to preview. In `readOnly`
 * mode stars are non-interactive. Apply to a `<div>` element.
 *
 * @hostTag div
 * @param props.value - Current rating (0 – max). `ValueOrState<number>`, defaults to `0`.
 * @param props.max - Total number of stars. Optional `number`, defaults to `5`.
 * @param props.onChange - Called with the new value when the user picks a star.
 * @param props.readOnly - Disable interaction. Optional `boolean`, defaults to `false`.
 * @param props.color - Star color tone. Optional `ThemeColor`, defaults to `"warning"`.
 * @example { div: null, $: [rating({ value: ratingState, onChange: (v) => ratingState.set(v) })] }
 */
function rating(
  props: {
    value?: ValueOrState<number>;
    max?: number;
    onChange?: (value: number) => void;
    readOnly?: boolean;
    color?: ThemeColor;
  } = {},
): PartialElement {
  const { max = 5, readOnly = false, onChange } = props;
  const color = props.color ?? "warning";
  const valueState = toState(props.value ?? 0);
  // Hover preview only — never reported via onChange, only affects display.
  const hoveredState = toState(0);

  const activeCount = (listener: Listener) => {
    const hovered = hoveredState.get(listener);
    return hovered > 0 ? hovered : valueState.get(listener);
  };

  return {
    role: "group",
    ariaLabel: "Rating",
    style: {
      display: "inline-flex",
      gap: themeSpacing(0.5),
      // 1.5× inherited control size (theme-owned type scale, not a px/rem literal).
      fontSize: (listener) => themeSize(listener, "increase-1"),
      cursor: readOnly ? "default" : "pointer",
      color: (listener) => themeColor(listener, "muted", color),
    },
    // Build stars as real child elements (not imperative DOM mutation in
    // _onMount) so generateHTML()/SSR emits the actual star markup.
    _onInit: (node) => {
      for (let i = 1; i <= max; i++) {
        const index = i;
        const interactive = readOnly
          ? {}
          : {
              onClick: () => {
                const next = index === valueState.get() ? 0 : index;
                valueState.set(next);
                onChange?.(next);
                hoveredState.set(0);
              },
              onMouseEnter: () => hoveredState.set(index),
              onMouseLeave: () => hoveredState.set(0),
              onKeyDown: (e: KeyboardEvent) => {
                const current = valueState.get();
                let next = current;
                if (e.key === "ArrowRight" || e.key === "ArrowUp") {
                  next = Math.min(max, current + 1);
                  e.preventDefault();
                } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
                  next = Math.max(0, current - 1);
                  e.preventDefault();
                } else {
                  return;
                }
                valueState.set(next);
                onChange?.(next);
                const target = next > 0 ? next - 1 : 0;
                (node.domElement?.children[target] as HTMLElement)?.focus();
              },
            };

        const star: DomphyElement<"button"> = {
          button: (listener) =>
            rawHtml(index <= activeCount(listener) ? STAR_FILLED : STAR_EMPTY),
          _key: index,
          type: "button",
          ariaLabel: `${index} star${index > 1 ? "s" : ""}`,
          ...interactive,
          style: {
            background: "none",
            border: "none",
            outline: "none",
            borderRadius: themeSpacing(1),
            padding: 0,
            cursor: "inherit",
            color: "inherit",
            fontSize: "inherit",
            display: "flex",
            alignItems: "center",
            transition: "box-shadow 140ms ease",
            "&:focus-visible": {
              boxShadow: (listener) => focusRing(listener, color),
            },
          },
        };
        node.children.insert(star);
      }
    },
  };
}

export { rating };