Rating
Lets users rate items using stars in a simple, interactive way. Provides immediate visual feedback and supports partial or full selections.
<script setup lang="ts">
import { ref } from 'vue'
import { Rating } from 'frappe-ui'
const value = ref(0)
</script>
<template>
<Rating v-model="value" label="Rate this article" />
</template>Sizes
<script setup lang="ts">
import { Rating } from 'frappe-ui'
</script>
<template>
<div class="flex flex-col gap-4 items-start">
<Rating size="sm" :model-value="3" label="Small" />
<Rating size="md" :model-value="3" label="Medium" />
<Rating size="lg" :model-value="3" label="Large" />
<Rating size="xl" :model-value="3" label="Extra large" />
</div>
</template>Max stars
max controls the number of stars rendered. Defaults to 5.
<script setup lang="ts">
import { Rating } from 'frappe-ui'
</script>
<template>
<div class="flex flex-col gap-4 items-start">
<Rating :model-value="3" :max="5" label="5 stars" />
<Rating :model-value="6" :max="10" label="10 stars" />
</div>
</template>Half stars
Set step="0.5" to allow half-star ratings. The control switches its ARIA role to slider so screen readers can announce non-integer values.
<script setup lang="ts">
import { ref } from 'vue'
import { Rating } from 'frappe-ui'
const value = ref(3.5)
</script>
<template>
<Rating v-model="value" :step="0.5" label="Half-star rating" />
</template>Clearing
Clicking the currently-selected star (or pressing 0) clears the rating to 0. This is the default behavior — there is no clearable prop.
To opt out, use :model-value + @update:model-value and drop the 0 update yourself:
<Rating
:model-value="value"
@update:model-value="(v) => { if (v !== 0) value = v }"
/><script setup lang="ts">
import { ref } from 'vue'
import { Rating } from 'frappe-ui'
// Default behavior: re-clicking the selected star clears the rating to 0.
const clearable = ref(3)
// Opt-out: bind manually and drop the 0 emitted when the user re-clicks
// the selected star. Any non-zero update is accepted as normal.
const sticky = ref(3)
function onSticky(next: number) {
if (next === 0) return
sticky.value = next
}
</script>
<template>
<div class="w-full flex flex-col gap-4 items-start">
<Rating
v-model="clearable"
label="Default — click the selected star to clear"
/>
<Rating
:model-value="sticky"
label="Disabled — re-clicking keeps the value"
@update:model-value="onSticky"
/>
</div>
</template>Custom icon
icon accepts either a Vue component — typically an auto-imported lucide icon (import LucideHeart from '~icons/lucide/heart') — or a string class name (e.g. "lucide-zap") that is rendered on a <span> for use with the shared Lucide Tailwind utility.
<script setup lang="ts">
import { ref } from 'vue'
import { Rating } from 'frappe-ui'
import LucideHeart from '~icons/lucide/heart'
import LucideFlame from '~icons/lucide/flame'
const heart = ref(4)
const flame = ref(3)
const zap = ref(3)
</script>
<template>
<div class="w-full flex flex-col gap-4 items-start">
<Rating v-model="heart" :icon="LucideHeart" label="Hearts (component)" />
<Rating v-model="flame" :icon="LucideFlame" label="Flames (component)" />
<Rating v-model="zap" icon="lucide-zap" label="Zap (lucide-* string)" />
</div>
</template>Custom icon slot
For full control over each star — different content per index (emojis, icons), custom colors, or any other per-star styling — use the #icon slot. It's called once per star and stamped into both half-spans so half-step clipping still works.
The slot receives:
| Prop | Type | Notes |
|---|---|---|
index | number | 1-based star position |
side | 'left' | 'right' | Which half-span this invocation renders into |
state | 'filled' | 'preview' | 'removing' | 'empty' | The current half's state (matches leftState or rightState per side) — drive your color/style off this |
leftState / rightState | same union | Per-half states for step="0.5", exposed if you need both at once |
value | number | Current saved rating |
previewValue | number | null | Value being hovered, or null. Use previewValue ?? value for single-select patterns where hover should preview the selection |
max | number | Total stars |
Per-index content
Render a different element per star — e.g. a mood scale of emojis. Style each one off the state prop with whatever utility classes you like.
<script setup lang="ts">
import { ref } from 'vue'
import { Rating } from 'frappe-ui'
const mood = ref(3)
const moods = ['😞', '🙁', '😐', '🙂', '😊']
const score = ref(2)
const scores = ['💩', '👎', '👌', '👍', '🔥']
</script>
<template>
<div class="w-full flex flex-col gap-4 items-start">
<Rating v-model="mood" label="How was your day?" size="lg">
<template #icon="{ index, value, previewValue }">
<span
class="text-2xl leading-none transition-transform"
:class="
index === (previewValue ?? value) ? 'scale-110' : 'opacity-40'
"
>
{{ moods[index - 1] }}
</span>
</template>
</Rating>
<Rating v-model="score" label="Rate this meal" size="lg">
<template #icon="{ index, value, previewValue }">
<span
class="text-2xl leading-none"
:class="
index === (previewValue ?? value) ? '' : 'grayscale opacity-50'
"
>
{{ scores[index - 1] }}
</span>
</template>
</Rating>
</div>
</template>Custom colors with Tailwind
Map state → Tailwind classes to recolor the control. Use the dark: variant for dark-mode overrides on preview / removing (which need to flip lighter→darker against a dark surface).
<script setup lang="ts">
import { ref } from 'vue'
import { Rating } from 'frappe-ui'
import LucideHeart from '~icons/lucide/heart'
import LucideFlame from '~icons/lucide/flame'
import type { RatingStarState } from 'frappe-ui'
const heart = ref(4)
const flame = ref(3)
const redClasses: Record<RatingStarState, string> = {
filled: 'text-red-500',
preview: 'text-red-300 dark:text-red-700',
removing: 'text-red-200 dark:text-red-800',
empty: 'text-gray-300 dark:text-gray-600',
}
const orangeClasses: Record<RatingStarState, string> = {
filled: 'text-orange-500',
preview: 'text-orange-300 dark:text-orange-700',
removing: 'text-orange-200 dark:text-orange-800',
empty: 'text-gray-300 dark:text-gray-600',
}
</script>
<template>
<div class="w-full flex flex-col gap-4 items-start">
<Rating v-model="heart" label="Hearts">
<template #icon="{ state }">
<LucideHeart
fill="currentColor"
class="size-5"
:class="redClasses[state]"
/>
</template>
</Rating>
<Rating v-model="flame" label="Flames">
<template #icon="{ state }">
<LucideFlame
fill="currentColor"
class="size-5"
:class="orangeClasses[state]"
/>
</template>
</Rating>
</div>
</template>The same pattern works with step="0.5" because state reflects the current half, so each half-span picks up the correct color independently.
<script setup lang="ts">
import { ref } from 'vue'
import { Rating } from 'frappe-ui'
import LucideHeart from '~icons/lucide/heart'
import type { RatingStarState } from 'frappe-ui'
const heart = ref(3.5)
const redClasses: Record<RatingStarState, string> = {
filled: 'text-red-500',
preview: 'text-red-300 dark:text-red-700',
removing: 'text-red-200 dark:text-red-800',
empty: 'text-gray-300 dark:text-gray-600',
}
</script>
<template>
<Rating v-model="heart" :step="0.5" label="Half-star hearts">
<template #icon="{ state }">
<LucideHeart
fill="currentColor"
class="size-5"
:class="redClasses[state]"
/>
</template>
</Rating>
</template>Labeling
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Checkbox, Rating } from 'frappe-ui'
const value = ref(0)
const required = ref(true)
const showError = ref(false)
const error = computed(() =>
showError.value ? 'Please rate before submitting.' : '',
)
</script>
<template>
<div class="flex gap-8 items-start">
<Rating
v-model="value"
label="Quality"
description="How would you rate the experience?"
:error="error"
:required="required"
/>
<div
class="flex flex-col gap-2 items-start border-l border-outline-gray-2 pl-6"
>
<Checkbox v-model="required" label="required" />
<Checkbox v-model="showError" label="show error" />
</div>
</div>
</template>States
<script setup lang="ts">
import { Rating } from 'frappe-ui'
</script>
<template>
<div class="flex flex-col gap-4 items-start">
<Rating label="Default" :model-value="3" />
<Rating label="Required" required :model-value="3" />
<Rating label="Read-only" readonly :model-value="3" />
<Rating
label="With error"
error="Please rate before submitting."
:model-value="3"
/>
</div>
</template>Keyboard
| Mode | Keys | Action |
|---|---|---|
Radiogroup (step="1") | ← / ↑ / → / ↓ | Move focus and select adjacent star |
Home / End | Select first / last star | |
Space / Enter | Select the focused star | |
1–9 | Set the rating to that value | |
Slider (step="0.5") | ← / ↓ | Decrement by step |
→ / ↑ | Increment by step | |
PageUp / PageDown | Increment / decrement by one full star | |
Home / End | Set to 0 / max | |
0–9 | Set the rating to that integer |
Customization
Each star exposes data-attribute hooks for styling:
- Root:
data-slot="control",data-size,data-readonly,data-state="valid|invalid". - Star:
data-slot="star",data-index,data-state="filled|preview|removing|empty". - Half-star fill: each star renders two half-spans with their own
data-statefor half-step granularity.
For color or per-star customization, see Custom icon slot above.
Deprecated rating_from prop
The rating_from prop is kept for backwards compatibility and fires a dev-mode [frappe-ui] Rating.rating_from is deprecated. Use max instead. warning. Use max instead.
<script setup lang="ts">
import { Rating } from 'frappe-ui'
</script>
<!--
`rating_from` is deprecated. Use `max` instead. Mounting this story
fires a one-time `[frappe-ui] Rating.rating_from is deprecated`
warning in the console.
-->
<template>
<Rating label="Legacy field" :model-value="3" :rating_from="10" />
</template>API Reference
Show types
import { type Component } from 'vue'
import type { InputSize } from '../../composables/inputTypes'
import type { InputLabelingProps } from '../../composables/useInputLabeling'
export interface RatingProps extends InputLabelingProps {
/** The current rating value (controlled). In star units, `0..max`, in increments of `step`. */
modelValue?: number
/** Number of stars to render. Defaults to 5. */
max?: number
/**
* Granularity of the rating value. `1` for whole stars, `0.5` for half stars.
* Defaults to `1`.
*/
step?: 1 | 0.5
/**
* Number of stars to render.
* @deprecated Use `max` instead.
*/
rating_from?: number
/** If true, disables interaction and makes the rating read-only. */
readonly?: boolean
/**
* Icon to render for each star. Accepts a Vue component (e.g. an auto-imported
* lucide icon: `import Heart from '~icons/lucide/heart'`).
* The component receives `fill="currentColor"` so closed-path SVGs render filled.
* Defaults to a filled lucide-star.
*/
icon?: string | Component
/** Size of the rating component. */
size?: InputSize
}
export type RatingStarState = 'filled' | 'preview' | 'removing' | 'empty'
export interface RatingIconSlotProps {
/** 1-based star position. */
index: number
/**
* Which half of the star this invocation renders into. The slot is stamped
* once per half so clipping works; use `side` to pick the matching state
* when driving icon color from a slot template under `step === 0.5`.
*/
side: 'left' | 'right'
/** State of the half being rendered — equals `leftState` or `rightState` per `side`. */
state: RatingStarState
/** State of the left half — equals `rightState` when `step === 1`. */
leftState: RatingStarState
/** State of the right half. */
rightState: RatingStarState
/** The current saved rating value. */
value: number
/**
* Value currently being previewed via hover, or `null` when not hovering.
* Combine with `value` for single-select patterns:
* `previewValue ?? value` gives the index to highlight.
*/
previewValue: number | null
/** Total number of stars. */
max: number
}
export interface RatingEmits {
/** Fired when the rating value changes. */
'update:modelValue': [value: number]
}The current rating value (controlled). In star units, `0..max`, in increments of `step`.
Number of stars to render. Defaults to 5.
Granularity of the rating value. `1` for whole stars, `0.5` for half stars. Defaults to `1`.
Deprecated — Use `max` instead.
If true, disables interaction and makes the rating read-only.
Icon to render for each star. Accepts a Vue component (e.g. an auto-imported lucide icon: `import Heart from '~icons/lucide/heart'`). The component receives `fill="currentColor"` so closed-path SVGs render filled. Defaults to a filled lucide-star.
Size of the rating component.
Label rendered above (or beside, for binary controls) the input.
Helper text rendered below the input. Hidden when `error` is set.
Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. May be either a string or an `Error` object whose `messages?: string[]` is rendered as stacked lines (with `Error.message` as the fallback).
Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.
HTML id of the underlying control. Auto-generated via `useId()` if omitted.
| Slot | Payload |
|---|---|
label | { required: boolean; } Overrides the rendered label content. Receives `{ required }`. |
description | — Overrides the rendered description content. |
icon | RatingIconSlotProps Overrides the per-star icon. Called once per star and stamped into both half-spans (so half-step clipping still works). Use `state` to color the icon, or `index` to render different content per position (e.g. emojis). |
Overrides the rendered label content. Receives `{ required }`.
Overrides the rendered description content.
Overrides the per-star icon. Called once per star and stamped into both half-spans (so half-step clipping still works). Use `state` to color the icon, or `index` to render different content per position (e.g. emojis).
| Event | Payload |
|---|---|
update:modelValue | [value: number] Fired when the model value changes. |
Fired when the model value changes.