Rating
A star rating slider with half-star support, keyboard navigation and a hidden form input, plus a JavaScript-free `display` read-out for showing a score.
Preview
Click a star or use arrow keys to set a rating.
Step = 0.5 enables half-star increments.
Non-interactive display of an existing rating.
<div class="flex flex-col gap-6">
{{-- Interactive rating with form value --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Rate this component') }}</label>
<x-ui.rating :value="3" name="rating" />
<p class="text-xs text-muted-foreground">{{ __('Click a star or use arrow keys to set a rating.') }}</p>
</div>
{{-- Half-star mode --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Half-star precision') }}</label>
<x-ui.rating :value="3.5" :step="0.5" name="rating_half" />
<p class="text-xs text-muted-foreground">{{ __('Step = 0.5 enables half-star increments.') }}</p>
</div>
{{-- Read-only display --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Read-only display') }}</label>
<x-ui.rating :value="4" :readOnly="true" />
<p class="text-xs text-muted-foreground">{{ __('Non-interactive display of an existing rating.') }}</p>
</div>
{{-- Size variants --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Size variants') }}</label>
<div class="flex flex-col gap-2">
<x-ui.rating :value="3" size="sm" :readOnly="true" />
<x-ui.rating :value="3" size="md" :readOnly="true" />
<x-ui.rating :value="3" size="lg" :readOnly="true" />
</div>
</div>
</div>
Installation
php artisan ui:add rating
Note
This component ships an Alpine behavior module at
resources/js/ui/rating.js. Import it once from your bundle so it registers on alpine:init:
import './rating.js';
Registry contract
php artisan ui:add rating
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/rating.blade.php -
resources/js/ui/rating.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Rating (`rating`)
A star rating slider with half-star support, keyboard navigation and a hidden form input, plus a JavaScript-free `display` read-out for showing a score.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add rating
```
## Usage
```blade
<div class="flex flex-col gap-6">
{{-- Interactive rating with form value --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Rate this component') }}</label>
<x-ui.rating :value="3" name="rating" />
<p class="text-xs text-muted-foreground">{{ __('Click a star or use arrow keys to set a rating.') }}</p>
</div>
{{-- Half-star mode --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Half-star precision') }}</label>
<x-ui.rating :value="3.5" :step="0.5" name="rating_half" />
<p class="text-xs text-muted-foreground">{{ __('Step = 0.5 enables half-star increments.') }}</p>
</div>
{{-- Read-only display --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Read-only display') }}</label>
<x-ui.rating :value="4" :readOnly="true" />
<p class="text-xs text-muted-foreground">{{ __('Non-interactive display of an existing rating.') }}</p>
</div>
{{-- Size variants --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Size variants') }}</label>
<div class="flex flex-col gap-2">
<x-ui.rating :value="3" size="sm" :readOnly="true" />
<x-ui.rating :value="3" size="md" :readOnly="true" />
<x-ui.rating :value="3" size="lg" :readOnly="true" />
</div>
</div>
</div>
```
## Props
- `value` (int|float, default `0`) — The rating. In display mode it is rendered as-is; in slider mode it is the starting committed value.
- `max` (int, default `5`) — How many stars the scale has.
- `step` (int|float, default `1`) — Granularity. 0.5 turns on half-star hover, clicking and arrow stepping.
- `readOnly` (bool, default `false`) — Keeps the Alpine slider (so x-model/wire:model still bind) but refuses input. For a purely presentational score prefer `display`.
- `display` (bool, default `false`) — Static read-out: no x-data, role="img", and an aria-label reading ":value out of :max stars". The page never imports rating.js for it, so prefer it wherever the stars only report a number.
- `name` (string|null, default `null`) — Form name for the hidden input carrying the value, so the rating posts with the surrounding form.
- `required` (bool, default `false`) — Marks the rating as required: aria-required on the slider and `required` on the hidden input. Native constraint validation SKIPS hidden inputs, so the browser will not block a submit at 0 stars — always enforce the rule server-side as well.
- `size` (sm|md|lg, default `md`) — Star size — 16 / 24 / 32px.
- `label` (string|null, default `null`) — Overrides the accessible name (the slider's "Rating", or the display mode's ":value out of :max stars").
## Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- Asking someone to score something — leave `display` off so the stars are an operable ARIA slider.
- Showing a score that was already given (a review average, a product card) — pass `display` and the stars render statically, with no Alpine.
- Half-star precision is needed: step=0.5.
## Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- The scale is not stars (a 1–10 NPS, a percentage) — use <x-ui.slider> or <x-ui.progress>.
- You only want a count of reviews — that is text, not this.
## Anti-patterns
- Hiding a small comparable set in a dropdown
## Rules
- Use the `<brok:rating>` tag (or `<x-ui.rating>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/rating
- Registry JSON (files, props, contract): https://brokui.dev/r/open/rating.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<div class="flex w-full max-w-sm flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">{{ __('Review score') }}</span>
<div class="flex items-center gap-2">
<x-ui.rating :value="4.5" :step="0.5" size="sm" display />
<span class="text-sm text-muted-foreground">{{ __('4.5 out of 5 (128 reviews)') }}</span>
</div>
<p class="text-xs text-muted-foreground">{{ __('Static read-out: no Alpine, role="img", the score is in the accessible name.') }}</p>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">{{ __('Required in a form') }}</span>
<x-ui.rating :value="0" name="review_rating" required />
<p class="text-xs text-muted-foreground">{{ __('Hidden inputs are skipped by native validation, so validate the rule on the server too.') }}</p>
</div>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | int | float | 0 | The rating. In display mode it is rendered as-is; in slider mode it is the starting committed value. |
| max | int | 5 | How many stars the scale has. |
| step | int | float | 1 | Granularity. 0.5 turns on half-star hover, clicking and arrow stepping. |
| readOnly | bool | false | Keeps the Alpine slider (so x-model/wire:model still bind) but refuses input. For a purely presentational score prefer `display`. |
| display | bool | false | Static read-out: no x-data, role="img", and an aria-label reading ":value out of :max stars". The page never imports rating.js for it, so prefer it wherever the stars only report a number. |
| name | string | null | null | Form name for the hidden input carrying the value, so the rating posts with the surrounding form. |
| required | bool | false | Marks the rating as required: aria-required on the slider and `required` on the hidden input. Native constraint validation SKIPS hidden inputs, so the browser will not block a submit at 0 stars — always enforce the rule server-side as well. |
| size | sm | md | lg | md | Star size — 16 / 24 / 32px. |
| label | string | null | null | Overrides the accessible name (the slider's "Rating", or the display mode's ":value out of :max stars"). |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Installs a lazy-loaded JavaScript behavior module.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Choose one option from a small visible set.
Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- Asking someone to score something — leave `display` off so the stars are an operable ARIA slider.
- Showing a score that was already given (a review average, a product card) — pass `display` and the stars render statically, with no Alpine.
- Half-star precision is needed: step=0.5.
Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- The scale is not stars (a 1–10 NPS, a percentage) — use <x-ui.slider> or <x-ui.progress>.
- You only want a count of reviews — that is text, not this.
Use instead
- Select or combobox for long option sets
Anti-patterns
- Hiding a small comparable set in a dropdown
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- ArrowUp ArrowDown ArrowLeft ArrowRight Home End
- Focus
managed
- The interactive rating is one role="slider" with aria-valuemin, aria-valuemax, aria-valuenow and an "N of max" aria-valuetext; the stars are aria-hidden.
- Arrow keys step by the configured step, Home and End jump to 0 and max; give the slider a label that names what is rated.
- The display mode is role="img" with an aria-label that states the score, for read-only ratings.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="rating-{{ $record->id }}">
<div class="flex flex-col gap-6">
{{-- Interactive rating with form value --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Rate this component') }}</label>
<x-ui.rating :value="3" name="rating" />
<p class="text-xs text-muted-foreground">{{ __('Click a star or use arrow keys to set a rating.') }}</p>
</div>
{{-- Half-star mode --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Half-star precision') }}</label>
<x-ui.rating :value="3.5" :step="0.5" name="rating_half" />
<p class="text-xs text-muted-foreground">{{ __('Step = 0.5 enables half-star increments.') }}</p>
</div>
{{-- Read-only display --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Read-only display') }}</label>
<x-ui.rating :value="4" :readOnly="true" />
<p class="text-xs text-muted-foreground">{{ __('Non-interactive display of an existing rating.') }}</p>
</div>
{{-- Size variants --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Size variants') }}</label>
<div class="flex flex-col gap-2">
<x-ui.rating :value="3" size="sm" :readOnly="true" />
<x-ui.rating :value="3" size="md" :readOnly="true" />
<x-ui.rating :value="3" size="lg" :readOnly="true" />
</div>
</div>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:rating
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'value' => 0,
'max' => 5,
'step' => 1,
'readOnly' => false,
'display' => false,
'name' => null,
'required' => false,
'size' => 'md',
'label' => null,
])
{{--
Rating (dice-ui Rating port).
Two modes.
`display` is the static read-out: no Alpine, no slider semantics, just a
`role="img"` group whose aria-label states the score. Reach for it whenever
the stars only report a number — a review score, a product card, a
testimonial — so the page does not load a behaviour module to draw them.
Otherwise the component renders `max` star icons as a single ARIA slider
widget. Supports full-star and half-star (step=0.5) modes. Each star tracks
the hovered / committed fill fraction via Alpine. The root acts as
`role="slider"`; individual stars are `aria-hidden` — the slider widget
conveys the value to AT.
Half-star technique: each star wrapper is `relative overflow-hidden`. An
empty star (`text-muted`) sits at full width; a filled star (`text-primary`)
is absolutely inset and its width is set to a percentage (0 %, 50 %, or
100 %) — width is not a colour token so it passes the raw-colour guard.
--}}
@php
$sizeClasses = match($size) {
'sm' => 'size-4',
'lg' => 'size-8',
default => 'size-6', // md
};
$max = (int) $max;
$value = (float) $value;
$isDisplay = filter_var($display, FILTER_VALIDATE_BOOLEAN);
$isRequired = filter_var($required, FILTER_VALIDATE_BOOLEAN);
// One sentence for AT in display mode; the slider builds its own from
// aria-valuenow/aria-valuetext.
$displayLabel = $label ?? __(':value out of :max stars', [
'value' => rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.') ?: '0',
'max' => $max,
]);
$starBase = 'relative inline-flex shrink-0 '.$sizeClasses;
@endphp
@if ($isDisplay)
{{-- Static read-out. No x-data, so the lazy loader never imports rating.js
for a page that only reports scores. --}}
<div
data-slot="rating"
data-display="true"
role="img"
aria-label="{{ $displayLabel }}"
{{ $attributes->merge(['class' => 'inline-flex items-center gap-1']) }}
>
@for ($i = 1; $i <= $max; $i++)
@php
$fill = $value >= $i ? 100 : ($value >= $i - 0.5 ? 50 : 0);
@endphp
<span aria-hidden="true" data-slot="rating-star" class="{{ $starBase }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-full text-muted" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 21 12 17.77 5.82 21 7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
@if ($fill > 0)
{{-- Width (0–100%) is a layout property, not a colour, so it passes the raw-colour guard. --}}
<span class="absolute inset-0 overflow-hidden" style="width: {{ $fill }}%">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-full text-primary" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 21 12 17.77 5.82 21 7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</span>
@endif
</span>
@endfor
@if ($name)
<input type="hidden" name="{{ $name }}" value="{{ $value }}" @if ($isRequired) required @endif />
@endif
</div>
@else
<div
x-data="uiRating({ value: @js((float)$value), max: @js((int)$max), step: @js((float)$step), readOnly: @js((bool)$readOnly) })"
{{-- The committed value is modelable: `x-model` (Alpine) and `wire:model`
(Livewire, which binds through the same x-model contract) both attach
here. Without either the attribute is inert. --}}
x-modelable="model"
data-slot="rating"
role="slider"
aria-label="{{ $label ?? __('Rating') }}"
aria-valuemin="0"
aria-valuemax="{{ $max }}"
@if ($isRequired) aria-required="true" @endif
:aria-valuenow="value"
:aria-valuetext="value + ' {{ __('of') }} ' + max"
@if(!$readOnly)
tabindex="0"
@keydown="onKey($event)"
@mouseleave="hover = null"
@else
tabindex="-1"
@endif
{{ $attributes->merge(['class' => 'inline-flex items-center gap-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm']) }}
>
@for ($i = 1; $i <= (int)$max; $i++)
{{-- Each star. In half-star mode the pointer's half of the star decides
between i - 0.5 and i (valueAt), for hover preview and for the click. --}}
<span
aria-hidden="true"
data-slot="rating-star"
@if(!$readOnly)
@click="set(valueAt($event, {{ $i }}))"
@mousemove="hover = valueAt($event, {{ $i }})"
@mouseenter="hover = valueAt($event, {{ $i }})"
class="relative inline-flex shrink-0 cursor-pointer {{ $sizeClasses }} transition-transform hover:scale-110 motion-reduce:transition-none"
@else
class="relative inline-flex shrink-0 {{ $sizeClasses }}"
@endif
>
{{-- Empty star (background) --}}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-full text-muted"
aria-hidden="true"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 21 12 17.77 5.82 21 7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{{-- Filled star overlay — clipped to fill fraction via inline width. --}}
{{-- Width (0–100%) is a layout property, not a colour, so it passes the raw-colour guard. --}}
<span
class="absolute inset-0 overflow-hidden"
:style="'width:' + (
display() >= {{ $i }} ? '100' :
display() >= {{ $i }} - 0.5 ? '50' :
'0'
) + '%'"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-full text-primary"
aria-hidden="true"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 21 12 17.77 5.82 21 7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</span>
</span>
@endfor
@if($name)
{{-- `required` is forwarded, but native constraint validation SKIPS hidden
inputs: the browser will not block a submit at 0 stars. It marks the
control for AT (aria-required on the slider) and for any server-side
rule — always validate `required` on the server too. --}}
<input type="hidden" name="{{ $name }}" :value="value" @if ($isRequired) required @endif />
@endif
</div>
@endif
/**
* Rating behavior — star rating slider (dice-ui Rating port).
*
* Exposes `uiRating` Alpine data. The root element acts as a `role="slider"`
* widget; individual stars are purely presentational (aria-hidden). Supports
* full-star and half-star (step=0.5) modes. Hover preview is tracked via
* `hover`; `display()` returns hover value if active, else committed value.
*
* Keyboard contract (ARIA slider pattern):
* ArrowRight / ArrowUp → value + step (clamped to max)
* ArrowLeft / ArrowDown → value - step (clamped to 0)
* Home → 0
* End → max
*
* Self-registers on `alpine:init` so import order does not matter — import
* this file once from your bundle (e.g. resources/js/ui/index.js).
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiRating', (config = {}) => ({
value: config.value ?? 0,
max: config.max ?? 5,
step: config.step ?? 1,
readOnly: config.readOnly ?? false,
hover: null,
/**
* Modelable value (`x-modelable="model"` on the root): the committed
* rating as a number. `x-model` and Livewire's `wire:model` read and
* write it through the same contract.
*/
get model() {
return this.value;
},
set model(v) {
const next = Number(v);
this.value = Number.isFinite(next) ? Math.min(this.max, Math.max(0, next)) : 0;
},
/** Value used for rendering — hover preview takes precedence. */
display() {
return this.hover ?? this.value;
},
/** Set committed value (no-op in readOnly mode). */
set(v) {
if (!this.readOnly) {
this.value = v;
}
},
/**
* The value a pointer over star `i` stands for: the whole star, or in
* half-star mode (step < 1) the half the pointer is on. RTL mirrors it.
*/
valueAt(event, i) {
if (this.step >= 1) return i;
const rect = event.currentTarget.getBoundingClientRect();
const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
const ratio = (event.clientX - rect.left) / rect.width;
const firstHalf = rtl ? ratio > 0.5 : ratio < 0.5;
return firstHalf ? i - 0.5 : i;
},
/**
* Handle keyboard events on the slider root.
* Prevents default scroll behaviour for arrow keys.
*/
onKey(e) {
if (this.readOnly) return;
const clamp = (v) => Math.min(this.max, Math.max(0, v));
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
e.preventDefault();
this.value = clamp(
Math.round((this.value + this.step) * 100) / 100,
);
break;
case 'ArrowLeft':
case 'ArrowDown':
e.preventDefault();
this.value = clamp(
Math.round((this.value - this.step) * 100) / 100,
);
break;
case 'Home':
e.preventDefault();
this.value = 0;
break;
case 'End':
e.preventDefault();
this.value = this.max;
break;
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0