Rating
An accessible rating component that allows users to provide star ratings with support for half values, keyboard navigation, and form integration.
import { Rating, RatingItem } from "@/components/ui/rating";
export function RatingDemo() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h4 className="font-medium text-sm">Basic Rating</h4>
<Rating defaultValue={3}>
{Array.from({ length: 5 }, (_, i) => (
<RatingItem key={i} />
))}
</Rating>
</div>
<div className="flex flex-col gap-2">
<h4 className="font-medium text-sm">Half Steps (LTR)</h4>
<Rating defaultValue={2.5} step={0.5}>
{Array.from({ length: 5 }, (_, i) => (
<RatingItem key={i} />
))}
</Rating>
</div>
<div className="flex flex-col gap-2">
<h4 className="font-medium text-sm">Half Steps (RTL)</h4>
<Rating dir="rtl" defaultValue={2.5} step={0.5}>
{Array.from({ length: 5 }, (_, i) => (
<RatingItem key={i} />
))}
</Rating>
</div>
</div>
);
}
Installation
CLI
npx shadcn@latest add "https://diceui.com/r/rating"
Manual
Install the following dependencies:
npm install @radix-ui/react-slot lucide-react
Copy the refs composition utilities into your lib/compose-refs.ts
file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };
Copy the visually hidden input component into your components/visually-hidden-input.tsx
file.
"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };
Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import { Star } from "lucide-react";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
const ROOT_NAME = "Rating";
const ITEM_NAME = "RatingItem";
const ENTRY_FOCUS = "ratingFocusGroup.onEntryFocus";
const EVENT_OPTIONS = { bubbles: false, cancelable: true };
function getItemId(id: string, value: number) {
return `${id}-item-${value}`;
}
function getPartialFillGradientId(id: string, step: Step) {
return `partial-fill-gradient-${id}-${step}`;
}
type FocusIntent = "first" | "last" | "prev" | "next";
const MAP_KEY_TO_FOCUS_INTENT: Record<string, FocusIntent> = {
ArrowLeft: "prev",
ArrowUp: "prev",
ArrowRight: "next",
ArrowDown: "next",
Home: "first",
End: "last",
};
function getDirectionAwareKey(key: string, dir?: Direction) {
if (dir !== "rtl") return key;
return key === "ArrowLeft"
? "ArrowRight"
: key === "ArrowRight"
? "ArrowLeft"
: key;
}
type ItemElement = React.ComponentRef<typeof RatingItem>;
function getFocusIntent(
event: React.KeyboardEvent<ItemElement>,
dir?: Direction,
orientation?: Orientation,
) {
const key = getDirectionAwareKey(event.key, dir);
if (orientation === "horizontal" && ["ArrowUp", "ArrowDown"].includes(key))
return undefined;
if (orientation === "vertical" && ["ArrowLeft", "ArrowRight"].includes(key))
return undefined;
return MAP_KEY_TO_FOCUS_INTENT[key];
}
function focusFirst(
candidates: React.RefObject<ItemElement | null>[],
preventScroll = false,
) {
const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
for (const candidateRef of candidates) {
const candidate = candidateRef.current;
if (!candidate) continue;
if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
candidate.focus({ preventScroll });
if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
}
}
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
type Direction = "ltr" | "rtl";
type Orientation = "horizontal" | "vertical";
type ActivationMode = "automatic" | "manual";
type Size = "default" | "sm" | "lg";
type Step = 0.5 | 1;
type DataState = "full" | "partial" | "empty";
const DirectionContext = React.createContext<Direction | undefined>(undefined);
function useDirection(dirProp?: Direction): Direction {
const contextDir = React.useContext(DirectionContext);
return dirProp ?? contextDir ?? "ltr";
}
interface StoreState {
value: number;
hoveredValue: number | null;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
}
function createStore(
listenersRef: React.RefObject<Set<() => void>>,
stateRef: React.RefObject<StoreState>,
onValueChange?: (value: number) => void,
onHover?: (value: number | null) => void,
): Store {
const store: Store = {
subscribe: (cb) => {
if (listenersRef.current) {
listenersRef.current.add(cb);
return () => listenersRef.current?.delete(cb);
}
return () => {};
},
getState: () =>
stateRef.current ?? {
value: 0,
hoveredValue: null,
},
setState: (key, value) => {
const state = stateRef.current;
if (!state || Object.is(state[key], value)) return;
if (key === "value" && typeof value === "number") {
state.value = value;
onValueChange?.(value);
} else if (key === "hoveredValue") {
state.hoveredValue = value as number | null;
onHover?.(value as number | null);
} else {
state[key] = value;
}
store.notify();
},
notify: () => {
if (listenersRef.current) {
for (const cb of listenersRef.current) {
cb();
}
}
},
};
return store;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(selector: (state: StoreState) => T): T {
const store = useStoreContext("useStore");
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface ItemData {
id: string;
ref: React.RefObject<ItemElement | null>;
value: number;
disabled: boolean;
}
interface RatingContextValue {
id: string;
dir: Direction;
orientation: Orientation;
activationMode: ActivationMode;
size: Size;
max: number;
step: Step;
clearable: boolean;
disabled: boolean;
readOnly: boolean;
getAutoIndex: (instanceId: string) => number;
}
const RatingContext = React.createContext<RatingContextValue | null>(null);
function useRatingContext(consumerName: string) {
const context = React.useContext(RatingContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface FocusContextValue {
tabStopId: string | null;
onItemFocus: (tabStopId: string) => void;
onItemShiftTab: () => void;
onFocusableItemAdd: () => void;
onFocusableItemRemove: () => void;
onItemRegister: (item: ItemData) => void;
onItemUnregister: (id: string) => void;
getItems: () => ItemData[];
}
const FocusContext = React.createContext<FocusContextValue | null>(null);
function useFocusContext(consumerName: string) {
const context = React.useContext(FocusContext);
if (!context) {
throw new Error(
`\`${consumerName}\` must be used within \`FocusProvider\``,
);
}
return context;
}
interface RatingRootProps extends React.ComponentProps<"div"> {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
onHover?: (value: number | null) => void;
max?: number;
activationMode?: ActivationMode;
dir?: Direction;
orientation?: Orientation;
size?: Size;
asChild?: boolean;
step?: Step;
clearable?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
name?: string;
}
function RatingRoot(props: RatingRootProps) {
const {
value,
defaultValue = 0,
onValueChange,
onHover,
...rootProps
} = props;
const stateRef = useLazyRef(() => ({
value: value ?? defaultValue,
hoveredValue: null,
}));
const listenersRef = useLazyRef(() => new Set<() => void>());
const store = React.useMemo(
() => createStore(listenersRef, stateRef, onValueChange, onHover),
[listenersRef, stateRef, onValueChange, onHover],
);
return (
<StoreContext.Provider value={store}>
<RatingRootImpl {...rootProps} value={value} />
</StoreContext.Provider>
);
}
interface RatingRootImplProps
extends Omit<RatingRootProps, "defaultValue" | "onValueChange" | "onHover"> {}
function RatingRootImpl(props: RatingRootImplProps) {
const {
value,
id: idProp,
dir: dirProp,
orientation = "horizontal",
activationMode = "automatic",
size = "default",
max = 5,
step = 1,
clearable = false,
asChild,
disabled = false,
readOnly = false,
required = false,
name,
className,
ref,
...rootProps
} = props;
const store = useStoreContext("RatingRootImpl");
useIsomorphicLayoutEffect(() => {
if (value !== undefined) {
store.setState("value", value);
}
}, [value]);
const dir = useDirection(dirProp);
const id = React.useId();
const rootId = idProp ?? id;
const currentValue = useStore((state) => state.value);
const [formTrigger, setFormTrigger] = React.useState<HTMLDivElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const [tabStopId, setTabStopId] = React.useState<string | null>(null);
const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false);
const [focusableItemCount, setFocusableItemCount] = React.useState(0);
const isClickFocusRef = React.useRef(false);
const itemsRef = React.useRef<Map<string, ItemData>>(new Map());
const autoIndexMapRef = React.useRef(new Map<string, number>());
const nextAutoIndexRef = React.useRef(0);
const getAutoIndex = React.useCallback((instanceId: string) => {
const existingIndex = autoIndexMapRef.current.get(instanceId);
if (existingIndex !== undefined) {
return existingIndex;
}
const newIndex = nextAutoIndexRef.current++;
autoIndexMapRef.current.set(instanceId, newIndex);
return newIndex;
}, []);
const onItemFocus = React.useCallback((tabStopId: string) => {
setTabStopId(tabStopId);
}, []);
const onItemShiftTab = React.useCallback(() => {
setIsTabbingBackOut(true);
}, []);
const onFocusableItemAdd = React.useCallback(() => {
setFocusableItemCount((prevCount) => prevCount + 1);
}, []);
const onFocusableItemRemove = React.useCallback(() => {
setFocusableItemCount((prevCount) => prevCount - 1);
}, []);
const onItemRegister = React.useCallback((item: ItemData) => {
itemsRef.current.set(item.id, item);
}, []);
const onItemUnregister = React.useCallback((id: string) => {
itemsRef.current.delete(id);
}, []);
const getItems = React.useCallback(() => {
return Array.from(itemsRef.current.values())
.filter((item) => item.ref.current)
.sort((a, b) => {
const elementA = a.ref.current;
const elementB = b.ref.current;
if (!elementA || !elementB) return 0;
const position = elementA.compareDocumentPosition(elementB);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1;
}
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
return 1;
}
return 0;
});
}, []);
const onBlur = React.useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
rootProps.onBlur?.(event);
if (event.defaultPrevented) return;
setIsTabbingBackOut(false);
},
[rootProps.onBlur],
);
const onFocus = React.useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
rootProps.onFocus?.(event);
if (event.defaultPrevented) return;
const isKeyboardFocus = !isClickFocusRef.current;
if (
event.target === event.currentTarget &&
isKeyboardFocus &&
!isTabbingBackOut
) {
const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);
event.currentTarget.dispatchEvent(entryFocusEvent);
if (!entryFocusEvent.defaultPrevented) {
const items = Array.from(itemsRef.current.values()).filter(
(item) => !item.disabled,
);
const selectedItem = items.find(
(item) => item.value === currentValue,
);
const currentItem = items.find((item) => item.id === tabStopId);
const candidateItems = [selectedItem, currentItem, ...items].filter(
Boolean,
) as ItemData[];
const candidateRefs = candidateItems.map((item) => item.ref);
focusFirst(candidateRefs, false);
}
}
isClickFocusRef.current = false;
},
[rootProps.onFocus, isTabbingBackOut, currentValue, tabStopId],
);
const onMouseDown = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
rootProps.onMouseDown?.(event);
if (event.defaultPrevented) return;
isClickFocusRef.current = true;
},
[rootProps.onMouseDown],
);
const contextValue = React.useMemo<RatingContextValue>(
() => ({
id: rootId,
dir,
orientation,
activationMode,
disabled,
readOnly,
size,
getAutoIndex,
max,
step,
clearable,
}),
[
rootId,
dir,
orientation,
activationMode,
disabled,
readOnly,
size,
getAutoIndex,
max,
step,
clearable,
],
);
const focusContextValue = React.useMemo<FocusContextValue>(
() => ({
tabStopId,
onItemFocus,
onItemShiftTab,
onFocusableItemAdd,
onFocusableItemRemove,
onItemRegister,
onItemUnregister,
getItems,
}),
[
tabStopId,
onItemFocus,
onItemShiftTab,
onFocusableItemAdd,
onFocusableItemRemove,
onItemRegister,
onItemUnregister,
getItems,
],
);
const RootPrimitive = asChild ? Slot : "div";
return (
<RatingContext.Provider value={contextValue}>
<FocusContext.Provider value={focusContextValue}>
<RootPrimitive
id={rootId}
role="radiogroup"
aria-orientation={orientation}
data-disabled={disabled ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
data-orientation={orientation}
data-slot="rating"
dir={dir}
tabIndex={isTabbingBackOut || focusableItemCount === 0 ? -1 : 0}
{...rootProps}
ref={composedRef}
className={cn(
"flex gap-1 text-primary outline-none",
orientation === "horizontal"
? "flex-row items-center"
: "flex-col items-start",
className,
)}
onBlur={onBlur}
onFocus={onFocus}
onMouseDown={onMouseDown}
/>
<svg width="0" height="0" style={{ position: "absolute" }}>
<defs>
<linearGradient id={getPartialFillGradientId(rootId, step)}>
{dir === "rtl" ? (
<>
<stop offset="50%" stopColor="transparent" />
<stop offset="50%" stopColor="currentColor" />
</>
) : (
<>
<stop offset="50%" stopColor="currentColor" />
<stop offset="50%" stopColor="transparent" />
</>
)}
</linearGradient>
</defs>
</svg>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={currentValue}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</FocusContext.Provider>
</RatingContext.Provider>
);
}
interface RatingItemProps
extends Omit<React.ComponentProps<"button">, "children"> {
index?: number;
asChild?: boolean;
children?: React.ReactNode | ((dataState: DataState) => React.ReactNode);
}
function RatingItem(props: RatingItemProps) {
const { index, asChild, disabled, className, ref, children, ...itemProps } =
props;
const itemRef = React.useRef<ItemElement>(null);
const composedRef = useComposedRefs(ref, itemRef);
const context = useRatingContext(ITEM_NAME);
const instanceId = React.useId();
const actualIndex = React.useMemo(() => {
if (index !== undefined) {
return index;
}
return context.getAutoIndex(instanceId);
}, [index, context, instanceId]);
const itemValue = actualIndex + 1;
const store = useStoreContext(ITEM_NAME);
const focusContext = useFocusContext(ITEM_NAME);
const value = useStore((state) => state.value);
const hoveredValue = useStore((state) => state.hoveredValue);
const clearable = context.clearable;
const step = context.step;
const activationMode = context.activationMode;
const itemId = getItemId(context.id, itemValue);
const isDisabled = context.disabled || disabled;
const isReadOnly = context.readOnly;
const isTabStop = focusContext.tabStopId === itemId;
const displayValue = hoveredValue ?? value;
const isFilled = displayValue >= itemValue;
const isPartiallyFilled =
step < 1 && displayValue >= itemValue - step && displayValue < itemValue;
const isHovered = hoveredValue !== null && hoveredValue < itemValue;
const isMouseClickRef = React.useRef(false);
useIsomorphicLayoutEffect(() => {
focusContext.onItemRegister({
id: itemId,
ref: itemRef,
value: itemValue,
disabled: !!isDisabled,
});
if (!isDisabled) {
focusContext.onFocusableItemAdd();
}
return () => {
focusContext.onItemUnregister(itemId);
if (!isDisabled) {
focusContext.onFocusableItemRemove();
}
};
}, [focusContext, itemId, itemValue, isDisabled]);
const onClick = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
itemProps.onClick?.(event);
if (event.defaultPrevented) return;
if (!isDisabled && !isReadOnly) {
let newValue = itemValue;
if (step < 1) {
const rect = event.currentTarget.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const isLeftHalf = clickX < rect.width / 2;
if (context.dir === "rtl") {
if (!isLeftHalf) {
newValue = itemValue - step;
}
} else {
if (isLeftHalf) {
newValue = itemValue - step;
}
}
}
if (clearable && value === newValue) {
newValue = 0;
}
store.setState("value", newValue);
}
},
[
isDisabled,
isReadOnly,
clearable,
step,
value,
itemValue,
store,
context.dir,
itemProps.onClick,
],
);
const onMouseEnter = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
itemProps.onMouseEnter?.(event);
if (event.defaultPrevented) return;
if (!isDisabled && !isReadOnly) {
let hoverValue = itemValue;
if (step < 1) {
const rect = event.currentTarget.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const isLeftHalf = mouseX < rect.width / 2;
if (context.dir === "rtl") {
if (!isLeftHalf) {
hoverValue = itemValue - step;
}
} else {
if (isLeftHalf) {
hoverValue = itemValue - step;
}
}
}
store.setState("hoveredValue", hoverValue);
}
},
[
isDisabled,
isReadOnly,
step,
itemValue,
store,
context.dir,
itemProps.onMouseEnter,
],
);
const onMouseMove = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
itemProps.onMouseMove?.(event);
if (event.defaultPrevented) return;
if (!isDisabled && !isReadOnly && step < 1) {
const rect = event.currentTarget.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const isLeftHalf = mouseX < rect.width / 2;
let hoverValue = itemValue;
if (context.dir === "rtl") {
hoverValue = !isLeftHalf ? itemValue - step : itemValue;
} else {
hoverValue = isLeftHalf ? itemValue - step : itemValue;
}
store.setState("hoveredValue", hoverValue);
}
},
[
isDisabled,
isReadOnly,
step,
itemValue,
store,
context.dir,
itemProps.onMouseMove,
],
);
const onMouseLeave = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
itemProps.onMouseLeave?.(event);
if (event.defaultPrevented) return;
if (!isDisabled && !isReadOnly) {
store.setState("hoveredValue", null);
}
},
[isDisabled, isReadOnly, store, itemProps.onMouseLeave],
);
const onFocus = React.useCallback(
(event: React.FocusEvent<ItemElement>) => {
itemProps.onFocus?.(event);
if (event.defaultPrevented) return;
focusContext.onItemFocus(itemId);
const isKeyboardFocus = !isMouseClickRef.current;
if (
!isDisabled &&
!isReadOnly &&
activationMode !== "manual" &&
isKeyboardFocus
) {
const newValue = clearable && value === itemValue ? 0 : itemValue;
store.setState("value", newValue);
}
isMouseClickRef.current = false;
},
[
focusContext,
itemId,
activationMode,
isDisabled,
isReadOnly,
clearable,
value,
itemValue,
store,
itemProps.onFocus,
],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<ItemElement>) => {
itemProps.onKeyDown?.(event);
if (event.defaultPrevented) return;
if (
(event.key === "Enter" || event.key === " ") &&
activationMode === "manual"
) {
event.preventDefault();
if (!isDisabled && !isReadOnly && itemRef.current) {
itemRef.current.click();
}
return;
}
if (event.key === "Tab" && event.shiftKey) {
focusContext.onItemShiftTab();
return;
}
if (event.target !== event.currentTarget) return;
const focusIntent = getFocusIntent(
event,
context.dir,
context.orientation,
);
if (focusIntent !== undefined) {
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
return;
event.preventDefault();
const items = focusContext.getItems().filter((item) => !item.disabled);
let candidateRefs = items.map((item) => item.ref);
if (focusIntent === "last") {
candidateRefs.reverse();
} else if (focusIntent === "prev" || focusIntent === "next") {
if (focusIntent === "prev") candidateRefs.reverse();
const currentIndex = candidateRefs.findIndex(
(ref) => ref.current === event.currentTarget,
);
candidateRefs = candidateRefs.slice(currentIndex + 1);
}
queueMicrotask(() => focusFirst(candidateRefs));
}
},
[
focusContext,
context.dir,
context.orientation,
activationMode,
isDisabled,
isReadOnly,
itemProps.onKeyDown,
],
);
const onMouseDown = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
itemProps.onMouseDown?.(event);
if (event.defaultPrevented) return;
isMouseClickRef.current = true;
if (isDisabled) {
event.preventDefault();
} else {
focusContext.onItemFocus(itemId);
}
},
[focusContext, itemId, isDisabled, itemProps.onMouseDown],
);
const dataState: DataState = isFilled
? "full"
: isPartiallyFilled
? "partial"
: "empty";
const ItemPrimitive = asChild ? Slot : "button";
return (
<ItemPrimitive
id={itemId}
role="radio"
type="button"
aria-checked={isFilled}
aria-posinset={itemValue}
aria-setsize={context.max}
data-disabled={isDisabled ? "" : undefined}
data-readonly={isReadOnly ? "" : undefined}
data-state={isFilled ? "full" : isPartiallyFilled ? "partial" : "empty"}
data-hovered={isHovered ? "" : undefined}
data-slot="rating-item"
disabled={isDisabled}
tabIndex={isTabStop ? 0 : -1}
{...itemProps}
ref={composedRef}
style={{
...itemProps.style,
...(isPartiallyFilled && {
"--partial-fill": `url(#${getPartialFillGradientId(context.id, step)})`,
}),
}}
className={cn(
"inline-flex items-center justify-center rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"[&_svg:not([class*='size-'])]:size-full [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:transition-colors [&_svg]:duration-200 data-[state=empty]:[&_svg]:fill-transparent data-[state=full]:[&_svg]:fill-current data-[state=partial]:[&_svg]:fill-[var(--partial-fill)]",
context.size === "sm"
? "size-4"
: context.size === "lg"
? "size-6"
: "size-5",
className,
)}
onClick={onClick}
onFocus={onFocus}
onKeyDown={onKeyDown}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
{typeof children === "function"
? children(dataState)
: (children ?? <Star />)}
</ItemPrimitive>
);
}
export {
RatingRoot as Root,
RatingItem as Item,
//
RatingRoot as Rating,
RatingItem,
//
useStore as useRating,
};
Layout
Import the parts, and compose them together.
import * as Rating from "@/components/ui/rating";
<Rating.Root>
<Rating.Item />
</Rating.Root>
Examples
Themes
Customize the rating component with different colors and icons.
"use client";
import { Heart, Star, Zap } from "lucide-react";
import { cn } from "@/lib/utils";
import { Rating, RatingItem } from "@/components/ui/rating";
const themes = [
{
label: "Default",
description: "Classic star rating",
value: 4,
icon: Star,
className: "text-primary",
},
{
label: "Gold",
description: "Premium gold stars",
value: 5,
icon: Star,
className: "text-yellow-500",
},
{
label: "Heart",
description: "Love & favorites",
value: 5,
icon: Heart,
className: "text-pink-500",
},
{
label: "Energy",
description: "Performance rating",
value: 4,
icon: Zap,
className: "text-orange-500",
},
];
export function RatingThemesDemo() {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{themes.map((theme) => (
<div
key={theme.label}
className="flex flex-col items-center gap-3 rounded-lg border p-4"
>
<h4 className="font-medium text-sm">{theme.label}</h4>
<Rating
defaultValue={theme.value}
className={cn("gap-1", theme.className)}
>
{Array.from({ length: 5 }, (_, i) => (
<RatingItem key={i}>
<theme.icon />
</RatingItem>
))}
</Rating>
<p className="text-muted-foreground text-xs">{theme.description}</p>
</div>
))}
</div>
);
}
Controlled
Control the rating value with state.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Rating, RatingItem } from "@/components/ui/rating";
export function RatingControlledDemo() {
const [rating, setRating] = React.useState(3);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h4 className="font-medium text-sm">Controlled Rating</h4>
<Rating value={rating} onValueChange={setRating}>
{Array.from({ length: 5 }, (_, i) => (
<RatingItem key={i} />
))}
</Rating>
<p className="text-muted-foreground text-sm">
Current rating: {rating}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setRating(0)}>
Clear
</Button>
<Button variant="outline" size="sm" onClick={() => setRating(5)}>
Set to 5
</Button>
<Button variant="outline" size="sm" onClick={() => setRating(2.5)}>
Set to 2.5
</Button>
</div>
</div>
);
}
With Form
Integrate the rating component with form validation.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Rating, RatingItem } from "@/components/ui/rating";
const FormSchema = z.object({
rating: z.number().min(1, {
message: "Please provide a rating.",
}),
});
export function RatingFormDemo() {
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
rating: 0,
},
});
const onSubmit = React.useCallback((data: z.infer<typeof FormSchema>) => {
toast.success(`You rated: ${data.rating} stars`);
}, []);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
<FormField
control={form.control}
name="rating"
render={({ field }) => (
<FormItem>
<FormLabel>Rating</FormLabel>
<FormControl>
<Rating
value={field.value}
onValueChange={field.onChange}
name={field.name}
step={0.5}
>
{Array.from({ length: 5 }, (_, i) => (
<RatingItem key={i} />
))}
</Rating>
</FormControl>
<FormDescription>
Rate your experience from 1 to 5 stars.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}
API Reference
Root
The main container component for the rating.
Prop
Type
Data Attribute | Value |
---|---|
[data-disabled] | Present when disabled |
[data-readonly] | Present when readonly |
[data-orientation] | "horizontal" | "vertical" |
Item
Individual rating item (star) component.
Prop
Type
Data Attribute | Value |
---|---|
[data-disabled] | Present when disabled |
[data-readonly] | Present when readonly |
[data-state] | "empty" | "partial" | "full" |
[data-hovered] | Present when hovered |
Accessibility
Keyboard Interactions
Key | Description |
---|---|
ArrowLeftArrowRight | Navigate between rating items. |
Home | Move to the first rating item. |
End | Move to the last rating item. |
EnterSpace | Activate the focused rating item (when activationMode is 'manual'). |
Escape | Clear the rating (when clearable is true). |