/**
* Rating — a row of stars you can read or set.
*
* A rating is two jobs wearing one shape. Read-only, it is a compact way to
* *show* a score — four-and-a-half stars next to a product. Interactive, it is
* an *input*: tap a star to pick a whole value, or with `precision={0.5}` press
* the left half of a star for a half. Either way the fill is the truth, so the
* two modes look identical and only differ in whether a finger changes them.
*
* Partial fills are drawn by clipping a filled star over an empty one of the
* same geometry — the filled layer's width is a fraction of the star's, so a
* half star is exactly half and any precision in between is honest. That fill
* is animated on the UI thread: setting a value springs the stars to it rather
* than snapping, and a drag follows the finger with no spring so it never lags.
*
* ```tsx
*
*
*
*
*
* ```
*
* Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
*/
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import {
View,
type AccessibilityActionEvent,
type LayoutChangeEvent,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { StarIcon } from '../../icons';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { selectionTick } from '../../utils/haptics';
import {
normalizeRatingMax,
normalizeRatingPrecision,
normalizeRatingValue,
} from './rating-inputs';
/** Springs the fill onto its resting value after a tap or the end of a drag. */
const SPRING = { damping: 18, stiffness: 220, mass: 0.6 } as const;
const ratingVariants = tv({
slots: {
root: 'gap-2',
header: 'flex-row items-center justify-between gap-3',
label: 'text-sm font-medium text-foreground',
value: 'text-sm text-muted-foreground',
row: 'flex-row items-center self-start',
},
variants: {
size: {
sm: { row: 'gap-0.5' },
md: { row: 'gap-1' },
lg: { row: 'gap-1.5' },
},
disabled: {
true: { root: 'opacity-50' },
},
},
defaultVariants: {
size: 'md',
},
});
type RatingVariantProps = VariantProps;
export type RatingSize = NonNullable;
export type RatingColor =
| 'warning'
| 'primary'
| 'success'
| 'destructive'
| 'info'
| 'foreground';
/** Star pixel size per size token — the whole control scales from this. */
const STAR_SIZE: Record = { sm: 18, md: 24, lg: 32 };
/** The CSS token each colour paints its filled stars with. */
const FILL_VAR: Record = {
warning: '--color-warning',
primary: '--color-primary',
success: '--color-success',
destructive: '--color-destructive',
info: '--color-info',
foreground: '--color-foreground',
};
function clampJS(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
/** Rounds `value` up to the nearest `precision`, so any touch fills at least it. */
function snap(value: number, precision: number, max: number) {
const stepped = Math.ceil(value / precision) * precision;
// Guard the float drift that `ceil` on fractional steps can leave behind.
return clampJS(Math.round(stepped / precision) * precision, 0, max);
}
export interface RatingProps extends Omit {
className?: string;
/** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */
value?: number;
/** Starting value when uncontrolled. */
defaultValue?: number;
/** How many stars. */
max?: number;
/**
* Smallest step a tap can pick, as a fraction of a star. `1` is whole stars,
* `0.5` lets the left half of a star mean a half. Reading a value renders any
* precision — this only constrains what a finger can set.
*/
precision?: number;
/** Fires as the value changes, including live while dragging. */
onValueChange?: (value: number) => void;
/** Fires once when a tap or drag ends — the place for expensive side effects. */
onValueCommit?: (value: number) => void;
/** Show the stars but ignore touches — the display half of the component. */
readOnly?: boolean;
disabled?: boolean;
/**
* Let a second tap on the current value clear it back to zero, so a rating
* given by mistake can be taken back without a separate control.
*/
allowClear?: boolean;
/** Which token the filled stars are painted with. */
color?: RatingColor;
/** Caption above the stars. Also becomes the accessibility label. */
label?: string;
/** Show the numeric value on the caption row, opposite the label. */
showValue?: boolean;
/** Format the shown value. Defaults to the number as written. */
formatValue?: (value: number) => string;
/**
* A tick under the finger each time a drag crosses onto a new star. Off by
* default — needs the optional `expo-haptics`, and is silent without it.
*/
haptics?: boolean;
/** Extra classes for the caption row. */
headerClassName?: string;
/** Extra classes for the row of stars. */
rowClassName?: string;
}
/**
* One star: an empty outline with a filled copy clipped over it. The clip width
* is driven by the shared `display` value on the UI thread, so the fill of this
* star animates and drags without a React render.
*/
function Star({
index,
display,
size,
gap,
fillColor,
emptyColor,
}: {
index: number;
display: SharedValue;
size: number;
gap: number;
fillColor: string;
emptyColor: string;
}) {
// Clamped inline rather than through a helper: this body runs on the UI
// thread, which can only reach worklets, not module-scope functions.
const clipStyle = useAnimatedStyle(() => ({
width: Math.min(Math.max(display.value - index, 0), 1) * size,
}));
return (
{/* The filled layer is pinned to the left and clipped to a fraction of
the star's width, so a partial value shows a partial star. */}
);
}
export const Rating = forwardRef(
(
{
className,
headerClassName,
rowClassName,
value: valueProp,
defaultValue = 0,
max: maxProp = 5,
precision: precisionProp = 1,
onValueChange,
onValueCommit,
readOnly = false,
disabled = false,
allowClear = false,
color = 'warning',
size = 'md',
label,
showValue = false,
formatValue,
haptics = false,
},
ref
) => {
const max = normalizeRatingMax(maxProp);
const precision = normalizeRatingPrecision(precisionProp);
const isControlled = valueProp !== undefined;
const [internal, setInternal] = useState(() => normalizeRatingValue(defaultValue, max));
const value = normalizeRatingValue(isControlled ? valueProp : internal, max);
const interactive = !readOnly && !disabled;
const slots = ratingVariants({ size, disabled });
const starSize = STAR_SIZE[size ?? 'md'];
const gap = size === 'sm' ? 2 : size === 'lg' ? 6 : 4;
// SVG paints with real colour strings, not classes — resolve the tokens.
const fillColor = useCSSVariable(FILL_VAR[color]);
const emptyColor = useCSSVariable('--color-border');
const fill = typeof fillColor === 'string' ? fillColor : '#f59e0b';
const empty = typeof emptyColor === 'string' ? emptyColor : '#d4d4d4';
// Drives every star's clipped fill on the UI thread.
const display = useSharedValue(value);
const rowWidth = useSharedValue(0);
// Keep the change/commit handlers reachable from the gesture without
// rebuilding it on every render.
const changeRef = useRef(onValueChange);
changeRef.current = onValueChange;
const commitRef = useRef(onValueCommit);
commitRef.current = onValueCommit;
// The last whole star a drag crossed, so haptics tick once per star.
const lastStep = useRef(Math.ceil(value));
const emitChange = useCallback(
(next: number) => {
if (!isControlled) setInternal(next);
changeRef.current?.(next);
},
[isControlled]
);
// Follow a controlled value that changes elsewhere, springing to it.
useEffect(() => {
display.value = withSpring(value, SPRING);
}, [value, display]);
const onRowLayout = (event: LayoutChangeEvent) => {
rowWidth.value = event.nativeEvent.layout.width;
};
// Maps an x within the row to a snapped rating. Runs on JS off the worklet.
const valueFromX = useCallback(
(x: number, width: number) => {
if (width <= 0) return value;
const raw = clampJS(x / width, 0, 1) * max;
return snap(raw, precision, max);
},
[max, precision, value]
);
const applyLive = useCallback(
(x: number, width: number) => {
let next = valueFromX(x, width);
if (allowClear && next === value && Math.ceil(next) === lastStep.current) {
next = 0;
}
display.value = next;
if (haptics && Math.ceil(next) !== lastStep.current) {
lastStep.current = Math.ceil(next);
selectionTick();
}
emitChange(next);
},
[valueFromX, allowClear, value, display, haptics, emitChange]
);
// Resets the haptic step to where the touch started, then applies the first
// position. Runs on JS: a ref cannot be written from a gesture worklet.
const begin = useCallback(
(x: number, width: number) => {
lastStep.current = Math.ceil(value);
applyLive(x, width);
},
[value, applyLive]
);
const commit = useCallback(
(x: number, width: number) => {
let next = valueFromX(x, width);
if (allowClear && next === value) next = 0;
display.value = withSpring(next, SPRING);
lastStep.current = Math.ceil(next);
emitChange(next);
commitRef.current?.(next);
},
[valueFromX, allowClear, value, display, emitChange]
);
const pan = Gesture.Pan()
.enabled(interactive)
.onBegin((event) => {
runOnJS(begin)(event.x, rowWidth.value);
})
.onUpdate((event) => {
runOnJS(applyLive)(event.x, rowWidth.value);
})
.onFinalize((event) => {
runOnJS(commit)(event.x, rowWidth.value);
});
const tap = Gesture.Tap()
.enabled(interactive)
.maxDuration(250)
.onEnd((event) => {
runOnJS(commit)(event.x, rowWidth.value);
});
const gesture = Gesture.Race(tap, pan);
// VoiceOver / TalkBack move by a single precision step.
const nudge = (dir: 1 | -1) => {
const next = clampJS(
Math.round((value + dir * precision) / precision) * precision,
0,
max
);
if (next === value) return;
display.value = withSpring(next, SPRING);
lastStep.current = Math.ceil(next);
emitChange(next);
commitRef.current?.(next);
};
const onAccessibilityAction = (event: AccessibilityActionEvent) => {
if (event.nativeEvent.actionName === 'increment') nudge(1);
else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
};
const shown = formatValue ? formatValue(value) : String(value);
const a11yLabel = label ?? `Rating, ${shown} out of ${max}`;
const stars = (
{Array.from({ length: max }, (_, i) => (
))}
);
const header =
label || showValue ? (
{label ? {label} : }
{showValue ? {shown} : null}
) : null;
return (
{header}
{interactive ? (
{stars}
) : (
{stars}
)}
);
}
);
Rating.displayName = 'Rating';