shshadcn-htmx

Components

Rating

A star rating built as a single-select radio group. One native <input type="radio"> per star, all sharing a name — the browser gives you arrow-key navigation, per-star labels, and a real submittable value. The fill and hover preview are pure CSS; zero JavaScript, works without it.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/rating.json

2. Use it

components/ui/rating.tsx
import { Rating } from "@/components/ui/rating"

<form method="post" action="/review">
  <Rating name="score" value={3} required />
  <button type="submit">Submit review</button>
</form>
Or copy the source manually
components/ui/rating.tsx
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"

// Rating — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A star/icon rating control built as a single-select radio group: one real
// <input type="radio"> per star, all sharing a `name`. The browser handles
// arrow-key navigation, focus management, one-selected-at-a-time, and form
// submission for free — no JavaScript. Submitting the form sends the chosen
// star count as the field value.
//
// APG: WAI-ARIA Radio Group pattern, star-styled rating example.
//   repos/aria-practices/content/patterns/radio/examples/radio-rating.html
//   repos/aria-practices/content/patterns/radio/examples/css/radio-rating.css
// The APG example puts role="radio" on SVG <g> elements driven by JS. We
// instead use native radios (zero JS, form-submittable) and reproduce its
// fill / hover-preview purely in CSS.
//
// Native element + ARIA references:
//   repos/mdn/files/en-us/web/html/reference/elements/input/radio/index.md
//   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/radiogroup_role
//
// CSS technique: inputs and their <label> stars are FLAT siblings inside a
// flex-row-reverse track, in DOM order max..1. Because CSS sibling
// combinators only reach *later* siblings, reversing the source order lets a
// checked (or hovered) star's general-sibling rule light up itself AND every
// star that follows it in DOM — i.e. every star to its visual LEFT — which is
// exactly what a star rating expects. The whole thing is one <input> + one
// <label> per star; the browser does the rest.

const ratingBase = "inline-flex w-fit items-center"

// flex-row-reverse so DOM order 5..1 paints visually left-to-right as 1..5.
const trackBase = "flex flex-row-reverse items-center justify-end"

// The native radio. Clipped to zero box (still focusable + in the a11y tree).
// `peer` so the adjacent <label> can react to :checked / :hover / :disabled.
const inputBase = "peer/star sr-only"

// The clickable star icon = a <label> for its radio.
//   text-muted-foreground  → empty star
//   text-primary + fill     → active star
// A named peer modifier (peer-checked/star) compiles to a GENERAL sibling
// selector (.peer\/star:checked ~ label). Because the inputs sit before the
// stars they should fill (DOM order max..1 under flex-row-reverse), checking
// or hovering star N automatically lights N and every star after it in DOM —
// i.e. N and everything to its visual left. No extra cascade rules needed.
const labelBase =
  "cursor-pointer p-0.5 text-muted-foreground transition-colors " +
  "peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 " +
  "peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current " +
  "peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current " +
  "peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50"

const starSize: Record<string, string> = {
  sm: "size-4",
  default: "size-6",
  lg: "size-7",
}

const gapForSize: Record<string, string> = {
  sm: "gap-0.5",
  default: "gap-0.5",
  lg: "gap-1",
}

type RatingProps = {
  // Form field name shared by every star radio — groups them and is the key
  // submitted with the chosen value.
  name: string
  // Number of stars. Default 5.
  max?: number
  // Pre-selected value (1..max). Renders the matching radio `checked`.
  value?: number
  // Disable the whole control — every radio becomes unfocusable + unsubmitted.
  disabled?: boolean
  // Require a selection for native form validation. Applied to the first star
  // radio; the browser treats any required radio in a name-group as making
  // the whole group required.
  // repos/mdn/files/en-us/web/html/reference/elements/input/radio/index.md
  required?: boolean
  size?: keyof typeof starSize
  // Builds each star's accessible name, e.g. (n,max) => `${n} of ${max} stars`.
  label?: (n: number, max: number) => string
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  class?: ClassValue
  // Spread onto the radiogroup wrapper: hx-*, data-*, aria-*, id, …
  [key: string]: unknown
}

