v0.1 public beta early access on GitHub
Home / Screens / ecommerce-product-detail-hero
Screen template

Product Detail — Hero Screen

Marketplace PDP: floating photo chips, compare price, size chips, seller row, price in CTA

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · selection
$ npx lumioui add ecommerce-product-detail-hero
import { ProductDetailHeroScreen } from "@/screens/ecommerce/product-detail-hero";
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
ecommerce/product-detail-hero.tsxsource · yours to edit
import * as React from "react";
import { useState } from "react";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Button, Chip, Glass, Icon, Image, Stepper, Text } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { catalogImg, Press, StickyBottomBar, BLOCK_STAGGER, Mount, SERIF } from "../shared";

export interface ProductDetailHeroScreenProps {
  name?: string;
  price?: number;
  comparePrice?: number;
  rating?: number;
  reviewCount?: number;
  imageUrl?: string;
  /** Extra gallery frames — hero pages through thumbnails below the photo. */
  images?: string[];
  sizes?: string[];
  seller?: { name: string; rating: number; sales: string; avatarUrl?: string };
  onBuy?: (size: string | undefined, qty: number) => void;
  onWishlist?: () => void;
  onBack?: () => void;
}

const fmt = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2 })}`;

/** Product detail — marketplace-hero variant (StockX/Shake Shack idiom):
 *  full-bleed photo with floating chrome chips, name + rating, struck
 *  compare price, size chips, seller row, and the price INSIDE the buy CTA. */
export function ProductDetailHeroScreen({
  name = "Court Low Sneaker",
  price = 60,
  comparePrice = 99,
  rating = 4.8,
  reviewCount = 1240,
  imageUrl = catalogImg("sneakers", 900, 900),
  images,
  sizes = ["7", "8", "9", "10", "11"],
  seller = { name: "Kickflip Supply", rating: 4.9, sales: "2.1k sales", avatarUrl: "https://i.pravatar.cc/100?img=12" },
  onBuy,
  onWishlist,
  onBack,
}: ProductDetailHeroScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const [size, setSize] = useState<string | undefined>("9");
  const [qty, setQty] = useState(1);
  const [wishlisted, setWishlisted] = useState(false);
  const gallery = images ?? [imageUrl, catalogImg("runner", 900, 900), catalogImg("flatlay", 900, 900)];
  const [frame, setFrame] = useState(0);

  return (
    <View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
      <ScrollView
        showsVerticalScrollIndicator={false}
        contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
        contentInsetAdjustmentBehavior="never"
      >
        {/* Hero photo with floating chrome chips */}
        <View>
          <Image
            source={{ uri: gallery[frame] }}
            rounded="none"
            aspectRatio={1}
            className="w-full"
            accessibilityLabel={`${name} photo ${frame + 1} of ${gallery.length}`}
          />
          <View style={{ top: insets.top + 8 }} className="absolute left-4 h-11 w-11 items-center justify-center rounded-full">
            <Glass interactive effect="regular" className="absolute inset-0 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>
          </View>
          <View style={{ top: insets.top + 8 }} className="absolute right-4 flex-row gap-2">
            <View className="h-11 w-11 items-center justify-center rounded-full">
              <Glass interactive effect="regular" className="absolute inset-0 rounded-full" />
              <Press
                haptic="light"
                onPress={() => {
                  setWishlisted((w) => !w);
                  onWishlist?.();
                }}
                accessibilityRole="button"
                accessibilityLabel={wishlisted ? "Remove from wishlist" : "Add to wishlist"}
                accessibilityState={{ selected: wishlisted }}
                className="h-9 w-9 items-center justify-center"
              >
                <Icon name="heart" size="md" color={wishlisted ? "destructive" : "foreground"} filled={wishlisted} />
              </Press>
            </View>
            <View className="h-11 w-11 items-center justify-center rounded-full">
              <Glass interactive effect="regular" className="absolute inset-0 rounded-full" />
              <Press
                haptic="light"
                onPress={() => haptics.light()}
                accessibilityRole="button"
                accessibilityLabel="Share product"
                className="h-9 w-9 items-center justify-center"
              >
                <Icon name="share" size="md" color="foreground" />
              </Press>
            </View>
          </View>
        </View>

        {/* Gallery strip — thumbs swap the hero frame. */}
        <View className="flex-row gap-2 px-6 pt-3">
          {gallery.map((uri, gi) => (
            <Press
              key={gi}
              haptic="light"
              pressedScale={0.94}
              onPress={() => setFrame(gi)}
              accessibilityRole="imagebutton"
              accessibilityLabel={`Show photo ${gi + 1}`}
              accessibilityState={{ selected: frame === gi }}
              className={cn(
                "h-16 w-16 overflow-hidden rounded-xl border-2",
                frame === gi ? "border-[#65A30D]" : "border-transparent opacity-70"
              )}
            >
              <Image source={{ uri }} className="h-full w-full" rounded="none" accessibilityElementsHidden />
            </Press>
          ))}
        </View>

        <Mount delay={BLOCK_STAGGER * 2} className="gap-5 px-6 pt-4">
          <View className="gap-1.5">
            <Text variant="h3" style={{ fontFamily: SERIF }}>{name}</Text>
            <View className="flex-row items-center gap-2">
              <Icon name="star" size="sm" color="warning" filled />
              <Text variant="small" className="font-medium tabular-nums">
                {rating}
              </Text>
              <Text variant="muted" className="text-xs">
                ({reviewCount.toLocaleString()} reviews)
              </Text>
            </View>
            <View className="flex-row items-baseline gap-2.5 pt-1">
              <Text variant="h4" className="tabular-nums">
                {fmt(price)}
              </Text>
              <Text variant="muted" className="line-through tabular-nums">
                {fmt(comparePrice)}
              </Text>
              <View className="rounded-full bg-success/15 px-2 py-0.5">
                <Text className="text-xs font-semibold text-success">
                  -{Math.round((1 - price / comparePrice) * 100)}%
                </Text>
              </View>
            </View>
            <View className="flex-row pt-1">
              <View className="flex-row items-center gap-1 rounded-full bg-warning/15 px-2.5 py-1">
                <Icon name="flame" size="sm" color="warning" />
                <Text className="text-[11px] font-medium text-warning">
                  Selling fast — 312 sold this week
                </Text>
              </View>
            </View>
          </View>

          {/* Size + qty */}
          <View className="gap-2.5">
            <Text variant="small" className="font-semibold">
              Size{size ? ` — US ${size}` : ""}
            </Text>
            <View className="flex-row flex-wrap gap-2">
              {sizes.map((s) => (
                <Chip
                  key={s}
                  variant="filter"
                  selected={s === size}
                  onPress={() => setSize(s)}
                  accessibilityLabel={`Size US ${s}`}
                  className={cn("h-11 min-w-12 justify-center px-4", s === size && "border-transparent bg-[#1B1B26]")}
                  textClassName={cn(s === size && "text-white")}
                >
                  {s}
                </Chip>
              ))}
            </View>
            {/* Stock whisper — scarcity belongs right under the size picker. */}
            {size === "9" ? (
              <Text variant="small" className="text-xs font-medium text-[#65A30D]">
                Only 3 left in US 9 — selling fast
              </Text>
            ) : null}
          </View>

          <View className="flex-row items-center justify-between">
            <Text variant="small" className="font-semibold">
              Quantity
            </Text>
            <Stepper value={qty} onValueChange={setQty} min={1} max={9} />
          </View>

          {/* Seller row — verified badge + shop link so the row does work. */}
          <Press
            haptic="light"
            pressedScale={0.98}
            accessibilityRole="button"
            accessibilityLabel={`Visit ${seller.name} shop`}
            className="flex-row items-center gap-3 rounded-3xl bg-white p-4 dark:bg-[#1A1A20]"
          >
            <Avatar
              size="md"
              source={seller.avatarUrl ? { uri: seller.avatarUrl } : undefined}
              fallback={seller.name}
              alt={`${seller.name} avatar`}
            />
            <View className="flex-1 gap-0.5">
              <View className="flex-row items-center gap-1.5">
                <Text variant="small" className="font-semibold">
                  {seller.name}
                </Text>
                <View className="flex-row items-center gap-1 rounded-full bg-success/15 px-1.5 py-0.5">
                  <Icon name="check-circle" size="sm" color="success" />
                  <Text className="text-[10px] font-medium text-success">Verified seller</Text>
                </View>
              </View>
              <View className="flex-row items-center gap-1.5">
                <Icon name="star" size="sm" color="warning" filled />
                <Text variant="muted" className="text-xs">
                  {seller.rating} · {seller.sales}
                </Text>
              </View>
            </View>
            <Icon name="chevron-right" size="md" color="mutedForeground" />
          </Press>

          <View className="flex-row items-center gap-2">
            <Icon name="truck" size="sm" color="mutedForeground" />
            <Text variant="muted" className="text-xs">
              Free shipping · arrives in 3–5 days · 30-day returns
            </Text>
          </View>
        </Mount>
      </ScrollView>

      {/* Price lives inside the CTA — StockX "Buy for $X" idiom. */}
      <StickyBottomBar>
        <Button
          size="lg"
          onPress={() => {
            haptics.light();
            onBuy?.(size, qty);
          }}
          accessibilityLabel={`Buy for ${fmt(price * qty)}`}
          tone="inverse" className="w-full bg-[#1B1B26]"
        >
          {`Buy ${size ? `US ${size} ` : ""}${qty > 1 ? `×${qty} ` : ""}for ${fmt(price * qty)}`}
        </Button>
      </StickyBottomBar>
    </View>
  );
}
ProductDetailHeroScreen.displayName = "ProductDetailHeroScreen";
agent prompt — paste into Claude Code, Cursor, Devin…
Recreate the "Product Detail — Hero Screen" screen in React Native using LumioUI.

Goal: Marketplace PDP: floating photo chips, compare price, size chips, seller row, price in CTA 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-product-detail-hero
```

