Line items with thumbnails, quantity steppers and line totals, free-shipping progress with unlocked state, applied-promo row with live discount, and a shadowed sticky checkout bar
import * as React from "react";
import { useState } from "react";
import { View, FlatList } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Button, Card, EmptyState, Glass, Icon, Image, Input, Progress, Separator, Stepper, Text } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { catalogImg, LumioMark, Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
import { StickyBottomBar } from "../shared";
export interface CartItem {
id: string;
name: string;
brand?: string;
variantLabel?: string;
price: number;
quantity: number;
imageUrl?: string;
}
export interface CartScreenProps {
items?: CartItem[];
currency?: string;
shippingCost?: number;
/** Free shipping above this subtotal; set shippingCost for the fallback. */
freeShippingThreshold?: number;
onUpdateQty?: (item: CartItem, quantity: number) => void;
onRemove?: (item: CartItem) => void;
onApplyPromo?: (code: string) => void;
onCheckout?: () => void;
onBrowse?: () => void;
}
const img = (p: Parameters<typeof catalogImg>[0]) => catalogImg(p, 200, 250);
const DEFAULT_ITEMS: CartItem[] = [
{ id: "1", name: "Structured Wool Overcoat", brand: "Nord Standard", variantLabel: "Camel · M", price: 248, quantity: 1, imageUrl: img("overcoat") },
{ id: "3", name: "Leather Lace-Up Boots", brand: "Marchetti & Sons", variantLabel: "Black · 42", price: 295, quantity: 1, imageUrl: img("boots") },
{ id: "7", name: "Heritage Graphic Tee", brand: "Atelier M", variantLabel: "Indigo", price: 85, quantity: 2, imageUrl: img("graphicTee") },
];
/** Prices always render with two decimals ($248.00). */
const fmt = (n: number, currency = "$") =>
`${currency}${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
function SummaryRow({
label,
value,
bold,
accent,
}: {
label: string;
value: string;
bold?: boolean;
/** Success-green value — used for promo discounts. */
accent?: boolean;
}) {
return (
<View className="flex-row items-center justify-between py-1">
<Text variant={bold ? "large" : "muted"}>{label}</Text>
<Text
variant={bold ? "large" : "small"}
className={accent ? "font-semibold tabular-nums text-success" : "tabular-nums"}
>
{value}
</Text>
</View>
);
}
/** Bag — line items with thumbnails, qty steppers and remove affordances,
* promo field, order summary, and a sticky checkout bar. */
export function CartScreen({
items = DEFAULT_ITEMS,
currency = "$",
shippingCost = 6.99,
freeShippingThreshold = 150,
onUpdateQty,
onRemove,
onApplyPromo,
onCheckout,
onBrowse,
}: CartScreenProps) {
const haptics = useHaptic();
const [promo, setPromo] = useState("");
/** Code currently applied — swaps the promo field for a success row. */
const [appliedPromo, setAppliedPromo] = useState<string | null>(null);
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const shipping = items.length === 0 || subtotal >= freeShippingThreshold ? 0 : shippingCost;
// Template discount — a real host wires this to onApplyPromo's response.
const discount = appliedPromo ? Math.round(subtotal * 0.1 * 100) / 100 : 0;
const total = subtotal - discount + shipping;
const remaining = Math.max(0, freeShippingThreshold - subtotal);
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="px-6 pb-2 pt-4">
<View className="flex-row items-center gap-2 pb-1.5">
<LumioMark size={18} />
<Text variant="muted" className="text-xs font-semibold uppercase tracking-widest">
Lumio Market
</Text>
</View>
<Text variant="h2">
Bag{itemCount > 0 ? ` (${itemCount})` : ""}
</Text>
</View>
{items.length === 0 ? (
<View className="flex-1">
<EmptyState
title="Your bag is empty"
description="Items you add will appear here."
action={
onBrowse ? (
<Button variant="secondary" onPress={onBrowse}>
Continue shopping
</Button>
) : undefined
}
className="flex-1"
/>
</View>
) : (
<>
<FlatList
data={items}
keyExtractor={(item) => item.id}
className="flex-1"
contentContainerClassName="px-6 pb-56"
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View
className="mb-4 gap-2 rounded-2xl bg-[#EDF8C4] p-4 dark:bg-[#1E2410]"
accessibilityRole="text"
accessibilityLabel={
remaining > 0
? `${fmt(remaining, currency)} away from free shipping`
: "Free shipping unlocked"
}
>
{remaining > 0 ? (
<>
<Text variant="small" className="font-medium">
You're {fmt(remaining, currency)} away from{" "}
<Text variant="small" className="font-semibold text-[#65A30D]">
free shipping
</Text>
</Text>
<Progress value={(subtotal / freeShippingThreshold) * 100} className="h-1.5" />
</>
) : (
<View className="flex-row items-center gap-1.5">
<View className="h-5 w-5 items-center justify-center rounded-full bg-success">
<Icon name="check" size="sm" color="successForeground" />
</View>
<Text variant="small" className="font-medium text-success">
Free shipping unlocked
</Text>
</View>
)}
</View>
}
ItemSeparatorComponent={() => <View className="h-3" />}
renderItem={({ item, index }) => (
<Mount delay={Math.min(index * ROW_STAGGER, ROW_STAGGER_CAP)}>
<View className="flex-row gap-3 overflow-hidden rounded-3xl bg-white p-3 dark:bg-[#1A1A20]">
<Image
source={item.imageUrl ? { uri: item.imageUrl } : undefined}
rounded="xl"
aspectRatio={4 / 5}
className="w-24"
accessibilityLabel={`Photo of ${item.name}`}
/>
<View className="flex-1">
<View className="flex-row items-start justify-between">
<View className="flex-1 gap-0.5 pr-2">
<Text variant="small" numberOfLines={1}>
{item.name}
</Text>
{item.brand || item.variantLabel ? (
<Text variant="muted" className="text-xs" numberOfLines={1}>
{[item.brand, item.variantLabel].filter(Boolean).join(" · ")}
</Text>
) : null}
</View>
<Press
onPress={() => {
haptics.warning();
onRemove?.(item);
}}
accessibilityRole="button"
accessibilityLabel={`Remove ${item.name} from bag`}
hitSlop={12}
className="h-11 w-11 items-center justify-center"
>
<Icon name="trash" size="sm" color="mutedForeground" />
</Press>
</View>
<View className="flex-row items-center justify-between pt-2">
<Stepper
value={item.quantity}
min={0}
onValueChange={(qty) =>
qty === 0 ? onRemove?.(item) : onUpdateQty?.(item, qty)
}
accessibilityLabel={`Quantity for ${item.name}`}
/>
<View className="items-end">
<Text
variant="small"
className="font-semibold tabular-nums"
accessibilityLabel={`${item.name} line total ${fmt(item.price * item.quantity, currency)}`}
>
{fmt(item.price * item.quantity, currency)}
</Text>
{item.quantity > 1 ? (
<Text variant="muted" className="text-xs tabular-nums">
{fmt(item.price, currency)} each
</Text>
) : null}
</View>
</View>
</View>
</View>
</Mount>
)}
/>
<StickyBottomBar className="gap-4">
{appliedPromo ? (
<View className="flex-row items-center justify-between rounded-2xl bg-success/10 py-1 pl-4">
<View className="flex-1 flex-row items-center gap-2">
<Icon name="check-circle" size="sm" color="success" />
<Text variant="small">
Promo <Text variant="small" className="font-semibold">{appliedPromo}</Text> applied — 10% off
</Text>
</View>
<Press
onPress={() => {
haptics.light();
setAppliedPromo(null);
setPromo("");
}}
accessibilityRole="button"
accessibilityLabel={`Remove promo code ${appliedPromo}`}
hitSlop={12}
className="h-11 w-11 items-center justify-center"
>
<Icon name="x" size="sm" color="mutedForeground" />
</Press>
</View>
) : (
<View className="flex-row gap-2">
<View className="flex-1">
<Input
glass
placeholder="Promo code"
value={promo}
onChangeText={setPromo}
autoCapitalize="characters"
accessibilityLabel="Promo code"
/>
</View>
<Button
variant="secondary"
disabled={promo.trim().length === 0}
onPress={() => {
const code = promo.trim();
haptics.success();
setAppliedPromo(code);
onApplyPromo?.(code);
}}
>
Apply
</Button>
</View>
)}
<Card className="gap-0 overflow-hidden rounded-3xl border-0 bg-white p-5 dark:bg-[#1A1A20]">
<SummaryRow label="Subtotal" value={fmt(subtotal, currency)} />
{discount > 0 ? (
<SummaryRow
label={`Promo (${appliedPromo})`}
value={`-${fmt(discount, currency)}`}
accent
/>
) : null}
<SummaryRow
label="Shipping"
value={shipping === 0 ? "Free" : fmt(shipping, currency)}
/>
<Separator className="my-2" />
<SummaryRow label="Total" value={fmt(total, currency)} bold />
{/* Delivery promise — the last reassurance before the CTA. */}
<View className="mt-2.5 flex-row items-center gap-1.5">
<Icon name="truck" size="sm" color="mutedForeground" />
<Text variant="muted" className="text-xs">
Arrives{" "}
{new Date(Date.now() + 5 * 86400000).toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" })}{" "}
— free returns for 30 days
</Text>
</View>
</Card>
<Button
haptic="heavy"
onPress={() => onCheckout?.()}
accessibilityLabel={`Checkout, total ${fmt(total, currency)}`}
tone="inverse"
>
Checkout · {fmt(total, currency)}
</Button>
</StickyBottomBar>
</>
)}
</SafeAreaView>
);
}
Recreate the "Cart Screen" screen in React Native using LumioUI.
Goal: Line items with thumbnails, quantity steppers and line totals, free-shipping progress with unlocked state, applied-promo row with live discount, and a shadowed sticky checkout bar 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-cart
```
Or pull just the components: `npx lumioui add button card empty-state icon image input progress separator 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
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×3
### `card` — Compound surface — header, title, content and footer slots.
- `<Card>` ×1
### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×5
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×1
### `input` — Labeled field with focus ring, error shake and success check.
- `<Input>` ×1
### `progress` — Animated fill bar with indeterminate sweep mode.
- `<Progress>` ×1
### `separator` — Hairline rule with orientation and spacing control.
- `<Separator>` ×1
### `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/cart.tsx` exporting CartScreen. 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 { View, FlatList } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Button, Card, EmptyState, Glass, Icon, Image, Input, Progress, Separator, Stepper, Text } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { catalogImg, LumioMark, Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
import { StickyBottomBar } from "../shared";
export interface CartItem {
id: string;
name: string;
brand?: string;
variantLabel?: string;
price: number;
quantity: number;
imageUrl?: string;
}
export interface CartScreenProps {
items?: CartItem[];
currency?: string;
shippingCost?: number;
/** Free shipping above this subtotal; set shippingCost for the fallback. */
freeShippingThreshold?: number;
onUpdateQty?: (item: CartItem, quantity: number) => void;
onRemove?: (item: CartItem) => void;
onApplyPromo?: (code: string) => void;
onCheckout?: () => void;
onBrowse?: () => void;
}
const img = (p: Parameters<typeof catalogImg>[0]) => catalogImg(p, 200, 250);
const DEFAULT_ITEMS: CartItem[] = [
{ id: "1", name: "Structured Wool Overcoat", brand: "Nord Standard", variantLabel: "Camel · M", price: 248, quantity: 1, imageUrl: img("overcoat") },
{ id: "3", name: "Leather Lace-Up Boots", brand: "Marchetti & Sons", variantLabel: "Black · 42", price: 295, quantity: 1, imageUrl: img("boots") },
{ id: "7", name: "Heritage Graphic Tee", brand: "Atelier M", variantLabel: "Indigo", price: 85, quantity: 2, imageUrl: img("graphicTee") },
];
/** Prices always render with two decimals ($248.00). */
const fmt = (n: number, currency = "$") =>
`${currency}${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
function SummaryRow({
label,
value,
bold,
accent,
}: {
label: string;
value: string;
bold?: boolean;
/** Success-green value — used for promo discounts. */
accent?: boolean;
}) {
return (
<View className="flex-row items-center justify-between py-1">
<Text variant={bold ? "large" : "muted"}>{label}</Text>
<Text
variant={bold ? "large" : "small"}
className={accent ? "font-semibold tabular-nums text-success" : "tabular-nums"}
>
{value}
</Text>
</View>
);
}
/** Bag — line items with thumbnails, qty steppers and remove affordances,
* promo field, order summary, and a sticky checkout bar. */
export function CartScreen({
items = DEFAULT_ITEMS,
currency = "$",
shippingCost = 6.99,
freeShippingThreshold = 150,
onUpdateQty,
onRemove,
onApplyPromo,
onCheckout,
onBrowse,
}: CartScreenProps) {
const haptics = useHaptic();
const [promo, setPromo] = useState("");
/** Code currently applied — swaps the promo field for a success row. */
const [appliedPromo, setAppliedPromo] = useState<string | null>(null);
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const shipping = items.length === 0 || subtotal >= freeShippingThreshold ? 0 : shippingCost;
// Template discount — a real host wires this to onApplyPromo's response.
const discount = appliedPromo ? Math.round(subtotal * 0.1 * 100) / 100 : 0;
const total = subtotal - discount + shipping;
const remaining = Math.max(0, freeShippingThreshold - subtotal);
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="px-6 pb-2 pt-4">
<View className="flex-row items-center gap-2 pb-1.5">
<LumioMark size={18} />
<Text variant="muted" className="text-xs font-semibold uppercase tracking-widest">
Lumio Market
</Text>
</View>
<Text variant="h2">
Bag{itemCount > 0 ? ` (${itemCount})` : ""}
</Text>
</View>
{items.length === 0 ? (
<View className="flex-1">
<EmptyState
title="Your bag is empty"
description="Items you add will appear here."
action={
onBrowse ? (
<Button variant="secondary" onPress={onBrowse}>
Continue shopping
</Button>
) : undefined
}
className="flex-1"
/>
</View>
) : (
<>
<FlatList
data={items}
keyExtractor={(item) => item.id}
className="flex-1"
contentContainerClassName="px-6 pb-56"
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View
className="mb-4 gap-2 rounded-2xl bg-[#EDF8C4] p-4 dark:bg-[#1E2410]"
accessibilityRole="text"
accessibilityLabel={
remaining > 0
? `${fmt(remaining, currency)} away from free shipping`
: "Free shipping unlocked"
}
>
{remaining > 0 ? (
<>
<Text variant="small" className="font-medium">
You're {fmt(remaining, currency)} away from{" "}
<Text variant="small" className="font-semibold text-[#65A30D]">
free shipping
</Text>
</Text>
<Progress value={(subtotal / freeShippingThreshold) * 100} className="h-1.5" />
</>
) : (
<View className="flex-row items-center gap-1.5">
<View className="h-5 w-5 items-center justify-center rounded-full bg-success">
<Icon name="check" size="sm" color="successForeground" />
</View>
<Text variant="small" className="font-medium text-success">
Free shipping unlocked
</Text>
</View>
)}
</View>
}
ItemSeparatorComponent={() => <View className="h-3" />}
renderItem={({ item, index }) => (
<Mount delay={Math.min(index * ROW_STAGGER, ROW_STAGGER_CAP)}>
<View className="flex-row gap-3 overflow-hidden rounded-3xl bg-white p-3 dark:bg-[#1A1A20]">
<Image
source={item.imageUrl ? { uri: item.imageUrl } : undefined}
rounded="xl"
aspectRatio={4 / 5}
className="w-24"
accessibilityLabel={`Photo of ${item.name}`}
/>
<View className="flex-1">
<View className="flex-row items-start justify-between">
<View className="flex-1 gap-0.5 pr-2">
<Text variant="small" numberOfLines={1}>
{item.name}
</Text>
{item.brand || item.variantLabel ? (
<Text variant="muted" className="text-xs" numberOfLines={1}>
{[item.brand, item.variantLabel].filter(Boolean).join(" · ")}
</Text>
) : null}
</View>
<Press
onPress={() => {
haptics.warning();
onRemove?.(item);
}}
accessibilityRole="button"
accessibilityLabel={`Remove ${item.name} from bag`}
hitSlop={12}
className="h-11 w-11 items-center justify-center"
>
<Icon name="trash" size="sm" color="mutedForeground" />
</Press>
</View>
<View className="flex-row items-center justify-between pt-2">
<Stepper
value={item.quantity}
min={0}
onValueChange={(qty) =>
qty === 0 ? onRemove?.(item) : onUpdateQty?.(item, qty)
}
accessibilityLabel={`Quantity for ${item.name}`}
/>
<View className="items-end">
<Text
variant="small"
className="font-semibold tabular-nums"
accessibilityLabel={`${item.name} line total ${fmt(item.price * item.quantity, currency)}`}
>
{fmt(item.price * item.quantity, currency)}
</Text>
{item.quantity > 1 ? (
<Text variant="muted" className="text-xs tabular-nums">
{fmt(item.price, currency)} each
</Text>
) : null}
</View>
</View>
</View>
</View>
</Mount>
)}
/>
<StickyBottomBar className="gap-4">
{appliedPromo ? (
<View className="flex-row items-center justify-between rounded-2xl bg-success/10 py-1 pl-4">
<View className="flex-1 flex-row items-center gap-2">
<Icon name="check-circle" size="sm" color="success" />
<Text variant="small">
Promo <Text variant="small" className="font-semibold">{appliedPromo}</Text> applied — 10% off
</Text>
</View>
<Press
onPress={() => {
haptics.light();
setAppliedPromo(null);
setPromo("");
}}
accessibilityRole="button"
accessibilityLabel={`Remove promo code ${appliedPromo}`}
hitSlop={12}
className="h-11 w-11 items-center justify-center"
>
<Icon name="x" size="sm" color="mutedForeground" />
</Press>
</View>
) : (
<View className="flex-row gap-2">
<View className="flex-1">
<Input
glass
placeholder="Promo code"
value={promo}
onChangeText={setPromo}
autoCapitalize="characters"
accessibilityLabel="Promo code"
/>
</View>
<Button
variant="secondary"
disabled={promo.trim().length === 0}
onPress={() => {
const code = promo.trim();
haptics.success();
setAppliedPromo(code);
onApplyPromo?.(code);
}}
>
Apply
</Button>
</View>
)}
<Card className="gap-0 overflow-hidden rounded-3xl border-0 bg-white p-5 dark:bg-[#1A1A20]">
<SummaryRow label="Subtotal" value={fmt(subtotal, currency)} />
{discount > 0 ? (
<SummaryRow
label={`Promo (${appliedPromo})`}
value={`-${fmt(discount, currency)}`}
accent
/>
) : null}
<SummaryRow
label="Shipping"
value={shipping === 0 ? "Free" : fmt(shipping, currency)}
/>
<Separator className="my-2" />
<SummaryRow label="Total" value={fmt(total, currency)} bold />
{/* Delivery promise — the last reassurance before the CTA. */}
<View className="mt-2.5 flex-row items-center gap-1.5">
<Icon name="truck" size="sm" color="mutedForeground" />
<Text variant="muted" className="text-xs">
Arrives{" "}
{new Date(Date.now() + 5 * 86400000).toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" })}{" "}
— free returns for 30 days
</Text>
</View>
</Card>
<Button
haptic="heavy"
onPress={() => onCheckout?.()}
accessibilityLabel={`Checkout, total ${fmt(total, currency)}`}
tone="inverse"
>
Checkout · {fmt(total, currency)}
</Button>
</StickyBottomBar>
</>
)}
</SafeAreaView>
);
}
```
Screen: https://lumioui.dev/screens/ecommerce-cart