const defaultLabel = (n: number, max: number) =>
  `${n} ${n === 1 ? "star" : "stars"} out of ${max}`

export function Rating(props: RatingProps) {
  const {
    name,
    max = 5,
    value,
    disabled,
    required,
    size = "default",
    label = defaultLabel,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    class: className,
    ...rest
  } = props

  const sz = starSize[size] ?? starSize.default
  const gap = gapForSize[size] ?? gapForSize.default
  // Stars 1..max, rendered in reverse (max..1) so the sibling cascade fills
  // left-to-right correctly under flex-row-reverse.
  const stars = Array.from({ length: max }, (_, i) => max - i)

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabelledby ? undefined : (ariaLabel ?? "Rating")}
      aria-labelledby={ariaLabelledby}
      aria-describedby={ariaDescribedby}
      aria-disabled={disabled ? "true" : undefined}
      aria-required={required ? "true" : undefined}
      data-slot="rating"
      class={cn(ratingBase, className)}
      {...rest}
    >
      <span class={cn(trackBase, gap)}>
        {stars.map((n) => {
          const id = `${name}-star-${n}`
          return (
            <>
              <input
                type="radio"
                id={id}
                name={name}
                value={String(n)}
                checked={value === n || undefined}
                disabled={disabled || undefined}
                required={(required && n === 1) || undefined}
                data-slot="rating-item"
                class={inputBase}
                key={id}
              />
              <label for={id} aria-label={label(n, max)} class={labelBase}>
                <svg
                  class={cn(sz, "shrink-0 rounded-sm stroke-current")}
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke-width="1.75"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  aria-hidden="true"
                >
                  <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
                </svg>
              </label>
            </>
          )
        })}
      </span>
    </div>
  )
}

1. Save the file

Copy rating.html into templates/components/.

2. Use it

templates/components/rating.html
{% from "components/rating.html" import rating %}

<form method="post" action="/review">
  {{ rating(name="score", value=3, required=true) }}
  <button type="submit">Submit review</button>
