Rating
Star rating with hover preview, half-star precision, read-only mode and sm / md / lg sizes.
Example
Installation
npx shadcn@latest add https://hirael.com/r/rating.jsonUsage
import { Rating, RatingItem } from "@/components/rating"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<Rating />
+ native element props| Prop | Type | Default |
|---|---|---|
value | number | none |
defaultValue | number | none |
onValueChange | ((value: number) => void) | none |
max | number | 5 |
step | 1 | 0.5 | 1 |
readOnly | boolean | none |
disabled | boolean | none |
size | "sm" | "lg" | "md" | 'md' |
name | string | none |
aria-labelDefines a string value that labels the current element. | string | none |
<RatingItem />
+ native element props| Prop | Type | Default |
|---|---|---|
index* | number | none |
Component source
'use client';
import * as React from 'react';
import { Star } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';
interface RatingContextValue {
value: number;
display: number;
max: number;
step: 0.5 | 1;
interactive: boolean;
iconSize: string;
tabStop: number;
setHover: (next: number | null) => void;
commit: (next: number) => void;
}
const RatingContext = React.createContext<RatingContextValue | null>(null);
const useRating = () => {
const ctx = React.useContext(RatingContext);
if (!ctx) {
throw new Error('Rating compound parts must be used inside <Rating>');
}
return ctx;
};
export interface RatingProps extends Omit<React.ComponentProps<'div'>, 'onChange' | 'defaultValue'> {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
max?: number;
step?: 0.5 | 1;
readOnly?: boolean;
disabled?: boolean;
size?: 'sm' | 'md' | 'lg';
name?: string;
'aria-label'?: string;
}
const sizeMap = {
sm: 'size-4',
md: 'size-5',
lg: 'size-6',
} as const;
const Rating = ({
value: valueProp,
defaultValue,
onValueChange,
max = 5,
step = 1,
readOnly,
disabled,
size = 'md',
name,
className,
onKeyDown,
onMouseLeave,
children,
...props
}: RatingProps) => {
const [hover, setHover] = React.useState<number | null>(null);
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue ?? 0,
onChange: onValueChange,
});
const display = hover ?? value;
const interactive = !readOnly && !disabled;
const iconSize = sizeMap[size];
const commit = React.useCallback(
(next: number) => {
if (readOnly || disabled) return;
setValue(next);
},
[readOnly, disabled, setValue],
);
const stepAligned = value > 0 && value <= max && Number.isInteger(value / step);
const tabStop = stepAligned ? value : step;
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented || !interactive) return;
const rtl = getComputedStyle(e.currentTarget).direction === 'rtl';
const next = (
{
ArrowRight: value + (rtl ? -step : step),
ArrowLeft: value + (rtl ? step : -step),
ArrowUp: value + step,
ArrowDown: value - step,
Home: step,
End: max,
} as Record<string, number>
)[e.key];
if (next === undefined) return;
e.preventDefault();
const clamped = Math.min(max, Math.max(step, next));
commit(clamped);
e.currentTarget.querySelector<HTMLButtonElement>(`[data-slot="rating-radio"][data-value="${clamped}"]`)?.focus();
};
const ctx = React.useMemo<RatingContextValue>(
() => ({
value,
display,
max,
step,
interactive,
iconSize,
tabStop,
setHover,
commit,
}),
[value, display, max, step, interactive, iconSize, tabStop, commit],
);
return (
<RatingContext.Provider value={ctx}>
<div
data-slot="rating"
data-readonly={readOnly || undefined}
data-disabled={disabled || undefined}
role={interactive ? 'radiogroup' : 'img'}
aria-label={props['aria-label'] ?? `Rating: ${value} of ${max}`}
className={cn('inline-flex items-center gap-0.5', disabled && 'pointer-events-none opacity-50', className)}
onMouseLeave={(e) => {
onMouseLeave?.(e);
setHover(null);
}}
onKeyDown={handleKeyDown}
{...props}
>
{children ?? Array.from({ length: max }).map((_, i) => <RatingItem key={i} index={i} />)}
{name && <input type="hidden" name={name} value={value} />}
</div>
</RatingContext.Provider>
);
};
interface RatingRadioProps {
value: number;
className?: string;
}
const RatingRadio = ({ value: radioValue, className }: RatingRadioProps) => {
const ctx = useRating();
return (
<button
type="button"
role="radio"
aria-checked={ctx.value === radioValue}
aria-label={`${radioValue} of ${ctx.max}`}
tabIndex={radioValue === ctx.tabStop ? 0 : -1}
data-slot="rating-radio"
data-value={radioValue}
className={cn(
'cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
className,
)}
onMouseEnter={() => ctx.setHover(radioValue)}
onClick={() => ctx.commit(ctx.value === radioValue ? 0 : radioValue)}
/>
);
};
export interface RatingItemProps extends Omit<React.ComponentProps<'span'>, 'children'> {
index: number;
}
const RatingItem = ({ index, className, ...props }: RatingItemProps) => {
const ctx = useRating();
const fullValue = index + 1;
const halfValue = index + 0.5;
const filled = ctx.display >= fullValue;
const half = !filled && ctx.display >= halfValue;
return (
<span
data-slot="rating-item"
className={cn('relative inline-flex', ctx.interactive && 'cursor-pointer', className)}
{...props}
>
<Star data-slot="rating-star" className={cn(ctx.iconSize, 'text-muted-foreground/40 transition-colors')} />
{(filled || half) && (
<Star
data-slot="rating-star-fill"
className={cn(
ctx.iconSize,
'absolute inset-0 fill-warning text-warning transition-[clip-path]',
half && '[clip-path:inset(0_50%_0_0)] rtl:[clip-path:inset(0_0_0_50%)]',
)}
/>
)}
{ctx.interactive && ctx.step === 0.5 && (
<>
<RatingRadio value={halfValue} className="absolute inset-y-0 start-0 w-1/2" />
<RatingRadio value={fullValue} className="absolute inset-y-0 end-0 w-1/2" />
</>
)}
{ctx.interactive && ctx.step === 1 && <RatingRadio value={fullValue} className="absolute inset-0" />}
</span>
);
};
export { Rating, RatingItem };
Dependencies
shadcn registry
use-controllable-state
npm
lucide-react