Cabin map seat picker
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, Icon, Text } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { BLOCK_STAGGER, Mount, StickyBottomBar, World } from "../shared";
export interface SeatSelectScreenProps {
flight?: string;
/** Row count rendered in the cabin map. */
rows?: number;
/** Seats already taken — "12A,14C" style tokens. */
taken?: string[];
onSelect?: (seat: string) => void;
onConfirm?: (seat: string) => void;
onBack?: () => void;
}
const COLS = ["A", "B", "C", "D", "E", "F"];
const DEFAULT_TAKEN = ["8A", "8C", "9B", "9E", "10D", "11A", "11F", "12C", "13D", "14B", "15E"];
/** Seat select — cabin map with rows + legend + confirm. */
export function SeatSelectScreen({
flight = "TP 268",
rows = 12,
taken = DEFAULT_TAKEN,
onSelect,
onConfirm,
onBack,
}: SeatSelectScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [seat, setSeat] = React.useState("14A");
const seatState = (s: string): "taken" | "picked" | "free" => {
if (taken.includes(s)) return "taken";
if (s === seat) return "picked";
return "free";
};
/** Quick pick — "window/aisle/legroom" selects the first free seat that
matches, the shortcut airline apps give solo travellers. */
const quickPick = (kind: "window" | "aisle" | "legroom") => () => {
for (let r = 0; r < rows; r++) {
const rowNum = r + 8;
for (const c of COLS) {
const s = `${rowNum}${c}`;
if (taken.includes(s)) continue;
const match =
kind === "window"
? c === "A" || c === "F"
: kind === "aisle"
? c === "C" || c === "D"
: rowNum === 10 || rowNum === 11;
if (match) {
haptics.selection();
setSeat(s);
onSelect?.(s);
return;
}
}
}
};
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="flex-row items-center gap-2 px-4 pb-1 pt-2">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Go back"
className="-ml-2 h-10 w-10"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Button>
<View>
<Text variant="h2" accessibilityRole="header" className="tracking-tight">
Select seat
</Text>
<Text variant="small" className="text-muted-foreground">
{flight} · Economy
</Text>
{/* Seat-map readout — pickers echo the live choice. */}
<Text variant="small" className="font-medium text-primary">
12A selected · window
</Text>
</View>
</View>
{/* Legend. */}
<View className="flex-row items-center justify-center gap-5 px-4 py-2">
{[
{ cls: "bg-[#65A30D]", label: "Selected" },
{ cls: "bg-muted", label: "Taken" },
{ cls: "border border-border bg-card", label: "Free" },
].map((l) => (
<View key={l.label} className="flex-row items-center gap-1.5">
<View className={`h-3.5 w-3.5 rounded ${l.cls}`} />
<Text className="text-xs text-muted-foreground">{l.label}</Text>
</View>
))}
</View>
{/* Quick pick — skips the map hunt for people who just want a window. */}
<View className="flex-row items-center justify-center gap-2 pb-2">
<Text className="text-xs text-muted-foreground">Quick pick</Text>
{(
[
["Window", "window"],
["Aisle", "aisle"],
["Legroom", "legroom"],
] as const
).map(([label, kind]) => (
<Chip
key={kind}
variant="filter"
onPress={quickPick(kind)}
accessibilityLabel={`Pick a ${label.toLowerCase()} seat`}
>
{label}
</Chip>
))}
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
>
{/* Cabin — nose dome on top, then column letters + row grids with a
center aisle. */}
<Mount delay={BLOCK_STAGGER} className="mx-6 mt-2 overflow-hidden rounded-3xl bg-white p-4 dark:bg-[#1A1A20]">
<View className="mx-6 mb-2 h-8 items-center justify-end rounded-t-full border-b border-border/60">
<Text className="pb-1 text-[9px] font-medium uppercase tracking-widest text-muted-foreground">
Front
</Text>
</View>
<View className="flex-row justify-between px-1 pb-2">
{COLS.map((c, i) => (
<React.Fragment key={c}>
{i === 3 && <View className="w-6" />}
<Text className="w-8 text-center text-xs font-medium text-muted-foreground">
{c}
</Text>
</React.Fragment>
))}
</View>
{Array.from({ length: rows }).map((_, r) => {
const rowNum = r + 8;
return (
<View
key={rowNum}
className="flex-row items-center justify-between py-1"
>
{COLS.map((c, i) => {
const s = `${rowNum}${c}`;
const state = seatState(s);
const exitRow = rowNum === 10 || rowNum === 11;
return (
<React.Fragment key={s}>
{i === 3 && (
<View className="w-6 items-center">
<Text className="text-[10px] text-muted-foreground">
{rowNum}
</Text>
</View>
)}
<Button
variant="ghost"
size="icon"
haptic="light"
disabled={state === "taken"}
onPress={() => {
haptics.selection();
setSeat(s);
onSelect?.(s);
}}
accessibilityLabel={`Seat ${s}${state === "taken" ? ", taken" : ""}${exitRow ? ", exit row, +$24" : ""}`}
accessibilityState={{ selected: state === "picked" }}
className={`h-9 w-8 rounded-lg ${
state === "picked"
? "bg-[#65A30D]"
: state === "taken"
? "bg-muted opacity-40"
: exitRow
? "border border-warning/50 bg-warning/10"
: "border border-border bg-card"
}`}
>
{state === "picked" && (
<Icon name="check" size="sm" color="#FFFFFF" />
)}
</Button>
</React.Fragment>
);
})}
</View>
);
})}
</Mount>
{/* Exit rows — the real seat-map detail; free exit seats glow amber. */}
<View className="mx-6 mt-2 flex-row items-center gap-2 px-1">
<View className="h-3.5 w-3.5 rounded border border-warning/50 bg-warning/10" />
<Text className="text-xs text-muted-foreground">
Rows 10–11 are exit rows · extra legroom +$24
</Text>
</View>
</ScrollView>
<StickyBottomBar>
<Button
haptic="light"
onPress={() => {
haptics.medium();
onConfirm?.(seat);
}}
accessibilityLabel={`Confirm seat ${seat}, ${seat.startsWith("10") || seat.startsWith("11") ? "24 dollars extra" : "free"}`}
tone="inverse" className="bg-[#1B1B26] h-12 w-full"
>
<Text className="text-[15px] font-semibold text-white">
Confirm {seat} · <Text className="font-bold text-white">{seat.startsWith("10") || seat.startsWith("11") ? "+$24" : "Free"}</Text>
</Text>
</Button>
</StickyBottomBar>
</SafeAreaView>
</World>
);
}
SeatSelectScreen.displayName = "SeatSelectScreen";
Recreate the "Seat Select Screen" screen in React Native using LumioUI.
Goal: Cabin map seat picker 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 travel-seat-select
```
Or pull just the components: `npx lumioui add button chip icon 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
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×2
### `text` — Themed type scale from caption to display.
- `<Text>` ×11
## 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/travel/seat-select.tsx` exporting SeatSelectScreen. 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, Icon, Text } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { BLOCK_STAGGER, Mount, StickyBottomBar, World } from "../shared";
export interface SeatSelectScreenProps {
flight?: string;
/** Row count rendered in the cabin map. */
rows?: number;
/** Seats already taken — "12A,14C" style tokens. */
taken?: string[];
onSelect?: (seat: string) => void;
onConfirm?: (seat: string) => void;
onBack?: () => void;
}
const COLS = ["A", "B", "C", "D", "E", "F"];
const DEFAULT_TAKEN = ["8A", "8C", "9B", "9E", "10D", "11A", "11F", "12C", "13D", "14B", "15E"];
/** Seat select — cabin map with rows + legend + confirm. */
export function SeatSelectScreen({
flight = "TP 268",
rows = 12,
taken = DEFAULT_TAKEN,
onSelect,
onConfirm,
onBack,
}: SeatSelectScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [seat, setSeat] = React.useState("14A");
const seatState = (s: string): "taken" | "picked" | "free" => {
if (taken.includes(s)) return "taken";
if (s === seat) return "picked";
return "free";
};
/** Quick pick — "window/aisle/legroom" selects the first free seat that
matches, the shortcut airline apps give solo travellers. */
const quickPick = (kind: "window" | "aisle" | "legroom") => () => {
for (let r = 0; r < rows; r++) {
const rowNum = r + 8;
for (const c of COLS) {
const s = `${rowNum}${c}`;
if (taken.includes(s)) continue;
const match =
kind === "window"
? c === "A" || c === "F"
: kind === "aisle"
? c === "C" || c === "D"
: rowNum === 10 || rowNum === 11;
if (match) {
haptics.selection();
setSeat(s);
onSelect?.(s);
return;
}
}
}
};
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="flex-row items-center gap-2 px-4 pb-1 pt-2">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Go back"
className="-ml-2 h-10 w-10"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Button>
<View>
<Text variant="h2" accessibilityRole="header" className="tracking-tight">
Select seat
</Text>
<Text variant="small" className="text-muted-foreground">
{flight} · Economy
</Text>
{/* Seat-map readout — pickers echo the live choice. */}
<Text variant="small" className="font-medium text-primary">
12A selected · window
</Text>
</View>
</View>
{/* Legend. */}
<View className="flex-row items-center justify-center gap-5 px-4 py-2">
{[
{ cls: "bg-[#65A30D]", label: "Selected" },
{ cls: "bg-muted", label: "Taken" },
{ cls: "border border-border bg-card", label: "Free" },
].map((l) => (
<View key={l.label} className="flex-row items-center gap-1.5">
<View className={`h-3.5 w-3.5 rounded ${l.cls}`} />
<Text className="text-xs text-muted-foreground">{l.label}</Text>
</View>
))}
</View>
{/* Quick pick — skips the map hunt for people who just want a window. */}
<View className="flex-row items-center justify-center gap-2 pb-2">
<Text className="text-xs text-muted-foreground">Quick pick</Text>
{(
[
["Window", "window"],
["Aisle", "aisle"],
["Legroom", "legroom"],
] as const
).map(([label, kind]) => (
<Chip
key={kind}
variant="filter"
onPress={quickPick(kind)}
accessibilityLabel={`Pick a ${label.toLowerCase()} seat`}
>
{label}
</Chip>
))}
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
>
{/* Cabin — nose dome on top, then column letters + row grids with a
center aisle. */}
<Mount delay={BLOCK_STAGGER} className="mx-6 mt-2 overflow-hidden rounded-3xl bg-white p-4 dark:bg-[#1A1A20]">
<View className="mx-6 mb-2 h-8 items-center justify-end rounded-t-full border-b border-border/60">
<Text className="pb-1 text-[9px] font-medium uppercase tracking-widest text-muted-foreground">
Front
</Text>
</View>
<View className="flex-row justify-between px-1 pb-2">
{COLS.map((c, i) => (
<React.Fragment key={c}>
{i === 3 && <View className="w-6" />}
<Text className="w-8 text-center text-xs font-medium text-muted-foreground">
{c}
</Text>
</React.Fragment>
))}
</View>
{Array.from({ length: rows }).map((_, r) => {
const rowNum = r + 8;
return (
<View
key={rowNum}
className="flex-row items-center justify-between py-1"
>
{COLS.map((c, i) => {
const s = `${rowNum}${c}`;
const state = seatState(s);
const exitRow = rowNum === 10 || rowNum === 11;
return (
<React.Fragment key={s}>
{i === 3 && (
<View className="w-6 items-center">
<Text className="text-[10px] text-muted-foreground">
{rowNum}
</Text>
</View>
)}
<Button
variant="ghost"
size="icon"
haptic="light"
disabled={state === "taken"}
onPress={() => {
haptics.selection();
setSeat(s);
onSelect?.(s);
}}
accessibilityLabel={`Seat ${s}${state === "taken" ? ", taken" : ""}${exitRow ? ", exit row, +$24" : ""}`}
accessibilityState={{ selected: state === "picked" }}
className={`h-9 w-8 rounded-lg ${
state === "picked"
? "bg-[#65A30D]"
: state === "taken"
? "bg-muted opacity-40"
: exitRow
? "border border-warning/50 bg-warning/10"
: "border border-border bg-card"
}`}
>
{state === "picked" && (
<Icon name="check" size="sm" color="#FFFFFF" />
)}
</Button>
</React.Fragment>
);
})}
</View>
);
})}
</Mount>
{/* Exit rows — the real seat-map detail; free exit seats glow amber. */}
<View className="mx-6 mt-2 flex-row items-center gap-2 px-1">
<View className="h-3.5 w-3.5 rounded border border-warning/50 bg-warning/10" />
<Text className="text-xs text-muted-foreground">
Rows 10–11 are exit rows · extra legroom +$24
</Text>
</View>
</ScrollView>
<StickyBottomBar>
<Button
haptic="light"
onPress={() => {
haptics.medium();
onConfirm?.(seat);
}}
accessibilityLabel={`Confirm seat ${seat}, ${seat.startsWith("10") || seat.startsWith("11") ? "24 dollars extra" : "free"}`}
tone="inverse" className="bg-[#1B1B26] h-12 w-full"
>
<Text className="text-[15px] font-semibold text-white">
Confirm {seat} · <Text className="font-bold text-white">{seat.startsWith("10") || seat.startsWith("11") ? "+$24" : "Free"}</Text>
</Text>
</Button>
</StickyBottomBar>
</SafeAreaView>
</World>
);
}
SeatSelectScreen.displayName = "SeatSelectScreen";
```
Screen: https://lumioui.dev/screens/travel-seat-select