Rating
A star, opinion-scale, or NPS control for capturing and displaying feedback.
npx shadcn@latest add @velaui/ratingpnpm dlx velaui add @velaui/ratingyarn dlx shadcn@latest add @velaui/ratingbunx --bun shadcn@latest add @velaui/ratingveni add ratingInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/ratingpnpm dlx velaui add @velaui/ratingyarn dlx shadcn@latest add @velaui/ratingbunx --bun shadcn@latest add @velaui/ratingExamples
- Default
- With value
- Half star
- Icon reaction
- Emoji reaction
- Nps
Usage
Props
Rating
| Prop | Type | Default |
|---|---|---|
allowHalf | boolean | false |
defaultValue | number | 0 |
disabled | boolean | false |
max | number | 5 |
onValueChange | (value: number) => void | — |
readOnly | boolean | false |
value | number | — |
Takes these and every prop of RatingProps.
RatingEmoji
| Prop | Type | Default |
|---|---|---|
emotionrequired | RatingEmotion | — |
variant | "icon" | "emoji" | "icon" |
Takes these and every prop of RatingEmojiProps.
RatingLabel
Takes every prop of RatingLabelProps.
RatingNpsItem
Takes every prop of RatingNpsItemProps.
RatingNpsScale
| Prop | Type | Default |
|---|---|---|
maxLabel | string | "Very happy" |
minLabel | string | "Very disappointed" |
scale | 5 | 10 | 10 |
Takes these and every prop of RatingNpsScaleProps.
RatingOpinionItem
| Prop | Type | Default |
|---|---|---|
emotionrequired | RatingEmotion | — |
variant | "icon" | "emoji" | "icon" |
Takes these and every prop of RatingOpinionItemProps.
RatingOpinionScale
| Prop | Type | Default |
|---|---|---|
scale | 2 | 3 | 5 | 5 |
showLabels | boolean | false |
variant | "icon" | "emoji" | "icon" |
Takes these and every prop of RatingOpinionScaleProps.
Dependencies
- npm
class-variance-authority@0.7.1radix-ui@1.6.0- Installs with it
- 5 icons
- npm
@base-ui/react@1.6.0class-variance-authority@0.7.1- Installs with it
- 5 icons
Source
components/ui/rating.tsx
/* eslint-disable react-refresh/only-export-components */
"use client";
import * as React from "react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { SmileyHappy } from "@/components/icons/smiley-happy";
import { SmileyHappyPlus } from "@/components/icons/smiley-happy-plus";
import { SmileyNeutral } from "@/components/icons/smiley-neutral";
import { SmileySad } from "@/components/icons/smiley-sad";
import { Star01 as StarIcon } from "@/components/icons/star-01";
import { cn } from "@/lib/utils";
type EmotionIcon = React.ComponentType<
React.SVGProps<SVGSVGElement> & { size?: number | string }
>;
/* -------------------------------------------------------------------------- */
/* Emotion */
/* -------------------------------------------------------------------------- */
export type RatingEmotion =
"very-disappointed" | "disappointed" | "neutral" | "happy" | "very-happy";
const RATING_EMOTIONS: Record<
RatingEmotion,
{ label: string; icon: EmotionIcon; emoji: string }
> = {
"very-disappointed": {
label: "Very disappointed",
icon: SmileySad,
emoji: "😡",
},
disappointed: { label: "Disappointed", icon: SmileySad, emoji: "🙁" },
neutral: { label: "Neutral", icon: SmileyNeutral, emoji: "😐" },
happy: { label: "Happy", icon: SmileyHappy, emoji: "🙂" },
"very-happy": { label: "Very happy", icon: SmileyHappyPlus, emoji: "😀" },
};
/** Which emotions each opinion scale length is made of. */
const RATING_OPINION_SCALES: Record<2 | 3 | 5, readonly RatingEmotion[]> = {
2: ["disappointed", "happy"],
3: ["disappointed", "neutral", "happy"],
5: ["very-disappointed", "disappointed", "neutral", "happy", "very-happy"],
};
const ratingEmojiVariants = cva(
"inline-flex size-6 items-center justify-center",
{
variants: {
variant: {
icon: "text-icon-strong",
emoji: "text-xl leading-6 text-text-strong",
},
},
defaultVariants: {
variant: "icon",
},
},
);
export type RatingEmojiProps = Omit<React.ComponentProps<"span">, "children"> &
VariantProps<typeof ratingEmojiVariants> & {
emotion: RatingEmotion;
};
function RatingEmoji({
className,
emotion,
variant = "icon",
...props
}: RatingEmojiProps) {
const { icon: Icon, emoji } = RATING_EMOTIONS[emotion];
return (
<span
data-slot="rating-emoji"
data-variant={variant}
data-emotion={emotion}
className={cn(ratingEmojiVariants({ variant }), className)}
{...props}
>
{variant === "emoji" ? emoji : <Icon className="size-6" />}
</span>
);
}
/* -------------------------------------------------------------------------- */
/* Label */
/* -------------------------------------------------------------------------- */
export type RatingLabelProps = React.ComponentProps<"p">;
function RatingLabel({ className, ...props }: RatingLabelProps) {
return (
<p
data-slot="rating-label"
className={cn(
"text-sm leading-[1.4] font-medium text-text-strong",
className,
)}
{...props}
/>
);
}
/* -------------------------------------------------------------------------- */
/* Star */
/* -------------------------------------------------------------------------- */
/** One star glyph — `fill` is the 0…1 fraction painted in the accent colour. */
function RatingStarGlyph({ fill }: { fill: number }) {
return (
<span className="relative inline-flex size-6 shrink-0">
<StarIcon
className="size-6 fill-current text-border-default"
strokeWidth={0}
aria-hidden
/>
{fill > 0 && (
<span
className="absolute inset-y-0 left-0 overflow-hidden"
style={{ width: `${Math.min(fill, 1) * 100}%` }}
aria-hidden
>
<StarIcon
className="size-6 fill-current text-yellow-400"
strokeWidth={0}
/>
</span>
)}
</span>
);
}
export type RatingProps = Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange"
> & {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
max?: number;
/** Allow half-star selection — the design's interactive half-star pattern. */
allowHalf?: boolean;
readOnly?: boolean;
disabled?: boolean;
};
function Rating({
className,
value,
defaultValue = 0,
onValueChange,
max = 5,
allowHalf = false,
readOnly = false,
disabled = false,
...props
}: RatingProps) {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const current = value ?? uncontrolled;
const interactive = !readOnly && !disabled;
const select = (next: number) => {
if (value === undefined) setUncontrolled(next);
onValueChange?.(next);
};
return (
<div
data-slot="rating"
role={interactive ? "group" : "img"}
aria-label={`${current} out of ${max}`}
className={cn("inline-flex items-center gap-0.5", className)}
{...props}
>
{Array.from({ length: max }, (_, index) => {
const fill = Math.min(Math.max(current - index, 0), 1);
return (
<span key={index} className="relative inline-flex">
<RatingStarGlyph fill={fill} />
{interactive && (
<>
{allowHalf && (
<button
type="button"
aria-label={`${index + 0.5} out of ${max}`}
aria-pressed={current === index + 0.5}
onClick={() => select(index + 0.5)}
className="absolute inset-y-0 left-0 w-1/2 cursor-pointer rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow"
/>
)}
<button
type="button"
aria-label={`${index + 1} out of ${max}`}
aria-pressed={current === index + 1}
onClick={() => select(index + 1)}
className={cn(
"absolute inset-y-0 right-0 cursor-pointer rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow",
allowHalf ? "w-1/2" : "w-full",
)}
/>
</>
)}
</span>
);
})}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Opinion scale */
/* -------------------------------------------------------------------------- */
export type RatingOpinionItemProps = React.ComponentProps<typeof RadioGroupPrimitive.Item> & {
emotion: RatingEmotion;
variant?: "icon" | "emoji";
};
/**
* One selectable face. Renders icon-only at 40px; pass children to get the
* design's labelled 64px item — the label sits inside the highlight.
*/
function RatingOpinionItem({
className,
children,
emotion,
variant = "icon",
...props
}: RatingOpinionItemProps) {
return (
<RadioGroupPrimitive.Item
data-slot="rating-opinion-item"
data-emotion={emotion}
className={cn(
"inline-flex min-w-10 flex-col items-center justify-center gap-1 rounded-xl py-2 text-sm leading-[1.4] font-normal text-text-strong transition-colors hover:bg-bg-surface-1 focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow data-[state=checked]:bg-bg-surface-1 data-[state=checked]:outline-[1.5px] data-[state=checked]:-outline-offset-[1.5px] data-[state=checked]:outline-border-active disabled:pointer-events-none disabled:text-text-disabled",
className,
)}
{...props}
>
<RatingEmoji emotion={emotion} variant={variant} />
{children}
</RadioGroupPrimitive.Item>
);
}
export type RatingOpinionScaleProps = Omit<
React.ComponentProps<typeof RadioGroupPrimitive.Root>,
"children"
> & {
scale?: 2 | 3 | 5;
variant?: "icon" | "emoji";
/** Render the emotion caption underneath each face. */
showLabels?: boolean;
};
function RatingOpinionScale({
className,
scale = 5,
variant = "icon",
showLabels = false,
...props
}: RatingOpinionScaleProps) {
return (
<RadioGroupPrimitive.Root
data-slot="rating-opinion-scale"
data-variant={variant}
className={cn(
"flex items-start gap-1",
showLabels ? "gap-x-5" : "",
className,
)}
{...props}
>
{RATING_OPINION_SCALES[scale].map((emotion) => (
<div key={emotion} className="flex flex-col items-center gap-1">
<RatingOpinionItem
value={emotion}
emotion={emotion}
variant={variant}
aria-label={RATING_EMOTIONS[emotion].label}
/>
{showLabels && (
<span className="text-xs leading-4 font-normal text-text-strong">
{RATING_EMOTIONS[emotion].label}
</span>
)}
</div>
))}
</RadioGroupPrimitive.Root>
);
}
/* -------------------------------------------------------------------------- */
/* NPS scale */
/* -------------------------------------------------------------------------- */
export type RatingNpsItemProps = React.ComponentProps<typeof RadioGroupPrimitive.Item>;
function RatingNpsItem({
className,
...props
}: RatingNpsItemProps) {
return (
<RadioGroupPrimitive.Item
data-slot="rating-nps-item"
className={cn(
"inline-flex h-10 min-w-10 items-center justify-center gap-2 rounded-xl border border-border-default bg-bg-main px-2 text-sm leading-[1.4] font-normal text-text-strong transition-colors hover:bg-bg-surface-1 focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow data-[state=checked]:border-transparent data-[state=checked]:bg-bg-surface-1 data-[state=checked]:outline-[1.5px] data-[state=checked]:-outline-offset-[1.5px] data-[state=checked]:outline-border-active disabled:pointer-events-none disabled:text-text-disabled",
className,
)}
{...props}
/>
);
}
export type RatingNpsScaleProps = Omit<
React.ComponentProps<typeof RadioGroupPrimitive.Root>,
"children"
> & {
/** Highest selectable score — the scale runs 0…scale. */
scale?: 5 | 10;
minLabel?: string;
maxLabel?: string;
};
function RatingNpsScale({
className,
scale = 10,
minLabel = "Very disappointed",
maxLabel = "Very happy",
...props
}: RatingNpsScaleProps) {
return (
<div
data-slot="rating-nps-scale"
className={cn("inline-flex flex-col gap-4", className)}
>
<RadioGroupPrimitive.Root className="flex gap-2" {...props}>
{Array.from({ length: scale + 1 }, (_, score) => (
<RatingNpsItem key={score} value={String(score)}>
{score}
</RatingNpsItem>
))}
</RadioGroupPrimitive.Root>
<div className="flex justify-between text-sm leading-[1.4] font-normal text-text-strong">
<span>{minLabel}</span>
<span>{maxLabel}</span>
</div>
</div>
);
}
export {
Rating,
RatingEmoji,
RatingLabel,
RatingNpsItem,
RatingNpsScale,
RatingOpinionItem,
RatingOpinionScale,
RATING_EMOTIONS,
RATING_OPINION_SCALES,
ratingEmojiVariants,
};components/ui/rating.tsx
/* eslint-disable react-refresh/only-export-components */
"use client";
import * as React from "react";
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
import { cva, type VariantProps } from "class-variance-authority";
import { SmileyHappy } from "@/components/icons/smiley-happy";
import { SmileyHappyPlus } from "@/components/icons/smiley-happy-plus";
import { SmileyNeutral } from "@/components/icons/smiley-neutral";
import { SmileySad } from "@/components/icons/smiley-sad";
import { Star01 as StarIcon } from "@/components/icons/star-01";
import { cn } from "@/lib/utils";
type EmotionIcon = React.ComponentType<
React.SVGProps<SVGSVGElement> & { size?: number | string }
>;
/* -------------------------------------------------------------------------- */
/* Emotion */
/* -------------------------------------------------------------------------- */
export type RatingEmotion =
"very-disappointed" | "disappointed" | "neutral" | "happy" | "very-happy";
const RATING_EMOTIONS: Record<
RatingEmotion,
{ label: string; icon: EmotionIcon; emoji: string }
> = {
"very-disappointed": {
label: "Very disappointed",
icon: SmileySad,
emoji: "😡",
},
disappointed: { label: "Disappointed", icon: SmileySad, emoji: "🙁" },
neutral: { label: "Neutral", icon: SmileyNeutral, emoji: "😐" },
happy: { label: "Happy", icon: SmileyHappy, emoji: "🙂" },
"very-happy": { label: "Very happy", icon: SmileyHappyPlus, emoji: "😀" },
};
/** Which emotions each opinion scale length is made of. */
const RATING_OPINION_SCALES: Record<2 | 3 | 5, readonly RatingEmotion[]> = {
2: ["disappointed", "happy"],
3: ["disappointed", "neutral", "happy"],
5: ["very-disappointed", "disappointed", "neutral", "happy", "very-happy"],
};
const ratingEmojiVariants = cva(
"inline-flex size-6 items-center justify-center",
{
variants: {
variant: {
icon: "text-icon-strong",
emoji: "text-xl leading-6 text-text-strong",
},
},
defaultVariants: {
variant: "icon",
},
},
);
export type RatingEmojiProps = Omit<React.ComponentProps<"span">, "children"> &
VariantProps<typeof ratingEmojiVariants> & {
emotion: RatingEmotion;
};
function RatingEmoji({
className,
emotion,
variant = "icon",
...props
}: RatingEmojiProps) {
const { icon: Icon, emoji } = RATING_EMOTIONS[emotion];
return (
<span
data-slot="rating-emoji"
data-variant={variant}
data-emotion={emotion}
className={cn(ratingEmojiVariants({ variant }), className)}
{...props}
>
{variant === "emoji" ? emoji : <Icon className="size-6" />}
</span>
);
}
/* -------------------------------------------------------------------------- */
/* Label */
/* -------------------------------------------------------------------------- */
export type RatingLabelProps = React.ComponentProps<"p">;
function RatingLabel({ className, ...props }: RatingLabelProps) {
return (
<p
data-slot="rating-label"
className={cn(
"text-sm leading-[1.4] font-medium text-text-strong",
className,
)}
{...props}
/>
);
}
/* -------------------------------------------------------------------------- */
/* Star */
/* -------------------------------------------------------------------------- */
/** One star glyph — `fill` is the 0…1 fraction painted in the accent colour. */
function RatingStarGlyph({ fill }: { fill: number }) {
return (
<span className="relative inline-flex size-6 shrink-0">
<StarIcon
className="size-6 fill-current text-border-default"
strokeWidth={0}
aria-hidden
/>
{fill > 0 && (
<span
className="absolute inset-y-0 left-0 overflow-hidden"
style={{ width: `${Math.min(fill, 1) * 100}%` }}
aria-hidden
>
<StarIcon
className="size-6 fill-current text-yellow-400"
strokeWidth={0}
/>
</span>
)}
</span>
);
}
export type RatingProps = Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange"
> & {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
max?: number;
/** Allow half-star selection — the design's interactive half-star pattern. */
allowHalf?: boolean;
readOnly?: boolean;
disabled?: boolean;
};
function Rating({
className,
value,
defaultValue = 0,
onValueChange,
max = 5,
allowHalf = false,
readOnly = false,
disabled = false,
...props
}: RatingProps) {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const current = value ?? uncontrolled;
const interactive = !readOnly && !disabled;
const select = (next: number) => {
if (value === undefined) setUncontrolled(next);
onValueChange?.(next);
};
return (
<div
data-slot="rating"
role={interactive ? "group" : "img"}
aria-label={`${current} out of ${max}`}
className={cn("inline-flex items-center gap-0.5", className)}
{...props}
>
{Array.from({ length: max }, (_, index) => {
const fill = Math.min(Math.max(current - index, 0), 1);
return (
<span key={index} className="relative inline-flex">
<RatingStarGlyph fill={fill} />
{interactive && (
<>
{allowHalf && (
<button
type="button"
aria-label={`${index + 0.5} out of ${max}`}
aria-pressed={current === index + 0.5}
onClick={() => select(index + 0.5)}
className="absolute inset-y-0 left-0 w-1/2 cursor-pointer rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow"
/>
)}
<button
type="button"
aria-label={`${index + 1} out of ${max}`}
aria-pressed={current === index + 1}
onClick={() => select(index + 1)}
className={cn(
"absolute inset-y-0 right-0 cursor-pointer rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow",
allowHalf ? "w-1/2" : "w-full",
)}
/>
</>
)}
</span>
);
})}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Opinion scale */
/* -------------------------------------------------------------------------- */
export type RatingOpinionItemProps = RadioPrimitive.Root.Props & {
emotion: RatingEmotion;
variant?: "icon" | "emoji";
};
/**
* One selectable face. Renders icon-only at 40px; pass children to get the
* design's labelled 64px item — the label sits inside the highlight.
*/
function RatingOpinionItem({
className,
children,
emotion,
variant = "icon",
...props
}: RatingOpinionItemProps) {
return (
<RadioPrimitive.Root
data-slot="rating-opinion-item"
data-emotion={emotion}
className={cn(
"inline-flex min-w-10 flex-col items-center justify-center gap-1 rounded-xl py-2 text-sm leading-[1.4] font-normal text-text-strong transition-colors hover:bg-bg-surface-1 focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow data-checked:bg-bg-surface-1 data-checked:outline-[1.5px] data-checked:-outline-offset-[1.5px] data-checked:outline-border-active data-disabled:pointer-events-none data-disabled:text-text-disabled",
className,
)}
{...props}
>
<RatingEmoji emotion={emotion} variant={variant} />
{children}
</RadioPrimitive.Root>
);
}
export type RatingOpinionScaleProps = Omit<
RadioGroupPrimitive.Props,
"children"
> & {
scale?: 2 | 3 | 5;
variant?: "icon" | "emoji";
/** Render the emotion caption underneath each face. */
showLabels?: boolean;
};
function RatingOpinionScale({
className,
scale = 5,
variant = "icon",
showLabels = false,
...props
}: RatingOpinionScaleProps) {
return (
<RadioGroupPrimitive
data-slot="rating-opinion-scale"
data-variant={variant}
className={cn(
"flex items-start gap-1",
showLabels ? "gap-x-5" : "",
className,
)}
{...props}
>
{RATING_OPINION_SCALES[scale].map((emotion) => (
<div key={emotion} className="flex flex-col items-center gap-1">
<RatingOpinionItem
value={emotion}
emotion={emotion}
variant={variant}
aria-label={RATING_EMOTIONS[emotion].label}
/>
{showLabels && (
<span className="text-xs leading-4 font-normal text-text-strong">
{RATING_EMOTIONS[emotion].label}
</span>
)}
</div>
))}
</RadioGroupPrimitive>
);
}
/* -------------------------------------------------------------------------- */
/* NPS scale */
/* -------------------------------------------------------------------------- */
export type RatingNpsItemProps = RadioPrimitive.Root.Props;
function RatingNpsItem({
className,
...props
}: RatingNpsItemProps) {
return (
<RadioPrimitive.Root
data-slot="rating-nps-item"
className={cn(
"inline-flex h-10 min-w-10 items-center justify-center gap-2 rounded-xl border border-border-default bg-bg-main px-2 text-sm leading-[1.4] font-normal text-text-strong transition-colors hover:bg-bg-surface-1 focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow data-checked:border-transparent data-checked:bg-bg-surface-1 data-checked:outline-[1.5px] data-checked:-outline-offset-[1.5px] data-checked:outline-border-active data-disabled:pointer-events-none data-disabled:text-text-disabled",
className,
)}
{...props}
/>
);
}
export type RatingNpsScaleProps = Omit<
RadioGroupPrimitive.Props,
"children"
> & {
/** Highest selectable score — the scale runs 0…scale. */
scale?: 5 | 10;
minLabel?: string;
maxLabel?: string;
};
function RatingNpsScale({
className,
scale = 10,
minLabel = "Very disappointed",
maxLabel = "Very happy",
...props
}: RatingNpsScaleProps) {
return (
<div
data-slot="rating-nps-scale"
className={cn("inline-flex flex-col gap-4", className)}
>
<RadioGroupPrimitive className="flex gap-2" {...props}>
{Array.from({ length: scale + 1 }, (_, score) => (
<RatingNpsItem key={score} value={String(score)}>
{score}
</RatingNpsItem>
))}
</RadioGroupPrimitive>
<div className="flex justify-between text-sm leading-[1.4] font-normal text-text-strong">
<span>{minLabel}</span>
<span>{maxLabel}</span>
</div>
</div>
);
}
export {
Rating,
RatingEmoji,
RatingLabel,
RatingNpsItem,
RatingNpsScale,
RatingOpinionItem,
RatingOpinionScale,
RATING_EMOTIONS,
RATING_OPINION_SCALES,
ratingEmojiVariants,
};