Marketplace PDP: floating photo chips, compare price, size chips, seller row, price in CTA
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";
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