Skip to content
Brok UI

Loading…

No results

Rating

Open source

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.

Version
v1.3.3
Stability
stable
License
MIT
Related
Slider
GitHub Stars

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.

previews.components.rating.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/rating.blade.php
  • js resources/js/ui/rating.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

rating.md
# 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

display.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

rating rating-star

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

Visible exclusive selection

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
rating rating-star
Theming hooks
rating rating-star

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/rating.blade.php Blade
@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
resources/js/ui/rating.js JS
/**
 * 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