Use it for
Use BbRating for a small whole-number judgement such as an order review.
Use something else when
BbSlider— the value is continuous or fractionalBbRadioGroup— each point needs a word label
Also see: Validated forms.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
Validate an order review
Inside a BbForm the rating joins the form under its name. Give it a legend and rules="required", a rule you register once that refuses null: the form blocks the submit and moves focus to the stars.
<template>
<BbForm
:model="review"
class="flex max-w-sm flex-col gap-4"
@submit="onSubmit"
>
<!-- No v-model on the fields: the form holds `rating` and `feedback`. -->
<BbRating
id="review-score"
description="Order #10482, delivered on 8 July 2026."
legend="Rate your order"
name="rating"
rules="required"
:warnings="lowScore ? ['One or two stars opens a support ticket.'] : []"
/>
<BbTextarea
v-if="review.rating != null && review.rating <= 3"
id="review-followup"
label="What went wrong?"
name="feedback"
placeholder="Late delivery, damaged packaging…"
:rows="3"
/>
<BbButton
class="self-start"
type="submit"
variant="primary"
>
Submit review
</BbButton>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]" role="status">
{{ status }}
</p>
</BbForm>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbForm, BbRating, BbTextarea } from 'bitboss-ui';
import { delay } from '~/demo-data';
const review = reactive({
rating: null as number | null,
feedback: null as string | null,
});
// In an app this lives once in a validators module, not in the component.
// Unrated is `null`, never `0`: that is the value the rule refuses.
defineRule('required', (value: unknown) => required(value) || 'Required.');
const lowScore = computed(() => review.rating != null && review.rating <= 2);
const status = ref('');
/** Runs only when the rating passes. */
const onSubmit = async (values: Record<string, unknown>) => {
await delay(null, 500); // stands in for the POST of `values`
status.value = `Thanks, your ${values.rating}-star review is in.`;
};
</script>
On a rating joined to a form the rule is the requirement, and it also marks the legend. required there is only a hint: it marks the legend and checks nothing. Outside a form, required is the prop to use.
The stars are native radio inputs: Tab enters the group once and arrow keys change the value. Without a name, the form keys the value by the legend, so pass one.
Clear and choose the scale
clearable adds the only path back to unrated: click the selected star again or press Delete or Backspace.
<template>
<BbRating
id="clearable-order"
v-model="score"
class="max-w-sm"
clearable
hint="Click the picked star or press Delete to clear."
legend="Rate your order"
name="order"
persistent-hint
>
<template #append>
<span class="ml-3 text-sm text-[color:var(--bb-text-muted)] tabular-nums">
{{ score === null ? 'Not rated' : `${score} of 5` }}
</span>
</template>
</BbRating>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbRating } from 'bitboss-ui';
// Cleared is `null`, not `0`, so the model has to admit null.
const score = ref<number | null>(3);
</script>
The cleared value is null, never 0. Explain the gesture in a hint that stays visible (persistent-hint). stars changes both the number of choices and maximum value; beyond about ten, prefer labels or a slider.
Display an existing score
Use readonly for a score people may inspect; unlike disabled, it stays in the tab order.
Anchor USB-C Dock, 11-in-1
<template>
<article
class="flex max-w-sm flex-col gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div class="flex flex-col gap-1">
<h3 class="m-0 text-sm font-semibold">{{ product.name }}</h3>
<!-- Round for the stars; the exact figure is text, because there is
no such thing as half a star here. -->
<BbRating
id="product-rating"
hide-legend
:legend="`Average rating for ${product.name}`"
:model-value="Math.round(product.rating)"
name="product-rating"
readonly
size="sm"
>
<template #append>
<span
class="ml-2 text-sm text-[color:var(--bb-text-muted)] tabular-nums"
>
{{ product.rating.toFixed(1) }} · {{ reviewCount }} reviews
</span>
</template>
</BbRating>
</div>
<!-- disabled: out of the tab order, so say why next to it. -->
<BbRating
id="product-my-rating"
description="You can rate it once your order arrives."
disabled
legend="Your rating"
name="product-my-rating"
size="sm"
/>
</article>
</template>
<script setup lang="ts">
import { BbRating } from 'bitboss-ui';
import { products } from '~/demo-data';
// A star fills only once the value reaches it (a raw 4.6 would light four, not
// five), so pass a rounded whole number and print the exact figure beside it.
const product = products.find((p) => p.id === 109)!; // Anchor USB-C Dock, 4.3
const reviewCount = 128;
</script>
There are no half stars. Round for the stars and print the precise decimal and sample size in the append slot.
Size, icon and colour
size takes the shared scale, xs to 2xl; md is the default. Use sm or xs for a score inline with text.
- xs
- sm
- md
- lg
- xl
- 2xl
<template>
<dl class="grid max-w-md grid-cols-[3rem_1fr] items-center gap-x-4 gap-y-3">
<template v-for="size in sizes" :key="size">
<dt class="font-mono text-xs text-[color:var(--bb-text-muted)]">
{{ size }}
</dt>
<dd class="m-0">
<BbRating
:id="`rating-size-${size}`"
hide-legend
:legend="`Rating, size ${size}`"
:model-value="4"
:name="`rating-size-${size}`"
readonly
:size="size"
/>
</dd>
</template>
</dl>
</template>
<script setup lang="ts">
import { BbRating } from 'bitboss-ui';
// The shared size scale. `md` is the default.
const sizes = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const;
</script>
Colour the stars by setting three variables on the root (a class or pt:root), never with a text-colour class: --accent for a filled or previewed star, --empty for an unfilled one, and --border-color for the outline. The outline defaults to var(--bb-text-faint); set --border-color: transparent for a glyph with no outline.
<template>
<div class="flex max-w-sm flex-col gap-5">
<!-- The colours are variables on the root: `--accent` for a filled or
hover-previewed star, `--empty` for the rest, `--border-color` for
the outline. Errors, warnings and disabled still re-point them. -->
<BbRating
id="glyph-amber"
v-model="stay"
legend="How was your stay?"
name="stay"
class="[--accent:var(--color-amber-400)] [--border-color:var(--color-amber-500)]"
/>
<BbRating
id="glyph-love"
v-model="love"
legend="How much do you love this release?"
name="love"
class="[--accent:var(--color-rose-500)] [--border-color:transparent] [--empty:var(--bb-text-faint)]"
>
<!-- `size` arrives as a resolved CSS length string: pass it through.
`checked` is true on every lit heart: fill those. The fill follows
currentColor, so the hover preview and error tint still work. -->
<template #icon="{ size, checked }">
<BbIcon
:class="{ '[&_path]:fill-current': checked }"
icon="lucide:heart"
:size="size"
/>
</template>
</BbRating>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbIcon, BbRating } from 'bitboss-ui';
const stay = ref<number | null>(4);
const love = ref<number | null>(3);
</script>
The icon slot swaps the star and passes size as a CSS length and checked on every lit star; pass size straight to BbIcon. A colour class on the icon or pt:item freezes the hover preview (bitboss-ui/no-star-color flags it); keep pt:item for spacing and pt:item:selected="drop-shadow-sm".
Coming from v2color → --accent
The removed color prop becomes --accent on the root. input-position, which did nothing in v2, now aligns the stars: delete any alignment workaround.