Vela UI Docsvenizia.ai

Rating

A star, opinion-scale, or NPS control for capturing and displaying feedback.

npx shadcn@latest add @velaui/rating
npx shadcn@latest add @velaui/rating

Examples

  • Default
  • With value
  • Half star
  • Icon reaction
  • Emoji reaction
  • Nps

View code

Rating · Default

Installation

pnpm dlx velaui add @velaui/rating
pnpm dlx velaui add @velaui/rating

Code

rating-default.tsx
import { Rating } from "@/components/ui/rating";

export default function Example() {
  return <Rating defaultValue={3} max={5} />;
}

View code

Rating · With value

Installation

pnpm dlx velaui add @velaui/rating
pnpm dlx velaui add @velaui/rating

Code

rating-with-value.tsx
import { Rating, RatingLabel } from "@/components/ui/rating";

export default function Example() {
  return (
    <div className="flex items-center gap-2">
      <Rating defaultValue={4} max={5} readOnly />
      <RatingLabel>4.0</RatingLabel>
    </div>
  );
}

View code

Rating · Half star

Installation

pnpm dlx velaui add @velaui/rating
pnpm dlx velaui add @velaui/rating

Code

rating-half-star.tsx
import { Rating } from "@/components/ui/rating";

export default function Example() {
  return <Rating defaultValue={2.5} max={5} allowHalf />;
}

View code

Rating · Icon reaction

Installation

pnpm dlx velaui add @velaui/rating
pnpm dlx velaui add @velaui/rating

Code

rating-icon-reaction.tsx
import { RatingOpinionScale } from "@/components/ui/rating";

export default function Example() {
  return (
    <RatingOpinionScale
      scale={5}
      variant="icon"
      showLabels
      defaultValue="happy"
    />
  );
}

View code

Rating · Emoji reaction

Installation

pnpm dlx velaui add @velaui/rating
pnpm dlx velaui add @velaui/rating

Code

rating-emoji-reaction.tsx
import { RatingOpinionScale } from "@/components/ui/rating";

export default function Example() {
  return (
    <RatingOpinionScale
      scale={3}
      variant="emoji"
      defaultValue="neutral"
    />
  );
}

View code

Rating · Nps

Installation

pnpm dlx velaui add @velaui/rating
pnpm dlx velaui add @velaui/rating

Code

rating-nps.tsx
import { RatingNpsScale } from "@/components/ui/rating";

export default function Example() {
  return <RatingNpsScale scale={5} defaultValue="4" className="gap-x-1" />;
}

Usage

import { Rating } from "@/components/ui/rating";

export default function Example() {
  return <Rating defaultValue={3} max={5} />;
}

Props

Rating

PropTypeDefault
allowHalfbooleanfalse
defaultValuenumber0
disabledbooleanfalse
maxnumber5
onValueChange(value: number) => void—
readOnlybooleanfalse
valuenumber—

Takes these and every prop of RatingProps.

RatingEmoji

PropTypeDefault
emotionrequiredRatingEmotion—
variant"icon" | "emoji""icon"

Takes these and every prop of RatingEmojiProps.

RatingLabel

Takes every prop of RatingLabelProps.

RatingNpsItem

Takes every prop of RatingNpsItemProps.

RatingNpsScale

PropTypeDefault
maxLabelstring"Very happy"
minLabelstring"Very disappointed"
scale5 | 1010

Takes these and every prop of RatingNpsScaleProps.

RatingOpinionItem

PropTypeDefault
emotionrequiredRatingEmotion—
variant"icon" | "emoji""icon"

Takes these and every prop of RatingOpinionItemProps.

RatingOpinionScale

PropTypeDefault
scale2 | 3 | 55
showLabelsbooleanfalse
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,
};