v0.1 public beta early access on GitHub
Home / Screens / ecommerce-write-review
Screen template

Write a review Screen

Star rating, headline, body, and photo prompt for a product review.

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · selection
$ npx lumioui add ecommerce-write-review
import { WriteReviewScreen } from "@/screens/ecommerce/write-review";
Registry dependencies7
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
ecommerce/write-review.tsxsource · yours to edit
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";
agent prompt — paste into Claude Code, Cursor, Devin…
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
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