Or pull just the components: `npx lumioui add avatar button chip glass icon image stepper text`

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

### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×1

### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×1

### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×1

### `glass` — iOS 26 liquid-glass surface — real glass via expo-glass-effect (Expo Go) or @callstack/liquid-glass (dev builds), SVG refraction + specular rim on web, frosted fallback elsewhere; GlassContainer melts adjacent panes into one blob
- `<Glass>` ×3

### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×9

### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×2

### `stepper` — Plus/minus quantity control with hold-to-repeat.
- `<Stepper>` ×1

### `text` — Themed type scale from caption to display.
- `<Text>` ×14

## 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/product-detail-hero.tsx` exporting ProductDetailHeroScreen. 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 { useState } from "react";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Button, Chip, Glass, Icon, Image, Stepper, Text } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { catalogImg, Press, StickyBottomBar, BLOCK_STAGGER, Mount, SERIF } from "../shared";

export interface ProductDetailHeroScreenProps {
  name?: string;
  price?: number;
  comparePrice?: number;
  rating?: number;
  reviewCount?: number;
  imageUrl?: string;
  /** Extra gallery frames — hero pages through thumbnails below the photo. */
  images?: string[];
  sizes?: string[];
  seller?: { name: string; rating: number; sales: string; avatarUrl?: string };
  onBuy?: (size: string | undefined, qty: number) => void;
  onWishlist?: () => void;
  onBack?: () => void;
}

