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.json2. Use it
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
/** @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
{% 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
{# 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
{{/* 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
{{/*
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
<form method="post" action="/review">
<.rating name="score" value={3} required />
<button type="submit">Submit review</button>
</form>View source
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
<form method="post" action="/review">
<!-- paste snippets/rating.html here -->
<button type="submit">Submit review</button>
</form>View source
<!--
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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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">
</polygon>
</svg>
</label>
</span>
</div>Further reading
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:[&_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-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:[&_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-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:[&_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-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:[&_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-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:[&_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-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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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-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:[&_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-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:[&_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-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:[&_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-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:[&_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-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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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">
</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.
<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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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">
</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:[&_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">
</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>Further reading
API Reference
<Rating>
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
max | number | 5 | Number of stars to render. |
value | number | — | Pre-selected rating (1..max). Renders the matching radio checked. |
size | "sm"|"default"|"lg" | "default" | Star dimensions. |
disabled | boolean | false | 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 |
required | boolean | false | 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) => string | n of max stars | Builds each star's accessible name (the aria-label on the per-star label). |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required