Animated star rating with tap or drag input, optional half-star precision, spring scale feedback, and selection haptics
components/ui/rating/rating.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0expo-haptics@*Live preview — the real component, driven by the props below.
import { Rating } from "@/components/ui/rating/rating";
export function Example() {
return (
<Rating />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type GestureResponderEvent, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedReaction,
withSequence,
withSpring,
withTiming,
runOnJS,
type SharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, useHaptic, spring, timing, useReducedMotion, useTheme, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { iconMap } from "@lumioui/ui";
const StarGlyph = iconMap.star;
/**
* Rating — PRO component. Star rating input: tap or drag across the stars to
* set the value, with optional half-star precision. Stars scale with a
* spring as they fill and fire a selection haptic on change. Set
* `readonly` for display-only use, `disabled` for a dimmed non-interactive
* input. Requires a dev build (react-native-gesture-handler).
*/
export interface RatingProps extends Omit<ViewProps, "className"> {
/** Controlled rating, 0..max (0.5 steps when allowHalf). */
value?: number;
/** Uncontrolled initial rating. Default 0. */
defaultValue?: number;
onChange?: (value: number) => void;
/** Number of stars. Default 5. */
max?: number;
/** Star size in px. Default 32. */
size?: number;
/** Allow half-star precision. Default false. */
allowHalf?: boolean;
/** Display-only — still announces the rating, removes the input role. */
readonly?: boolean;
/** Non-interactive input — keeps the adjustable role but ignores touches
* and dims the row (distinct from readonly, which is pure display). */
disabled?: boolean;
className?: string;
}
interface StarProps {
index: number;
size: number;
anim: SharedValue<number>;
color: string;
emptyColor: string;
}
interface StarButtonProps {
index: number;
size: number;
max: number;
/** x is the tap location within the star, 0..size. */
onRate: (index: number, x: number) => void;
children: React.ReactNode;
}
/** Structurally-typed measurable node — this package's tsconfig has no DOM
* lib, so RNW host nodes are described by the one method we need. */
type Measurable = { getBoundingClientRect?: () => { left: number } } | null;
/**
* X position of a press/touch within `anchor`'s element. Web first:
* RNW's responder `locationX` is a lazy getter over `currentTarget`'s
* rect, but `currentTarget` is already null when `onPress` dispatches
* (after mouseup), so the getter returns undefined — measure the host
* node we hold in a ref instead. Falls back to `locationX` on native.
* Returns null when nothing measurable exists — callers must skip the
* commit rather than write NaN into the rating.
*/
function eventX(e: GestureResponderEvent, anchor: Measurable): number | null {
const ne = e.nativeEvent as unknown as {
locationX?: number;
clientX?: number;
pageX?: number;
};
const px = ne.clientX ?? ne.pageX;
if (
typeof px === "number" &&
Number.isFinite(px) &&
anchor &&
typeof anchor.getBoundingClientRect === "function"
) {
return px - anchor.getBoundingClientRect().left;
}
if (typeof ne.locationX === "number" && Number.isFinite(ne.locationX)) {
return ne.locationX;
}
return null;
}
/**
* Pressable star — each star is its own target so screen readers can label
* it, and hitSlop expands the visual size to a ≥44pt effective touch area.
* Own component so every star gets a usePressAnimation instance. Uses the
* shared AnimatedPressable singleton so the press-scale style and any
* className stay on the same registered element.
*/
function StarButton({ index, size, max, onRate, children }: StarButtonProps) {
const press = usePressAnimation({ pressedScale: 0.85, fast: true });
// Host node for web geometry — on web the forwarded ref resolves to the
// pressable's DOM element; on native it has no getBoundingClientRect and
// eventX falls through to locationX.
const host = React.useRef<Measurable>(null);
return (
<AnimatedPressable
ref={(node) => {
host.current = node as unknown as Measurable;
}}
onPress={(e) => {
const x = eventX(e, host.current);
if (x == null) return;
// x can exceed [0, size] inside the hitSlop margin — clamp.
onRate(index, Math.max(0, Math.min(size, x)));
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
hitSlop={Math.max(0, (44 - size) / 2)}
accessibilityRole="button"
accessibilityLabel={`Rate ${index + 1} of ${max}`}
accessibilityHint={`Sets the rating to ${index + 1} out of ${max} stars`}
>
{children}
</AnimatedPressable>
);
}
function Star({ index, size, anim, color, emptyColor }: StarProps) {
const reduce = useReducedMotion();
/** Pop driver — 1 at rest; the fill-edge crossing punches it to 1.28 and
* a bouncy spring settles it back for the "star pops" feel. */
const pop = useSharedValue(1);
// Pop on the rising edge of "fully filled" only — partial fills sweep
// without scaling so scrubbing stays precise, not jittery.
useAnimatedReaction(
() => anim.value - index >= 1,
(filled, wasFilled) => {
if (filled && !wasFilled && !reduce) {
pop.value = withSequence(
withTiming(1.28, timing.fast),
withSpring(1, spring.bouncy)
);
}
},
[reduce]
);
const fillStyle = useAnimatedStyle(() => ({
width: Math.max(0, Math.min(1, anim.value - index)) * size,
}));
// Pop scale lives on the whole star (both glyphs) so the fill clip isn't
// distorted; reduced motion drops it — the fill alone signals state.
const popStyle = useAnimatedStyle(() => ({
transform: [{ scale: reduce ? 1 : pop.value }],
}));
return (
<Animated.View
style={[{ width: size, height: size }, popStyle]}
className="items-center justify-center"
>
<StarGlyph size={size} color={emptyColor} fill="none" />
<Animated.View
style={[{ position: "absolute", left: 0, top: 0, height: size, overflow: "hidden" }, fillStyle]}
>
<StarGlyph size={size} color={color} fill={color} />
</Animated.View>
</Animated.View>
);
}
export const Rating = forwardRef<React.ElementRef<typeof View>, RatingProps>(
function Rating(
{
value,
defaultValue = 0,
onChange,
max = 5,
size = 32,
allowHalf = false,
readonly = false,
disabled = false,
className,
...props
},
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const interactive = !readonly && !disabled;
const [internal, setInternal] = React.useState(defaultValue);
const current = value ?? internal;
const anim = useSharedValue(current);
const lastSent = React.useRef(current);
React.useEffect(() => {
anim.value = current;
lastSent.current = current;
}, [current, anim]);
const quantize = React.useCallback(
(raw: number) => {
const clamped = Math.max(0, Math.min(max, raw));
return allowHalf ? Math.round(clamped * 2) / 2 : Math.ceil(clamped);
},
[max, allowHalf]
);
const commit = React.useCallback(
(v: number) => {
// Guard the web path — an event with no usable x must never commit NaN.
if (!Number.isFinite(v) || v === lastSent.current) return;
lastSent.current = v;
haptics.selection();
if (value === undefined) setInternal(v);
onChange?.(v);
},
[value, haptics, onChange]
);
const valueFromX = React.useCallback(
(x: number) => quantize(x / size),
[quantize, size]
);
// Drag commit — mapped from the pan's x (relative to the star row). The
// gesture is created once, so the worklet hops to JS through a stable
// trampoline bound to the freshest commit.
const commitXRef = React.useRef<(x: number) => void>(() => {});
commitXRef.current = (x: number) => {
const v = valueFromX(x);
anim.value = v;
commit(v);
};
const commitX = React.useCallback((x: number) => commitXRef.current(x), []);
// Drag-to-rate: a real gesture-handler pan over the star row — the
// responder touch events the row used before never fired reliably over
// the child Pressables. activeOffsetX keeps taps on the star buttons and
// lets vertical scrolls pass through; each star stays a real button for
// taps and screen readers.
const pan = React.useMemo(() => {
if (!interactive) return null;
return Gesture.Pan()
.activeOffsetX([-6, 6])
.onStart((e) => {
"worklet";
runOnJS(commitX)(e.x);
})
.onUpdate((e) => {
"worklet";
runOnJS(commitX)(e.x);
});
}, [interactive, commitX]);
const handleStarPress = React.useCallback(
(index: number, x: number) => {
const v = quantize(index + x / size);
anim.value = v;
commit(v);
},
[quantize, size, anim, commit]
);
const stars = React.useMemo(() => Array.from({ length: max }, (_, i) => i), [max]);
const row = (
<View
ref={ref}
collapsable={false}
className={cn("flex-row items-center", disabled && "opacity-50", className)}
style={{ minHeight: Math.max(44, size) }}
accessibilityRole={readonly ? "text" : "adjustable"}
accessibilityLabel={readonly ? `Rated ${current} out of ${max}` : `Rating: ${current} of ${max}`}
accessibilityState={{ disabled: readonly || disabled }}
accessibilityValue={{ min: 0, max, now: current }}
// RNW drops accessibilityValue/accessibilityState — mirror so the web
// slider role reports the current rating.
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={current}
aria-valuetext={`${current} of ${max}`}
aria-disabled={readonly || disabled || undefined}
accessibilityActions={interactive ? [{ name: "increment" }, { name: "decrement" }] : undefined}
onAccessibilityAction={
interactive
? (e) => {
const dir = e.nativeEvent.actionName === "increment" ? 1 : -1;
const v = quantize(current + dir * (allowHalf ? 0.5 : 1));
anim.value = v;
commit(v);
}
: undefined
}
{...props}
>
{stars.map((i) =>
interactive ? (
<StarButton key={i} index={i} size={size} max={max} onRate={handleStarPress}>
<Star
index={i}
size={size}
anim={anim}
color={colors.warning}
emptyColor={colors.mutedForeground}
/>
</StarButton>
) : (
<Star
key={i}
index={i}
size={size}
anim={anim}
color={colors.warning}
emptyColor={colors.mutedForeground}
/>
)
)}
</View>
);
return pan ? <GestureDetector gesture={pan}>{row}</GestureDetector> : row;
}
);
Rating.displayName = "Rating";
| value? | number | Controlled rating, 0..max (0.5 steps when allowHalf). | |
| defaultValue? | number | Uncontrolled initial rating. Default 0. | |
| onChange? | (value: number) => void | ||
| max? | number | Number of stars. Default 5. | |
| size? | number | Star size in px. Default 32. | |
| allowHalf? | boolean | Allow half-star precision. Default false. | |
| readonly? | boolean | Display-only — still announces the rating, removes the input role. | |
| disabled? | boolean | Non-interactive input — keeps the adjustable role but ignores touches and dims the row (distinct from readonly, which is pure display). | |
| className? | string |
Animated star rating with tap or drag input, optional half-star precision, spring scale feedback, and selection haptics
import { RatingInteractive } from "@/components/ui/rating/variants/interactive";
export function Example() {
return (
<RatingInteractive />
);
} import * as React from "react";
import { View } from "react-native";
import { Rating } from "../rating";
import { Ambient, Button, Glass, Text } from "@lumioui/ui";
/** RatingInteractive — half-step star input staged as an order review card. */
export function RatingInteractive() {
const [value, setValue] = React.useState(4.5);
return (
<View className="relative w-full overflow-hidden rounded-3xl">
<Ambient seed="rating-interactive" />
<View className="w-full gap-3 p-4">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4 items-center">
<View className="gap-1 items-center">
<Text variant="small" className="font-semibold text-center">Rate your Tonkotsu</Text>
<Text variant="muted" className="text-xs text-center">Order #8421 · Sakura Ramen · delivered by Diego Ruiz</Text>
</View>
<Rating value={value} onChange={setValue} allowHalf />
<View className="w-full gap-1.5">
<Glass interactive curvature="edge" className="flex-row items-center justify-between rounded-lg px-3 py-2">
<Text variant="muted">Your rating</Text>
<Text variant="small" className="tabular-nums">{value} / 5</Text>
</Glass>
<Glass interactive curvature="edge" className="flex-row items-center justify-between rounded-lg px-3 py-2">
<Text variant="muted">Driver says</Text>
<Text variant="small">Diego says thanks</Text>
</Glass>
</View>
<Button size="sm" className="w-full" disabled={value === 0}>
Submit review
</Button>
</Glass>
</View>
</View>
);
}
Animated star rating with tap or drag input, optional half-star precision, spring scale feedback, and selection haptics
import { RatingReadonly } from "@/components/ui/rating/variants/readonly";
export function Example() {
return (
<RatingReadonly />
);
} import * as React from "react";
import { View } from "react-native";
import { Rating } from "../rating";
import { Ambient, Glass, Text } from "@lumioui/ui";
/** RatingReadonly — compact display-only rating with review count. */
export function RatingReadonly() {
return (
<View className="relative w-full overflow-hidden rounded-3xl">
<Ambient seed="rating-readonly" />
<View className="w-full gap-3 p-4">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<Text variant="small">Sakura Ramen · Kyoto</Text>
<Rating value={4.5} allowHalf readonly size={20} />
<Text variant="muted">4.5 · 2.1k reviews (display only)</Text>
</Glass>
</View>
</View>
);
}