const fmt = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2 })}`;

/** Product detail — marketplace-hero variant (StockX/Shake Shack idiom):
 *  full-bleed photo with floating chrome chips, name + rating, struck
 *  compare price, size chips, seller row, and the price INSIDE the buy CTA. */
export function ProductDetailHeroScreen({
  name = "Court Low Sneaker",
  price = 60,
  comparePrice = 99,
  rating = 4.8,
  reviewCount = 1240,
  imageUrl = catalogImg("sneakers", 900, 900),
  images,
  sizes = ["7", "8", "9", "10", "11"],
  seller = { name: "Kickflip Supply", rating: 4.9, sales: "2.1k sales", avatarUrl: "https://i.pravatar.cc/100?img=12" },
  onBuy,
  onWishlist,
  onBack,
}: ProductDetailHeroScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const [size, setSize] = useState<string | undefined>("9");
  const [qty, setQty] = useState(1);
  const [wishlisted, setWishlisted] = useState(false);
  const gallery = images ?? [imageUrl, catalogImg("runner", 900, 900), catalogImg("flatlay", 900, 900)];
  const [frame, setFrame] = useState(0);

  return (
    <View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
      <ScrollView
        showsVerticalScrollIndicator={false}
        contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
        contentInsetAdjustmentBehavior="never"
      >
        {/* Hero photo with floating chrome chips */}
        <View>
          <Image
            source={{ uri: gallery[frame] }}
            rounded="none"
            aspectRatio={1}
            className="w-full"
            accessibilityLabel={`${name} photo ${frame + 1} of ${gallery.length}`}
          />
          <View style={{ top: insets.top + 8 }} className="absolute left-4 h-11 w-11 items-center justify-center rounded-full">
            <Glass interactive effect="regular" className="absolute inset-0 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>
          </View>
          <View style={{ top: insets.top + 8 }} className="absolute right-4 flex-row gap-2">
            <View className="h-11 w-11 items-center justify-center rounded-full">
              <Glass interactive effect="regular" className="absolute inset-0 rounded-full" />
              <Press
                haptic="light"
                onPress={() => {
                  setWishlisted((w) => !w);
                  onWishlist?.();
                }}
                accessibilityRole="button"
                accessibilityLabel={wishlisted ? "Remove from wishlist" : "Add to wishlist"}
                accessibilityState={{ selected: wishlisted }}
                className="h-9 w-9 items-center justify-center"
              >
                <Icon name="heart" size="md" color={wishlisted ? "destructive" : "foreground"} filled={wishlisted} />
              </Press>
            </View>
            <View className="h-11 w-11 items-center justify-center rounded-full">
              <Glass interactive effect="regular" className="absolute inset-0 rounded-full" />
              <Press
                haptic="light"
                onPress={() => haptics.light()}
                accessibilityRole="button"
                accessibilityLabel="Share product"
                className="h-9 w-9 items-center justify-center"
              >
                <Icon name="share" size="md" color="foreground" />
              </Press>
            </View>
          </View>
        </View>

        {/* Gallery strip — thumbs swap the hero frame. */}
        <View className="flex-row gap-2 px-6 pt-3">
          {gallery.map((uri, gi) => (
            <Press
              key={gi}
              haptic="light"
              pressedScale={0.94}
              onPress={() => setFrame(gi)}
              accessibilityRole="imagebutton"
              accessibilityLabel={`Show photo ${gi + 1}`}
              accessibilityState={{ selected: frame === gi }}
              className={cn(
                "h-16 w-16 overflow-hidden rounded-xl border-2",
                frame === gi ? "border-[#65A30D]" : "border-transparent opacity-70"
              )}
            >
              <Image source={{ uri }} className="h-full w-full" rounded="none" accessibilityElementsHidden />
            </Press>
          ))}
        </View>

        <Mount delay={BLOCK_STAGGER * 2} className="gap-5 px-6 pt-4">
          <View className="gap-1.5">
            <Text variant="h3" style={{ fontFamily: SERIF }}>{name}</Text>
            <View className="flex-row items-center gap-2">
              <Icon name="star" size="sm" color="warning" filled />
              <Text variant="small" className="font-medium tabular-nums">
                {rating}
              </Text>
              <Text variant="muted" className="text-xs">
                ({reviewCount.toLocaleString()} reviews)
              </Text>
            </View>
            <View className="flex-row items-baseline gap-2.5 pt-1">
              <Text variant="h4" className="tabular-nums">
                {fmt(price)}
              </Text>
              <Text variant="muted" className="line-through tabular-nums">
                {fmt(comparePrice)}
              </Text>
              <View className="rounded-full bg-success/15 px-2 py-0.5">
                <Text className="text-xs font-semibold text-success">
                  -{Math.round((1 - price / comparePrice) * 100)}%
                </Text>
              </View>
            </View>
            <View className="flex-row pt-1">
              <View className="flex-row items-center gap-1 rounded-full bg-warning/15 px-2.5 py-1">
                <Icon name="flame" size="sm" color="warning" />
                <Text className="text-[11px] font-medium text-warning">
                  Selling fast — 312 sold this week
                </Text>
              </View>
            </View>
          </View>

          {/* Size + qty */}
          <View className="gap-2.5">
            <Text variant="small" className="font-semibold">
              Size{size ? ` — US ${size}` : ""}
            </Text>
            <View className="flex-row flex-wrap gap-2">
              {sizes.map((s) => (
                <Chip
                  key={s}
                  variant="filter"
                  selected={s === size}
                  onPress={() => setSize(s)}
                  accessibilityLabel={`Size US ${s}`}
                  className={cn("h-11 min-w-12 justify-center px-4", s === size && "border-transparent bg-[#1B1B26]")}
                  textClassName={cn(s === size && "text-white")}
                >
                  {s}
                </Chip>
              ))}
            </View>
            {/* Stock whisper — scarcity belongs right under the size picker. */}
            {size === "9" ? (
              <Text variant="small" className="text-xs font-medium text-[#65A30D]">
                Only 3 left in US 9 — selling fast
              </Text>
            ) : null}
          </View>

          <View className="flex-row items-center justify-between">
            <Text variant="small" className="font-semibold">
              Quantity
            </Text>
            <Stepper value={qty} onValueChange={setQty} min={1} max={9} />
          </View>

          {/* Seller row — verified badge + shop link so the row does work. */}
          <Press
            haptic="light"
            pressedScale={0.98}
            accessibilityRole="button"
            accessibilityLabel={`Visit ${seller.name} shop`}
            className="flex-row items-center gap-3 rounded-3xl bg-white p-4 dark:bg-[#1A1A20]"
          >
            <Avatar
              size="md"
              source={seller.avatarUrl ? { uri: seller.avatarUrl } : undefined}
              fallback={seller.name}
              alt={`${seller.name} avatar`}
            />
            <View className="flex-1 gap-0.5">
              <View className="flex-row items-center gap-1.5">
                <Text variant="small" className="font-semibold">
                  {seller.name}
                </Text>
                <View className="flex-row items-center gap-1 rounded-full bg-success/15 px-1.5 py-0.5">
                  <Icon name="check-circle" size="sm" color="success" />
                  <Text className="text-[10px] font-medium text-success">Verified seller</Text>
                </View>
              </View>
              <View className="flex-row items-center gap-1.5">
                <Icon name="star" size="sm" color="warning" filled />
                <Text variant="muted" className="text-xs">
                  {seller.rating} · {seller.sales}
                </Text>
              </View>
            </View>
            <Icon name="chevron-right" size="md" color="mutedForeground" />
          </Press>

          <View className="flex-row items-center gap-2">
            <Icon name="truck" size="sm" color="mutedForeground" />
            <Text variant="muted" className="text-xs">
              Free shipping · arrives in 3–5 days · 30-day returns
            </Text>
          </View>
        </Mount>
      </ScrollView>

      {/* Price lives inside the CTA — StockX "Buy for $X" idiom. */}
      <StickyBottomBar>
        <Button
          size="lg"
          onPress={() => {
            haptics.light();
            onBuy?.(size, qty);
          }}
          accessibilityLabel={`Buy for ${fmt(price * qty)}`}
          tone="inverse" className="w-full bg-[#1B1B26]"
        >
          {`Buy ${size ? `US ${size} ` : ""}${qty > 1 ? `×${qty} ` : ""}for ${fmt(price * qty)}`}
        </Button>
      </StickyBottomBar>
    </View>
  );
}
ProductDetailHeroScreen.displayName = "ProductDetailHeroScreen";
```

Screen: https://lumioui.dev/screens/ecommerce-product-detail-hero
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