</form>
View source
templates/components/rating.html
{# Rating macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/rating.tsx. A star rating built as a single-select
   radio group: one native <input type="radio"> per star sharing a `name`.
   The browser handles arrow keys, focus, one-at-a-time, and form submit.
   Fill + hover preview are pure CSS via reversed DOM order + named peers.

   APG: repos/aria-practices/content/patterns/radio/examples/radio-rating.html

   Usage:
     {% from "components/rating.html" import rating %}
     {{ rating(name="score", value=3) }}
     {{ rating(name="score", max=5, size="lg", required=true) }} #}

{% macro rating(
    name,
    max=5,
    value=none,
    size="default",
    disabled=false,
    required=false,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    extra_class="",
    **attrs
) -%}
{%- set sizes = {"sm": "size-4", "default": "size-6", "lg": "size-7"} -%}
{%- set gaps  = {"sm": "gap-0.5", "default": "gap-0.5", "lg": "gap-1"} -%}
{%- set sz = sizes[size] or sizes["default"] -%}
{%- set gap = gaps[size] or gaps["default"] -%}
{%- set label_base = "cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50" -%}
<div role="radiogroup"
     {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% else %} aria-label="{{ aria_label or 'Rating' }}"{% endif %}
     {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
     {%- if disabled %} aria-disabled="true"{% endif %}
     {%- if required %} aria-required="true"{% endif %}
     data-slot="rating"
     class="inline-flex w-fit items-center {{ extra_class }}"
     {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor %}>
  <span class="flex flex-row-reverse items-center justify-end {{ gap }}">
    {%- for n in range(max, 0, -1) %}
    {%- set id = name ~ "-star-" ~ n -%}
    <input type="radio" id="{{ id }}" name="{{ name }}" value="{{ n }}"
           {%- if value == n %} checked{% endif %}
           {%- if disabled %} disabled{% endif %}
           {%- if required and n == 1 %} required{% endif %}
           data-slot="rating-item"
           class="peer/star sr-only">
    <label for="{{ id }}" aria-label="{{ n }} {{ 'star' if n == 1 else 'stars' }} out of {{ max }}" class="{{ label_base }}">
      <svg class="{{ sz }} shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24"
           fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
      </svg>
    </label>
    {%- endfor %}
  </span>
</div>
{%- endmacro %}

1. Save the file

Add rating.tmpl alongside your templates.

2. Use it

components/rating.tmpl
{{/* Stars must be passed in reverse (max..1) — plain
  text/template has no numeric range. */}}
<form method="post" action="/review">
  {{template "rating" (dict "Name" "score" "Max" 5 "Stars" .Stars "Value" 3 "Required" true)}}
  <button type="submit">Submit review</button>
</form>
View source
components/rating.tmpl
{{/*
  Rating template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/rating.tsx. A star rating built as a single-select
  radio group: one native <input type="radio"> per star sharing a `name`.
  The browser handles arrow keys, focus, one-at-a-time, and form submit.
  Fill + hover preview are pure CSS via reversed DOM order + named peers.

  APG: repos/aria-practices/content/patterns/radio/examples/radio-rating.html

  Args (dict). Plain text/template has no numeric range, so the caller passes
  the star numbers in REVERSE order (max..1) as .Stars — e.g. for a 5-star
  control, (intSlice 5 4 3 2 1) or a precomputed []int{5,4,3,2,1}:
    Name (string, required), Stars ([]int, e.g. {5,4,3,2,1}),
    Max (int, total — used only for the aria-label text, default 5),
    Value (int, the checked star), SizeClass (string, default "size-6"),
    GapClass (string, default "gap-0.5"), Disabled (bool), Required (bool),
    AriaLabel, AriaLabelledby, AriaDescribedby, Attrs (map).

  Usage:
    {{template "rating" (dict "Name" "score" "Max" 5 "Stars" .Stars "Value" 3)}}
*/}}

{{define "rating"}}
{{- $max := or .Max 5 -}}
{{- $sz := or .SizeClass "size-6" -}}
{{- $gap := or .GapClass "gap-0.5" -}}
{{- $labelBase := "cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50" -}}
<div role="radiogroup"
     {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{else}} aria-label="{{or .AriaLabel "Rating"}}"{{end}}
     {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
     {{- if .Disabled}} aria-disabled="true"{{end}}
     {{- if .Required}} aria-required="true"{{end}}
     data-slot="rating"
     class="inline-flex w-fit items-center"
     {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end}}>
  <span class="flex flex-row-reverse items-center justify-end {{$gap}}">
    {{- $root := . -}}
    {{- range $n := .Stars -}}
    {{- $id := printf "%s-star-%v" $root.Name $n -}}
    <input type="radio" id="{{$id}}" name="{{$root.Name}}" value="{{$n}}"
           {{- if eq $root.Value $n}} checked{{end}}
           {{- if $root.Disabled}} disabled{{end}}
           {{- if and $root.Required (eq $n 1)}} required{{end}}
           data-slot="rating-item"
           class="peer/star sr-only">
    <label for="{{$id}}" aria-label="{{$n}} {{if eq $n 1}}star{{else}}stars{{end}} out of {{$max}}" class="{{$labelBase}}">
      <svg class="{{$sz}} shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24"
           fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
      </svg>
    </label>
    {{- end -}}
  </span>
</div>
{{end}}

1. Save the file

Drop rating.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/rating.ex
<form method="post" action="/review">
  <.rating name="score" value={3} required />
  <button type="submit">Submit review</button>
</form>
View source
lib/my_app_web/components/rating.ex
defmodule ShadcnHtmx.Components.Rating do
  @moduledoc """
  Rating — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/rating.tsx. A star rating built as a single-select
  radio group: one native `<input type="radio">` per star sharing a `name`,
  so the platform handles arrow-key navigation, focus, one-at-a-time, and
  form submission. Fill + hover preview are pure CSS via reversed DOM order
  (`flex-row-reverse` + stars rendered max..1) and named peer modifiers.

  APG: repos/aria-practices/content/patterns/radio/examples/radio-rating.html

  ## Examples

      <.rating name="score" value={3} />
      <.rating name="score" size="lg" required />
  """

  use Phoenix.Component

  @label_base "cursor-pointer p-0.5 text-muted-foreground transition-colors " <>
                "peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 " <>
                "peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current " <>
                "peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current " <>
                "peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50"

  @sizes %{"sm" => "size-4", "default" => "size-6", "lg" => "size-7"}
  @gaps %{"sm" => "gap-0.5", "default" => "gap-0.5", "lg" => "gap-1"}

  attr :name, :string, required: true
  attr :max, :integer, default: 5
  attr :value, :integer, default: nil
  attr :size, :string, default: "default", values: ["sm", "default", "lg"]
  attr :disabled, :boolean, default: false
  attr :required, :boolean, default: false
  attr :"aria-label", :string, default: nil
  attr :"aria-labelledby", :string, default: nil
  attr :"aria-describedby", :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global

  def rating(assigns) do
    assigns =
      assigns
      |> assign(:label_base, @label_base)
      |> assign(:sz, Map.get(@sizes, assigns.size, @sizes["default"]))
      |> assign(:gap, Map.get(@gaps, assigns.size, @gaps["default"]))
      # Reverse order (max..1) so the CSS sibling cascade fills left-to-right
      # under flex-row-reverse.
      |> assign(:stars, Enum.to_list(assigns.max..1//-1))

    ~H"""
    <div
      role="radiogroup"
      aria-label={if @rest[:"aria-labelledby"], do: nil, else: @rest[:"aria-label"] || "Rating"}
      aria-disabled={@disabled && "true"}
      aria-required={@required && "true"}
      data-slot="rating"
      class={["inline-flex w-fit items-center", @class]}
      {@rest}
    >
      <span class={["flex flex-row-reverse items-center justify-end", @gap]}>
        <%= for n <- @stars do %>
          <input
            type="radio"
            id={"#{@name}-star-#{n}"}
            name={@name}
            value={n}
            checked={@value == n}
            disabled={@disabled}
            required={@required && n == 1}
            data-slot="rating-item"
            class="peer/star sr-only"
          />
          <label
            for={"#{@name}-star-#{n}"}
            aria-label={"#{n} #{if n == 1, do: "star", else: "stars"} out of #{@max}"}
            class={@label_base}
          >
            <svg
              class={[@sz, "shrink-0 rounded-sm stroke-current"]}
              viewBox="0 0 24 24"
              fill="none"
              stroke-width="1.75"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
            </svg>
          </label>
        <% end %>
      </span>
    </div>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/rating.html
<form method="post" action="/review">
  <!-- paste snippets/rating.html here -->
  <button type="submit">Submit review</button>
</form>
View source
snippets/rating.html
<!--
  shadcn-htmx — raw HTML rating snippet.

  Mirrors registry/ui/rating.tsx. A star rating built as a single-select
  radio group: one native <input type="radio"> per star, all sharing a
  `name`. The browser handles arrow-key navigation, focus management,
  one-selected-at-a-time, and form submission — no JavaScript.

  How the fill works (pure CSS, no script):
    - Stars are laid out in REVERSE DOM order (5,4,3,2,1) inside a
      flex-row-reverse track, so they paint left-to-right as 1..5.
    - Each <label> reacts to its preceding <input> peer. Because CSS sibling
      combinators only reach *later* siblings, checking or hovering star N
      lights N and every star after it in DOM (= every star to its visual
      left). That gives the cumulative star fill for free.

  Relies only on theme tokens: text-muted-foreground (empty),
  text-primary (filled), ring-ring/50 (focus).
-->

<div role="radiogroup" aria-label="Rating" data-slot="rating" class="inline-flex w-fit items-center">
  <span class="flex flex-row-reverse items-center justify-end gap-0.5">

    <input type="radio" id="score-star-5" name="score" value="5" data-slot="rating-item"
           class="peer/star sr-only">
    <label for="score-star-5" aria-label="5 stars out of 5"
           class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
      </svg>
    </label>

    <input type="radio" id="score-star-4" name="score" value="4" data-slot="rating-item"
           class="peer/star sr-only">
    <label for="score-star-4" aria-label="4 stars out of 5"
           class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
      </svg>
    </label>

    <input type="radio" id="score-star-3" name="score" value="3" checked data-slot="rating-item"
           class="peer/star sr-only">
    <label for="score-star-3" aria-label="3 stars out of 5"
           class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
      </svg>
    </label>

    <input type="radio" id="score-star-2" name="score" value="2" data-slot="rating-item"
           class="peer/star sr-only">
    <label for="score-star-2" aria-label="2 stars out of 5"
           class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
      </svg>
    </label>

    <input type="radio" id="score-star-1" name="score" value="1" data-slot="rating-item"
           class="peer/star sr-only">
    <label for="score-star-1" aria-label="1 star out of 5"
           class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&_svg]:fill-current peer-focus-visible/star:[&_svg]:ring-2 peer-focus-visible/star:[&_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
      </svg>
    </label>

  </span>
</div>

Examples

Basic — click, hover preview, arrow keys

Hover the stars to preview; click to pick. Tab into the group and press ←/→/↑/↓ to move and select. Nothing is selected until the user chooses.

APG's radio-group rating example puts role="radio" on SVG groups and drives everything with JavaScript. We do the opposite: each star is a real <input type="radio"> sharing a name, so the browser handles arrow keys and one-at-a-time for free. The cumulative fill and hover preview come from rendering the stars in reverse DOM order and using sibling combinators — no script.

<Rating name="score" ariaLabel="Rate this article" />
{{ rating(name="score", aria_label="Rate this article") }}
{{template "rating" (dict "Name" "score" "Max" 5 "Stars" .Stars "AriaLabel" "Rate this article")}}
<.rating name="score" aria-label="Rate this article" />
<div role="radiogroup" aria-label="Rate this article" data-slot="rating" class="inline-flex w-fit items-center">
  <span class="flex flex-row-reverse items-center justify-end gap-0.5">
    <input type="radio" id="ex-basic-score-star-5" name="ex-basic-score" value="5" data-slot="rating-item" class="peer/star sr-only"/>
    <label for="ex-basic-score-star-5" aria-label="5 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
        </polygon>
      </svg>
    </label>
    <input type="radio" id="ex-basic-score-star-4" name="ex-basic-score" value="4" data-slot="rating-item" class="peer/star sr-only"/>
    <label for="ex-basic-score-star-4" aria-label="4 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
        </polygon>
      </svg>
    </label>
    <input type="radio" id="ex-basic-score-star-3" name="ex-basic-score" value="3" data-slot="rating-item" class="peer/star sr-only"/>
    <label for="ex-basic-score-star-3" aria-label="3 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
        </polygon>
      </svg>
    </label>
    <input type="radio" id="ex-basic-score-star-2" name="ex-basic-score" value="2" data-slot="rating-item" class="peer/star sr-only"/>
    <label for="ex-basic-score-star-2" aria-label="2 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
        </polygon>
      </svg>
    </label>
    <input type="radio" id="ex-basic-score-star-1" name="ex-basic-score" value="1" data-slot="rating-item" class="peer/star sr-only"/>
    <label for="ex-basic-score-star-1" aria-label="1 star out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
      <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
        </polygon>
      </svg>
    </label>
  </span>
</div>

Preset value, sizes, disabled

Pre-select a value, scale the stars with size, or lock the control. A disabled rating stays readable but is skipped from the tab order.

Pass value to render the matching radio checked (great for "your rating" or read-back states). The size prop swaps the star dimensions, and disabled sets the HTML attribute on every radio so the whole group is inert and unsubmitted.

<Rating name="score" size="sm" value={2} />
<Rating name="score" value={3} />
<Rating name="score" size="lg" value={5} />
<Rating name="score" value={4} disabled />
{{ rating(name="score", size="sm", value=2) }}
{{ rating(name="score", value=3) }}
{{ rating(name="score", size="lg", value=5) }}
{{ rating(name="score", value=4, disabled=true) }}
{{template "rating" (dict "Name" "score" "Stars" .Stars "SizeClass" "size-4" "Value" 2)}}
{{template "rating" (dict "Name" "score" "Stars" .Stars "Value" 3)}}
{{template "rating" (dict "Name" "score" "Stars" .Stars "SizeClass" "size-7" "GapClass" "gap-1" "Value" 5)}}
{{template "rating" (dict "Name" "score" "Stars" .Stars "Value" 4 "Disabled" true)}}
<.rating name="score" size="sm" value={2} />
<.rating name="score" value={3} />
<.rating name="score" size="lg" value={5} />
<.rating name="score" value={4} disabled />
<div class="flex flex-col gap-4">
  <div role="radiogroup" aria-label="Small, 2 stars" data-slot="rating" class="inline-flex w-fit items-center">
    <span class="flex flex-row-reverse items-center justify-end gap-0.5">
      <input type="radio" id="ex-states-sm-star-5" name="ex-states-sm" value="5" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-sm-star-5" aria-label="5 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-4 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-sm-star-4" name="ex-states-sm" value="4" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-sm-star-4" aria-label="4 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-4 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-sm-star-3" name="ex-states-sm" value="3" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-sm-star-3" aria-label="3 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-4 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-sm-star-2" name="ex-states-sm" value="2" checked="" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-sm-star-2" aria-label="2 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-4 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-sm-star-1" name="ex-states-sm" value="1" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-sm-star-1" aria-label="1 star out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-4 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
    </span>
  </div>
  <div role="radiogroup" aria-label="Default, 3 stars" data-slot="rating" class="inline-flex w-fit items-center">
    <span class="flex flex-row-reverse items-center justify-end gap-0.5">
      <input type="radio" id="ex-states-md-star-5" name="ex-states-md" value="5" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-md-star-5" aria-label="5 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-md-star-4" name="ex-states-md" value="4" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-md-star-4" aria-label="4 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-md-star-3" name="ex-states-md" value="3" checked="" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-md-star-3" aria-label="3 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-md-star-2" name="ex-states-md" value="2" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-md-star-2" aria-label="2 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-md-star-1" name="ex-states-md" value="1" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-md-star-1" aria-label="1 star out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
    </span>
  </div>
  <div role="radiogroup" aria-label="Large, 5 stars" data-slot="rating" class="inline-flex w-fit items-center">
    <span class="flex flex-row-reverse items-center justify-end gap-1">
      <input type="radio" id="ex-states-lg-star-5" name="ex-states-lg" value="5" checked="" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-lg-star-5" aria-label="5 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-7 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-lg-star-4" name="ex-states-lg" value="4" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-lg-star-4" aria-label="4 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-7 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-lg-star-3" name="ex-states-lg" value="3" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-lg-star-3" aria-label="3 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-7 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-lg-star-2" name="ex-states-lg" value="2" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-lg-star-2" aria-label="2 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-7 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-lg-star-1" name="ex-states-lg" value="1" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-lg-star-1" aria-label="1 star out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-7 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
    </span>
  </div>
  <div role="radiogroup" aria-label="Disabled, 4 stars" aria-disabled="true" data-slot="rating" class="inline-flex w-fit items-center">
    <span class="flex flex-row-reverse items-center justify-end gap-0.5">
      <input type="radio" id="ex-states-off-star-5" name="ex-states-off" value="5" disabled="" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-off-star-5" aria-label="5 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-off-star-4" name="ex-states-off" value="4" checked="" disabled="" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-off-star-4" aria-label="4 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-off-star-3" name="ex-states-off" value="3" disabled="" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-off-star-3" aria-label="3 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-off-star-2" name="ex-states-off" value="2" disabled="" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-off-star-2" aria-label="2 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="ex-states-off-star-1" name="ex-states-off" value="1" disabled="" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="ex-states-off-star-1" aria-label="1 star out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
    </span>
  </div>
</div>

Further reading

htmx — save on change

Wrap the rating in a form and POST on every change. The server records the score and swaps a confirmation in lockstep.

For "rate and we'll remember it" flows, persist the pick the moment it's made. hx-trigger="change" on the wrapping <form> fires whenever a star radio is selected and submits the form payload (the score field) to the endpoint, which returns the new status row.

Pick a rating to save it.

<form hx-post="/api/rate" hx-trigger="change"
      hx-target="#status" hx-swap="innerHTML">
  <Rating name="score" ariaLabel="Rate your experience" />
  <p id="status" aria-live="polite" />
</form>
<form hx-post="/api/rate" hx-trigger="change"
      hx-target="#status" hx-swap="innerHTML">
  {{ rating(name="score", aria_label="Rate your experience") }}
  <p id="status" aria-live="polite"></p>
</form>
<form hx-post="/api/rate" hx-trigger="change"
      hx-target="#status" hx-swap="innerHTML">
  {{template "rating" (dict "Name" "score" "Stars" .Stars "AriaLabel" "Rate your experience")}}
  <p id="status" aria-live="polite"></p>
</form>
<form hx-post={~p"/api/rate"} hx-trigger="change"
      hx-target="#status" hx-swap="innerHTML">
  <.rating name="score" aria-label="Rate your experience" />
  <p id="status" aria-live="polite"></p>
</form>
<form hx-post="/docs/rating/save" hx-trigger="change" hx-target="#ex-rating-status" hx-swap="innerHTML" class="flex flex-col gap-3">
  <div role="radiogroup" aria-label="Rate your experience" data-slot="rating" class="inline-flex w-fit items-center">
    <span class="flex flex-row-reverse items-center justify-end gap-0.5">
      <input type="radio" id="score-star-5" name="score" value="5" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="score-star-5" aria-label="5 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="score-star-4" name="score" value="4" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="score-star-4" aria-label="4 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="score-star-3" name="score" value="3" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="score-star-3" aria-label="3 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="score-star-2" name="score" value="2" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="score-star-2" aria-label="2 stars out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
      <input type="radio" id="score-star-1" name="score" value="1" data-slot="rating-item" class="peer/star sr-only"/>
      <label for="score-star-1" aria-label="1 star out of 5" class="cursor-pointer p-0.5 text-muted-foreground transition-colors peer-disabled/star:cursor-not-allowed peer-disabled/star:opacity-50 peer-checked/star:text-primary peer-checked/star:[&amp;_svg]:fill-current peer-hover/star:text-primary peer-hover/star:[&amp;_svg]:fill-current peer-focus-visible/star:[&amp;_svg]:ring-2 peer-focus-visible/star:[&amp;_svg]:ring-ring/50">
        <svg class="size-6 shrink-0 rounded-sm stroke-current" viewBox="0 0 24 24" fill="none" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2">
          </polygon>
        </svg>
      </label>
    </span>
  </div>
  <p id="ex-rating-status" class="text-xs text-muted-foreground" aria-live="polite">Pick a rating to save it.</p>
</form>

API Reference

<Rating>

PropTypeDefaultDescription
name*string
Form field name shared by every star radio. Groups them so the browser allows one selection, and is the key submitted with the chosen value.
maxnumber5
Number of stars to render.
valuenumber
Pre-selected rating (1..max). Renders the matching radio checked.
size"sm"|"default"|"lg""default"
Star dimensions.
disabledbooleanfalse
Disable the whole control. Sets the disabled attribute on every radio, so the group is skipped from the tab order and not submitted.MDNinput disabled
requiredbooleanfalse
Require a selection for native form validation. Applied to the first star radio; the browser treats any required radio in a name-group as making the whole group required.MDN<input type="radio"> required
label(n: number, max: number) => stringn of max stars
Builds each star's accessible name (the aria-label on the per-star label).
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required