diff --git a/.gitignore b/.gitignore
index 1a100cd..199db4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@
.mise.local.toml
.vscode
coverage
+example/.expo
+example/dist
lib
node_modules
npm-debug.log
diff --git a/README.md b/README.md
index f98eed4..0549aeb 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,14 @@
[](https://www.npmjs.com/package/react-native-rating) [](https://www.npmjs.com/package/react-native-rating) [](https://github.com/f0rr0/react-native-rating/actions/workflows/ci.yml) [](./LICENSE.md)
-Accessible React Native star rating, drag rating, and semantic feedback scale for iOS, Android, Expo, and React Native Web.
-
-Fractional values, RTL and vertical layouts, keyboard and screen-reader support, a FlatList-friendly display path, and zero runtime dependencies.
-
+Accessible React Native star rating, drag rating, and semantic feedback scale for iOS, Android, Expo, and React Native Web.
+
+Fractional values, RTL and vertical layouts, keyboard and screen-reader support, a FlatList-friendly display path, and zero runtime dependencies.
+
## Why this rating component?
- **One accessible control.** Native assistive technologies get an adjustable control; the Web gets a real ARIA slider with keyboard input.
@@ -66,6 +66,10 @@ The example also provides `bun run ios` and `bun run android` scripts for local
Tap is the conservative default. Add `interactionMode="tap-and-drag"` when scrubbing across the rating is useful:
+
+
+
+
```tsx
+
+
+
## Accessible on native and Web
Interactive ratings have one focus stop, not one stop per star:
@@ -125,6 +133,10 @@ Always provide a useful `accessibilityLabel`. Localize the spoken value with `fo
Use `disabled` for a temporarily unavailable input. Use `RatingDisplay` or `readOnly` for content that is not an input.
+
+
+
+
## FlatList: interactive rows and exact displays
### Interactive ratings
@@ -186,6 +198,10 @@ import { RatingDisplay } from "react-native-rating";
`` also selects the static path, but `RatingDisplay` makes the intent clearest in list cells and aggregate views. When adjacent text already communicates the same value, pass `decorative` to hide the duplicate visual from assistive technology:
+
+
+
+
```tsx
4.37 out of 5
@@ -195,6 +211,10 @@ import { RatingDisplay } from "react-native-rating";
`RatingScale` is generic over finite numbers or strings. `null` means “no selection,” so zero and negative values remain first-class answers.
+
+
+
+
### Net Promoter Score
```tsx
@@ -246,6 +266,10 @@ Use `selectionMode="single"` for one highlighted choice (the default), or `selec
`itemExtent` controls each choice's primary-axis length independently of `size`. Increase it for longer labels in a horizontal scale without inflating the cross-axis target. It is never smaller than `size`; for a long custom vertical presentation, use `renderItem` and its `itemExtent` value to lay out the content deliberately. A read-only scale can also be `decorative` when equivalent adjacent content is already accessible.
+
+
+
+
## Custom rendering
The default star has no icon-library dependency. A render slot receives all state needed to keep custom visuals aligned with interaction and accessibility:
diff --git a/assets/rating.gif b/assets/rating.gif
deleted file mode 100644
index 0b51859..0000000
Binary files a/assets/rating.gif and /dev/null differ
diff --git a/assets/readme/accessibility-formatting.png b/assets/readme/accessibility-formatting.png
new file mode 100644
index 0000000..7b3a181
Binary files /dev/null and b/assets/readme/accessibility-formatting.png differ
diff --git a/assets/readme/custom-renderers.png b/assets/readme/custom-renderers.png
new file mode 100644
index 0000000..46513bc
Binary files /dev/null and b/assets/readme/custom-renderers.png differ
diff --git a/assets/readme/rating-input.png b/assets/readme/rating-input.png
new file mode 100644
index 0000000..50f37db
Binary files /dev/null and b/assets/readme/rating-input.png differ
diff --git a/assets/readme/read-only-display.png b/assets/readme/read-only-display.png
new file mode 100644
index 0000000..47bdbe6
Binary files /dev/null and b/assets/readme/read-only-display.png differ
diff --git a/assets/readme/scale-states.png b/assets/readme/scale-states.png
new file mode 100644
index 0000000..0b7a4e7
Binary files /dev/null and b/assets/readme/scale-states.png differ
diff --git a/assets/readme/semantic-scales.png b/assets/readme/semantic-scales.png
new file mode 100644
index 0000000..eb19abb
Binary files /dev/null and b/assets/readme/semantic-scales.png differ
diff --git a/example/app.json b/example/app.json
index 80f5788..d3be254 100644
--- a/example/app.json
+++ b/example/app.json
@@ -4,7 +4,7 @@
"slug": "react-native-rating-example",
"version": "1.0.0",
"orientation": "portrait",
- "userInterfaceStyle": "light",
+ "userInterfaceStyle": "dark",
"newArchEnabled": true,
"web": {
"bundler": "metro",
diff --git a/example/app.tsx b/example/app.tsx
index 62b16d3..88ba58d 100644
--- a/example/app.tsx
+++ b/example/app.tsx
@@ -1,10 +1,23 @@
-import { useCallback, useState } from "react";
-import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
+import { useCallback, useMemo, useState } from "react";
+import type { ReactNode } from "react";
+import {
+ Platform,
+ ScrollView,
+ StyleSheet,
+ StatusBar,
+ Text,
+ useWindowDimensions,
+ View,
+} from "react-native";
import { Rating, RatingDisplay, RatingScale } from "react-native-rating";
import type {
+ RatingInteractionDetails,
+ RatingInteractionEndDetails,
RatingRenderItemProps,
RatingScaleItem,
+ RatingScaleRenderItemProps,
} from "react-native-rating";
+import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
const EXPERIENCE_ITEMS = [
{
@@ -24,106 +37,82 @@ const EXPERIENCE_ITEMS = [
},
] as const satisfies readonly RatingScaleItem[];
+const NPS_ITEMS = Array.from({ length: 11 }, (_, score) => ({
+ content: score,
+ label: `${score} out of 10`,
+ value: score,
+})) satisfies RatingScaleItem[];
+
+const LIKERT_ITEMS = [
+ { content: "Strong no", label: "Strongly disagree", value: "strong-no" },
+ { content: "No", label: "Disagree", value: "no" },
+ { content: "Mixed", label: "Neither agree nor disagree", value: "mixed" },
+ { content: "Yes", label: "Agree", value: "yes" },
+ { content: "Strong yes", label: "Strongly agree", value: "strong-yes" },
+] as const satisfies readonly RatingScaleItem[];
+
+const COMPACT_LIKERT_ITEMS = [
+ { content: "S no", label: "Strongly disagree", value: "strong-no" },
+ { content: "No", label: "Disagree", value: "no" },
+ { content: "Mixed", label: "Neither agree nor disagree", value: "mixed" },
+ { content: "Yes", label: "Agree", value: "yes" },
+ { content: "S yes", label: "Strongly agree", value: "strong-yes" },
+] as const satisfies readonly RatingScaleItem[];
+
+const PRIORITY_ITEMS = [
+ { content: "P1", label: "Low priority", value: "low" },
+ { content: "P2", label: "Medium priority", value: "medium" },
+ { content: "P3", label: "High priority", value: "high" },
+ { content: "P4", label: "Critical priority", value: "critical" },
+] as const satisfies readonly RatingScaleItem[];
+
+const CONFIDENCE_ITEMS = [
+ { content: "Guess", label: "Guess", value: "guess" },
+ { content: "Some", label: "Some evidence", value: "some" },
+ { content: "Good", label: "Good evidence", value: "good" },
+ { content: "Certain", label: "Certain", value: "certain" },
+] as const satisfies readonly RatingScaleItem[];
+
+const formatRating = (value: number, max: number): string =>
+ `${value.toFixed(2)} of ${max}`;
+
+const formatPercent = (value: number, max: number): string =>
+ `${Math.round((value / max) * 100)} percent filled`;
+
const styles = StyleSheet.create({
app: {
- backgroundColor: "#FAF7F2",
+ backgroundColor: "#06080F",
flex: 1,
},
card: {
- backgroundColor: "#FFFFFF",
- borderColor: "#E7E0D8",
- borderRadius: 20,
+ backgroundColor: "#10141D",
+ borderColor: "#273244",
+ borderRadius: 8,
borderWidth: 1,
- gap: 14,
- padding: 20,
+ gap: 16,
+ padding: 16,
...Platform.select({
web: {
- boxShadow: "0 12px 32px rgba(64, 45, 28, 0.08)",
+ boxShadow: "0 18px 45px rgba(0, 0, 0, 0.34)",
},
}),
},
+ cell: {
+ flexBasis: 260,
+ flexGrow: 1,
+ flexShrink: 1,
+ gap: 8,
+ minWidth: 0,
+ },
content: {
alignSelf: "center",
- gap: 20,
- maxWidth: 720,
- paddingBottom: 64,
- paddingHorizontal: 20,
- paddingTop: 48,
+ gap: 16,
+ maxWidth: 920,
+ paddingBottom: 56,
+ paddingHorizontal: 16,
+ paddingTop: 12,
width: "100%",
},
- eyebrow: {
- color: "#9A3412",
- fontSize: 13,
- fontWeight: "700",
- letterSpacing: 1.2,
- textTransform: "uppercase",
- },
- header: {
- gap: 10,
- marginBottom: 4,
- },
- hint: {
- color: "#6B625B",
- fontSize: 14,
- lineHeight: 20,
- },
- score: {
- color: "#292524",
- fontSize: 18,
- fontVariant: ["tabular-nums"],
- fontWeight: "700",
- },
- staticRow: {
- alignItems: "center",
- flexDirection: "row",
- flexWrap: "wrap",
- gap: 12,
- },
- status: {
- backgroundColor: "#FFF7ED",
- borderRadius: 12,
- color: "#7C2D12",
- fontSize: 14,
- lineHeight: 20,
- paddingHorizontal: 14,
- paddingVertical: 10,
- },
- subtitle: {
- color: "#57534E",
- fontSize: 17,
- lineHeight: 26,
- maxWidth: 600,
- },
- title: {
- color: "#1C1917",
- fontSize: 38,
- fontWeight: "800",
- letterSpacing: -1.2,
- lineHeight: 43,
- },
- sectionTitle: {
- color: "#292524",
- fontSize: 18,
- fontWeight: "700",
- },
- showcaseCell: {
- alignItems: "center",
- flexGrow: 1,
- gap: 10,
- minWidth: 220,
- },
- showcaseLabel: {
- color: "#57534E",
- fontSize: 13,
- fontWeight: "600",
- },
- showcaseRow: {
- alignItems: "center",
- flexDirection: "row",
- flexWrap: "wrap",
- gap: 24,
- justifyContent: "space-around",
- },
customGlyph: {
includeFontPadding: false,
textAlign: "center",
@@ -151,11 +140,78 @@ const styles = StyleSheet.create({
customTop: {
top: 0,
},
+ eyebrow: {
+ color: "#5EEAD4",
+ fontSize: 12,
+ fontWeight: "800",
+ letterSpacing: 0.8,
+ lineHeight: 16,
+ textTransform: "uppercase",
+ },
+ featureGrid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 12,
+ },
+ footerStatus: {
+ backgroundColor: "#F4C95D",
+ borderRadius: 8,
+ color: "#17120B",
+ fontSize: 13,
+ fontVariant: ["tabular-nums"],
+ fontWeight: "700",
+ lineHeight: 18,
+ overflow: "hidden",
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ hint: {
+ color: "#8FA0B8",
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ inlineRow: {
+ alignItems: "center",
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 10,
+ },
+ metric: {
+ color: "#F8FAFC",
+ fontSize: 16,
+ fontVariant: ["tabular-nums"],
+ fontWeight: "800",
+ lineHeight: 21,
+ },
+ scaleBox: {
+ alignItems: "center",
+ borderRadius: 7,
+ borderWidth: 1,
+ justifyContent: "center",
+ },
+ scaleBoxLabel: {
+ fontSize: 11,
+ fontWeight: "800",
+ lineHeight: 13,
+ textAlign: "center",
+ },
+ sectionTitle: {
+ color: "#F8FAFC",
+ fontSize: 18,
+ fontWeight: "800",
+ lineHeight: 23,
+ },
+ splitRow: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 14,
+ },
+ scroll: {
+ backgroundColor: "#06080F",
+ flex: 1,
+ },
});
-const formatScore = (value: number): string =>
- `${value.toFixed(2)} out of 5 stars`;
-
const renderHeart = ({
activeColor,
fill,
@@ -211,155 +267,650 @@ const renderHeart = ({
);
};
+const renderBlock = ({
+ activeColor,
+ fill,
+ fillOrigin,
+ inactiveColor,
+ size,
+}: RatingRenderItemProps) => {
+ const fromEnd = fillOrigin === "right";
+ const fromBottom = fillOrigin === "bottom";
+
+ return (
+
+
+
+ );
+};
+
+const renderScaleBox = ({
+ activeColor,
+ content,
+ inactiveColor,
+ itemExtent,
+ label,
+ orientation,
+ selected,
+ size,
+}: RatingScaleRenderItemProps) => (
+
+
+ {content ?? label}
+
+
+);
+
+const Section = ({
+ children,
+ hint,
+ title,
+}: {
+ children: ReactNode;
+ hint?: string;
+ title: string;
+}) => (
+
+
+ {title}
+
+ {children}
+ {hint ? {hint} : null}
+
+);
+
+const FeatureCell = ({
+ children,
+ label,
+ value,
+}: {
+ children: ReactNode;
+ label: string;
+ value?: string;
+}) => (
+
+ {label}
+ {children}
+ {value ? {value} : null}
+
+);
+
+const describeStart = (
+ label: string,
+ value: number | string | null,
+ details: RatingInteractionDetails
+): string => `${label} start: ${value ?? "empty"} by ${details.source}`;
+
+const describeEnd = (
+ label: string,
+ value: number | string | null,
+ details: RatingInteractionEndDetails
+): string =>
+ `${label} end: ${value ?? "empty"} by ${details.source}${
+ details.cancelled ? " (cancelled)" : ""
+ }`;
+
export function App() {
- const [score, setScore] = useState(3.75);
+ const { width } = useWindowDimensions();
+ const [preciseScore, setPreciseScore] = useState(3.75);
+ const [tapScore, setTapScore] = useState(4);
+ const [tenPointScore, setTenPointScore] = useState(7);
const [heartScore, setHeartScore] = useState(3.5);
- const [intensity, setIntensity] = useState(3);
+ const [blockScore, setBlockScore] = useState(2.6);
+ const [verticalScore, setVerticalScore] = useState(3);
+ const [calmScore, setCalmScore] = useState(4);
const [experience, setExperience] = useState(0);
- const [status, setStatus] = useState(
- "Try touch, mouse, keyboard, or a screen reader."
+ const [npsScore, setNpsScore] = useState(8);
+ const [likert, setLikert] = useState("yes");
+ const [priority, setPriority] = useState("medium");
+ const [confidence, setConfidence] = useState("good");
+ const [status, setStatus] = useState("Ready for capture.");
+ const compactScales = width < 520;
+ const likertItems = compactScales ? COMPACT_LIKERT_ITEMS : LIKERT_ITEMS;
+
+ const focusStyle = useMemo(
+ () => ({
+ borderColor: "#5EEAD4",
+ borderRadius: 8,
+ borderWidth: 2,
+ }),
+ []
);
- const handleScoreEnd = useCallback((value: number): void => {
- setStatus(`Saved a ${value}-star rating.`);
- }, []);
- const handleExperienceEnd = useCallback((value: number | null): void => {
- const label =
- EXPERIENCE_ITEMS.find((item) => item.value === value)?.label ??
- "No selection";
- setStatus(`Saved “${label}”.`);
- }, []);
+ const setNumericStatus = useCallback(
+ (label: string) =>
+ (value: number, details: RatingInteractionEndDetails): void => {
+ setStatus(describeEnd(label, value, details));
+ },
+ []
+ );
+ const setScaleStatus = useCallback(
+ (label: string) =>
+ (
+ value: number | string | null,
+ details: RatingInteractionEndDetails
+ ): void => {
+ setStatus(describeEnd(label, value, details));
+ },
+ []
+ );
return (
-
-
- React Native Rating
-
- One input, every way people rate.
-
-
- Precise stars, quiet motion, semantic scales, and the same accessible
- interaction model on native and Web.
-
-
+
+
+
+
+
+
+
+ {
+ setStatus(describeStart("Quarter step", value, details));
+ }}
+ size={40}
+ step={0.25}
+ value={preciseScore}
+ />
+
-
-
- Your rating
-
-
- {score.toFixed(2)} / 5
-
- Drag across the stars. On Web, focus the control and use the arrow,
- Home, or End keys.
-
-
+
+
+
-
-
- RTL, vertical, and your own icon
-
-
-
- RTL custom hearts
-
- {heartScore.toFixed(1)}
-
-
- Vertical intensity
-
-
-
-
- Direction, orientation, and fill origin stay explicit. The custom
- renderer receives the same fractional value used by input and
- accessibility.
-
-
+
+
+
-
-
- How was the experience?
-
-
-
- Semantic values can include zero and negatives without treating either
- as an empty rating.
-
-
+
+
+
+
+
-
-
- Read-only average
-
-
-
- 4.37
-
-
- RatingDisplay preserves exact aggregate values and avoids interaction
- work, making it the list-friendly path.
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 4.37
+
+
+
+
+
+
+
+
+
+ 4.80 of 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Priority: high
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ item ? `Selected ${item.label}` : "Nothing selected yet"
+ }
+ gap={compactScales ? 2 : 3}
+ itemExtent={compactScales ? 28 : 34}
+ inactiveColor="#64748B"
+ items={NPS_ITEMS}
+ readOnly
+ renderItem={renderScaleBox}
+ selectionMode="cumulative"
+ size={30}
+ value={6}
+ />
+
+
+
+
+
+ {status}
+
+
+
+
);
}
diff --git a/example/metro.config.js b/example/metro.config.js
new file mode 100644
index 0000000..fb468e5
--- /dev/null
+++ b/example/metro.config.js
@@ -0,0 +1,54 @@
+const path = require("node:path");
+const { getDefaultConfig } = require("expo/metro-config");
+const exclusionList =
+ require("metro-config/private/defaults/exclusionList").default;
+
+const projectRoot = __dirname;
+const workspaceRoot = path.resolve(projectRoot, "..");
+const installedPackageRoot = path.join(
+ projectRoot,
+ "node_modules/react-native-rating"
+);
+
+const config = getDefaultConfig(projectRoot);
+const escapePath = (value) =>
+ value.replaceAll("\\", "\\\\").replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&");
+
+config.watchFolders = Array.from(
+ new Set([...(config.watchFolders ?? []), workspaceRoot])
+);
+
+config.resolver = {
+ ...config.resolver,
+ blockList: exclusionList([
+ new RegExp(
+ `${escapePath(path.join(installedPackageRoot, "node_modules/react"))}(/.*)?$`
+ ),
+ new RegExp(
+ `${escapePath(path.join(installedPackageRoot, "node_modules/react-native"))}(/.*)?$`
+ ),
+ new RegExp(
+ `${escapePath(path.join(installedPackageRoot, "example/node_modules/react"))}(/.*)?$`
+ ),
+ ]),
+ disableHierarchicalLookup: true,
+ extraNodeModules: {
+ ...(config.resolver.extraNodeModules ?? {}),
+ react: path.join(projectRoot, "node_modules/react"),
+ "react/jsx-dev-runtime": path.join(
+ projectRoot,
+ "node_modules/react/jsx-dev-runtime"
+ ),
+ "react/jsx-runtime": path.join(
+ projectRoot,
+ "node_modules/react/jsx-runtime"
+ ),
+ "react-native": path.join(projectRoot, "node_modules/react-native"),
+ },
+ nodeModulesPaths: [
+ path.join(projectRoot, "node_modules"),
+ path.join(workspaceRoot, "node_modules"),
+ ],
+};
+
+module.exports = config;
diff --git a/example/package.json b/example/package.json
index bd7e329..64ee36d 100644
--- a/example/package.json
+++ b/example/package.json
@@ -18,6 +18,7 @@
"react-dom": "19.2.8",
"react-native": "0.86.2",
"react-native-rating": "file:..",
+ "react-native-safe-area-context": "~5.7.0",
"react-native-web": "0.21.2"
},
"devDependencies": {