Star rating, headline, body, and photo prompt for a product review.
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, Glass, Icon, Image, Input, Text, Textarea, } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { LumioMark, Mount, Press, StickyBottomBar, catalogImg, type CatalogPhoto } from "../shared";
export interface WriteReviewScreenProps {
productName?: string;
productPhoto?: CatalogPhoto;
onSubmit?: (review: {
rating: number;
title: string;
body: string;
fit?: string;
recommend?: boolean;
photos: string[];
}) => void;
onBack?: () => void;
}
const FIT_OPTIONS = ["Runs small", "True to size", "Runs large"];
/** Pool the seeded thumbs cycle through when "Add photos" is tapped. */
const PHOTO_POOL: CatalogPhoto[] = ["overcoat", "shop", "flatlay"];
/** Write review — star rating, headline, body, photo hint, submit. */
export function WriteReviewScreen({
productName = "Oversized wool overcoat",
productPhoto = "overcoat",
onSubmit,
onBack,
}: WriteReviewScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [rating, setRating] = React.useState(4);
const [title, setTitle] = React.useState("Fits like a dream");
const [body, setBody] = React.useState(
"True to size, warm without the bulk. The wool feels premium and the lining is smooth.",
);
const [fit, setFit] = React.useState("True to size");
const [recommend, setRecommend] = React.useState<boolean | undefined>(true);
const [photos, setPhotos] = React.useState<string[]>([
catalogImg("overcoat", 240, 240),
]);
const addPhoto = () => {
haptics.selection();
setPhotos((p) =>
p.length >= 5
? p
: [...p, catalogImg(PHOTO_POOL[p.length % PHOTO_POOL.length] ?? "overcoat", 240, 240)]
);
};
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back disc + centered title pill. */}
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => onBack?.()}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="h-11 w-11" />
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="h-11 flex-row items-center justify-center gap-2 rounded-full pl-3.5 pr-5">
<Glass effect="regular" className="absolute inset-0 rounded-full" />
<LumioMark size={18} />
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Write a review
</Text>
</View>
</View>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 120 }}
>
{/* Identity line — who the review posts as. */}
<Mount>
<Text variant="muted" className="px-6 pb-2 text-xs">
Posting publicly as Amara C. — order HL-2094
</Text>
</Mount>
{/* Product being reviewed — context before the form. */}
<Mount className="mx-6 mb-6 flex-row items-center gap-3 rounded-3xl bg-white p-3 dark:bg-[#1A1A20]">
<Image
source={{ uri: catalogImg(productPhoto, 160, 200) }}
className="h-16 w-14 rounded-xl bg-muted"
accessibilityLabel={productName}
/>
<View>
<Text variant="small" className="text-muted-foreground">
Reviewing
</Text>
<Text variant="large">{productName}</Text>
</View>
</Mount>
{/* Rating — tappable stars, real feedback. */}
<Mount delay={60} className="mx-6 mb-6 items-center gap-2 rounded-3xl bg-white p-4 dark:bg-[#1A1A20]">
<View className="flex-row gap-2">
{[1, 2, 3, 4, 5].map((n) => (
<Button
key={n}
variant="ghost"
haptic="light"
onPress={() => {
haptics.selection();
setRating(n);
}}
accessibilityLabel={`${n} star${n > 1 ? "s" : ""}`}
accessibilityState={{ selected: rating === n }}
className="h-11 w-11 p-0"
>
<Icon
name="star"
size="lg"
color={n <= rating ? "warning" : "mutedForeground"}
filled={n <= rating}
/>
</Button>
))}
</View>
<Text variant="small" className="text-muted-foreground">
{rating === 5 ? "Excellent" : rating === 4 ? "Great" : rating === 3 ? "Good" : rating === 2 ? "Fair" : "Poor"}
</Text>
</Mount>
<Mount delay={120} className="gap-4 px-6">
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
Headline
</Text>
<Input
value={title}
onChangeText={setTitle}
placeholder="Sum it up in a line"
accessibilityLabel="Review headline"
/>
</View>
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
Your review
</Text>
<Textarea
value={body}
onChangeText={setBody}
placeholder="How's the fit, the fabric, the quality?"
minHeight={120}
accessibilityLabel="Review body"
/>
</View>
{/* Structured fit — the apparel-review signature row. */}
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
How does it fit?
</Text>
<View className="flex-row gap-2">
{FIT_OPTIONS.map((f) => (
<Chip
key={f}
variant="filter"
selected={fit === f}
onPress={() => setFit(f)}
accessibilityLabel={`Fit: ${f}`}
accessibilityState={{ selected: fit === f }}
className={fit === f ? "border-transparent bg-[#1B1B26]" : undefined}
textClassName={fit === f ? "text-white" : undefined}
>
{f}
</Chip>
))}
</View>
</View>
{/* Recommend — binary chip pair. */}
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
Would you recommend it?
</Text>
<View className="flex-row gap-2">
{([true, false] as const).map((v) => (
<Chip
key={String(v)}
variant="filter"
selected={recommend === v}
onPress={() => setRecommend(v)}
accessibilityLabel={v ? "Yes, recommend" : "No, do not recommend"}
accessibilityState={{ selected: recommend === v }}
className={recommend === v ? "border-transparent bg-[#1B1B26]" : undefined}
textClassName={recommend === v ? "text-white" : undefined}
>
{v ? "Yes" : "No"}
</Chip>
))}
</View>
</View>
{/* Photos — thumbs plus a dashed picker tile in the same row,
the media-picker idiom; each thumb gets a remove X. */}
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
Photos
</Text>
<View className="flex-row flex-wrap gap-2">
{photos.map((uri, i) => (
<View key={`${uri}-${i}`} className="h-16 w-16">
<Image
source={{ uri }}
className="h-16 w-16 rounded-xl bg-muted"
accessibilityLabel={`Attached photo ${i + 1}`}
/>
<Press
haptic="light"
onPress={() => setPhotos((p) => p.filter((_, pi) => pi !== i))}
accessibilityRole="button"
accessibilityLabel={`Remove photo ${i + 1}`}
hitSlop={6}
className="absolute -right-1.5 -top-1.5 h-5 w-5 items-center justify-center rounded-full bg-foreground"
>
<Icon name="x" size="sm" color="background" />
</Press>
</View>
))}
<Press
haptic="light"
onPress={addPhoto}
accessibilityRole="button"
accessibilityLabel="Add photos"
className="h-16 w-16 items-center justify-center rounded-xl border-2 border-dashed border-[#BEF04C]"
>
<Icon name="camera" size="sm" color="#65A30D" />
</Press>
</View>
</View>
</Mount>
</ScrollView>
<StickyBottomBar>
<Button
tone="inverse" className="w-full bg-[#1B1B26]"
haptic="medium"
onPress={() => {
haptics.medium();
onSubmit?.({ rating, title, body, fit, recommend, photos });
}}
disabled={title.trim().length < 3}
accessibilityLabel="Submit review"
>
Submit review
</Button>
</StickyBottomBar>
</SafeAreaView>
);
}
WriteReviewScreen.displayName = "WriteReviewScreen";
Recreate the "Write a review Screen" screen in React Native using LumioUI.
Goal: Star rating, headline, body, and photo prompt for a product review. Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add ecommerce-write-review
```
Or pull just the components: `npx lumioui add button chip icon image input text textarea`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×2
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×4
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×2
### `input` — Labeled field with focus ring, error shake and success check.
- `<Input>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×10
### `textarea` — Multiline field with auto-grow and character count.
- `<Textarea>` ×1
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/ecommerce/write-review.tsx` exporting WriteReviewScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, Glass, Icon, Image, Input, Text, Textarea, } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { LumioMark, Mount, Press, StickyBottomBar, catalogImg, type CatalogPhoto } from "../shared";
export interface WriteReviewScreenProps {
productName?: string;
productPhoto?: CatalogPhoto;
onSubmit?: (review: {
rating: number;
title: string;
body: string;
fit?: string;
recommend?: boolean;
photos: string[];
}) => void;
onBack?: () => void;
}
const FIT_OPTIONS = ["Runs small", "True to size", "Runs large"];
/** Pool the seeded thumbs cycle through when "Add photos" is tapped. */
const PHOTO_POOL: CatalogPhoto[] = ["overcoat", "shop", "flatlay"];
/** Write review — star rating, headline, body, photo hint, submit. */
export function WriteReviewScreen({
productName = "Oversized wool overcoat",
productPhoto = "overcoat",
onSubmit,
onBack,
}: WriteReviewScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [rating, setRating] = React.useState(4);
const [title, setTitle] = React.useState("Fits like a dream");
const [body, setBody] = React.useState(
"True to size, warm without the bulk. The wool feels premium and the lining is smooth.",
);
const [fit, setFit] = React.useState("True to size");
const [recommend, setRecommend] = React.useState<boolean | undefined>(true);
const [photos, setPhotos] = React.useState<string[]>([
catalogImg("overcoat", 240, 240),
]);
const addPhoto = () => {
haptics.selection();
setPhotos((p) =>
p.length >= 5
? p
: [...p, catalogImg(PHOTO_POOL[p.length % PHOTO_POOL.length] ?? "overcoat", 240, 240)]
);
};
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back disc + centered title pill. */}
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => onBack?.()}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="h-11 w-11" />
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View className="h-11 flex-row items-center justify-center gap-2 rounded-full pl-3.5 pr-5">
<Glass effect="regular" className="absolute inset-0 rounded-full" />
<LumioMark size={18} />
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Write a review
</Text>
</View>
</View>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 120 }}
>
{/* Identity line — who the review posts as. */}
<Mount>
<Text variant="muted" className="px-6 pb-2 text-xs">
Posting publicly as Amara C. — order HL-2094
</Text>
</Mount>
{/* Product being reviewed — context before the form. */}
<Mount className="mx-6 mb-6 flex-row items-center gap-3 rounded-3xl bg-white p-3 dark:bg-[#1A1A20]">
<Image
source={{ uri: catalogImg(productPhoto, 160, 200) }}
className="h-16 w-14 rounded-xl bg-muted"
accessibilityLabel={productName}
/>
<View>
<Text variant="small" className="text-muted-foreground">
Reviewing
</Text>
<Text variant="large">{productName}</Text>
</View>
</Mount>
{/* Rating — tappable stars, real feedback. */}
<Mount delay={60} className="mx-6 mb-6 items-center gap-2 rounded-3xl bg-white p-4 dark:bg-[#1A1A20]">
<View className="flex-row gap-2">
{[1, 2, 3, 4, 5].map((n) => (
<Button
key={n}
variant="ghost"
haptic="light"
onPress={() => {
haptics.selection();
setRating(n);
}}
accessibilityLabel={`${n} star${n > 1 ? "s" : ""}`}
accessibilityState={{ selected: rating === n }}
className="h-11 w-11 p-0"
>
<Icon
name="star"
size="lg"
color={n <= rating ? "warning" : "mutedForeground"}
filled={n <= rating}
/>
</Button>
))}
</View>
<Text variant="small" className="text-muted-foreground">
{rating === 5 ? "Excellent" : rating === 4 ? "Great" : rating === 3 ? "Good" : rating === 2 ? "Fair" : "Poor"}
</Text>
</Mount>
<Mount delay={120} className="gap-4 px-6">
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
Headline
</Text>
<Input
value={title}
onChangeText={setTitle}
placeholder="Sum it up in a line"
accessibilityLabel="Review headline"
/>
</View>
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
Your review
</Text>
<Textarea
value={body}
onChangeText={setBody}
placeholder="How's the fit, the fabric, the quality?"
minHeight={120}
accessibilityLabel="Review body"
/>
</View>
{/* Structured fit — the apparel-review signature row. */}
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
How does it fit?
</Text>
<View className="flex-row gap-2">
{FIT_OPTIONS.map((f) => (
<Chip
key={f}
variant="filter"
selected={fit === f}
onPress={() => setFit(f)}
accessibilityLabel={`Fit: ${f}`}
accessibilityState={{ selected: fit === f }}
className={fit === f ? "border-transparent bg-[#1B1B26]" : undefined}
textClassName={fit === f ? "text-white" : undefined}
>
{f}
</Chip>
))}
</View>
</View>
{/* Recommend — binary chip pair. */}
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
Would you recommend it?
</Text>
<View className="flex-row gap-2">
{([true, false] as const).map((v) => (
<Chip
key={String(v)}
variant="filter"
selected={recommend === v}
onPress={() => setRecommend(v)}
accessibilityLabel={v ? "Yes, recommend" : "No, do not recommend"}
accessibilityState={{ selected: recommend === v }}
className={recommend === v ? "border-transparent bg-[#1B1B26]" : undefined}
textClassName={recommend === v ? "text-white" : undefined}
>
{v ? "Yes" : "No"}
</Chip>
))}
</View>
</View>
{/* Photos — thumbs plus a dashed picker tile in the same row,
the media-picker idiom; each thumb gets a remove X. */}
<View className="gap-2">
<Text variant="small" className="text-muted-foreground">
Photos
</Text>
<View className="flex-row flex-wrap gap-2">
{photos.map((uri, i) => (
<View key={`${uri}-${i}`} className="h-16 w-16">
<Image
source={{ uri }}
className="h-16 w-16 rounded-xl bg-muted"
accessibilityLabel={`Attached photo ${i + 1}`}
/>
<Press
haptic="light"
onPress={() => setPhotos((p) => p.filter((_, pi) => pi !== i))}
accessibilityRole="button"
accessibilityLabel={`Remove photo ${i + 1}`}
hitSlop={6}
className="absolute -right-1.5 -top-1.5 h-5 w-5 items-center justify-center rounded-full bg-foreground"
>
<Icon name="x" size="sm" color="background" />
</Press>
</View>
))}
<Press
haptic="light"
onPress={addPhoto}
accessibilityRole="button"
accessibilityLabel="Add photos"
className="h-16 w-16 items-center justify-center rounded-xl border-2 border-dashed border-[#BEF04C]"
>
<Icon name="camera" size="sm" color="#65A30D" />
</Press>
</View>
</View>
</Mount>
</ScrollView>
<StickyBottomBar>
<Button
tone="inverse" className="w-full bg-[#1B1B26]"
haptic="medium"
onPress={() => {
haptics.medium();
onSubmit?.({ rating, title, body, fit, recommend, photos });
}}
disabled={title.trim().length < 3}
accessibilityLabel="Submit review"
>
Submit review
</Button>
</StickyBottomBar>
</SafeAreaView>
);
}
WriteReviewScreen.displayName = "WriteReviewScreen";
```
Screen: https://lumioui.dev/screens/ecommerce-write-review