Photo editing canvas with tool row
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Icon, type IconName, Image, Slider, Text } from "@lumioui/ui";
import { GlowSpot, Mount, World } from "../shared";
import { useHaptic } from "@lumioui/core";
export interface EditTool {
id: string;
icon: IconName;
label: string;
}
export interface PhotoEditorScreenProps {
photoUrl?: string;
tools?: EditTool[];
/** Currently applied filter, shown on the Adjust row. */
filter?: string;
/** 0–100 adjust intensity. */
intensity?: number;
onTool?: (id: string) => void;
onDone?: () => void;
onBack?: () => void;
}
const DEFAULT_TOOLS: EditTool[] = [
{ id: "crop", icon: "grid", label: "Crop" },
{ id: "filter", icon: "image", label: "Filters" },
{ id: "adjust", icon: "sun", label: "Adjust" },
{ id: "markup", icon: "edit", label: "Markup" },
];
const FILTERS = [
{ id: "chrome", name: "Chrome", tint: "bg-blue-400/50" },
{ id: "vivid", name: "Vivid", tint: "bg-orange-400/50" },
{ id: "matte", name: "Matte", tint: "bg-stone-400/50" },
{ id: "noir", name: "Noir", tint: "bg-neutral-700/70" },
];
/** Photo editor — full photo, tool row, adjust slider, Done. */
export function PhotoEditorScreen({
photoUrl = "https://images.unsplash.com/photo-1445205170230-053b83016050?w=800&h=1100&fit=crop&q=75&auto=format",
tools = DEFAULT_TOOLS,
filter = "Chrome",
intensity = 42,
onTool,
onDone,
onBack,
}: PhotoEditorScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [activeTool, setActiveTool] = React.useState("adjust");
const [level, setLevel] = React.useState(intensity);
const [activeFilter, setActiveFilter] = React.useState(
filter.toLowerCase()
);
React.useEffect(() => setLevel(intensity), [intensity]);
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
<GlowSpot color="#BEF04C" size={220} className="bottom-[10%] right-[-80px] opacity-25" />
{/* Chrome is dark here — the photo is the canvas. */}
<View className="flex-row items-center justify-between px-4 py-1">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Cancel editing"
className="h-10 w-10"
>
<Icon name="x" size="md" color="#F5F3F8" />
</Button>
<View className="items-center">
<Text className="text-sm font-medium text-white/80">
{FILTERS.find((f) => f.id === activeFilter)?.name ?? filter}
</Text>
{/* Non-destructive promise — editors reassure on the way out. */}
<Text className="text-[10px] text-white/40">
Saves a copy — original kept
</Text>
</View>
<Button
haptic="light"
onPress={() => {
haptics.light();
onDone?.();
}}
accessibilityLabel="Done editing"
className="bg-[#BEF04C] h-9 px-4"
textClassName="text-[#1B1B26] font-bold"
>
Done
</Button>
</View>
{/* Photo canvas. */}
<View className="flex-1 items-center justify-center px-4">
<Image
source={{ uri: photoUrl }}
className="aspect-[3/4] w-full rounded-3xl"
accessibilityLabel="Photo being edited"
/>
</View>
{/* Filter rail — photo thumbs with the live filter applied on top. */}
{activeTool === "filter" && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 12, paddingHorizontal: 20, paddingVertical: 8 }}
>
{FILTERS.map((f) => {
const selected = f.id === activeFilter;
return (
<View key={f.id} className="items-center gap-1.5">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => {
haptics.selection();
setActiveFilter(f.id);
onTool?.(f.id);
}}
accessibilityLabel={`${f.name} filter`}
accessibilityState={{ selected }}
className={`h-14 w-14 overflow-hidden rounded-2xl p-0 ${
selected ? "border-2 border-white" : ""
}`}
>
<Image
source={{ uri: photoUrl }}
className="h-full w-full"
accessibilityElementsHidden
importantForAccessibility="no"
/>
<View className={`absolute inset-0 ${f.tint}`} />
</Button>
<Text
className={`text-[11px] ${
selected ? "font-medium text-white" : "text-white/60"
}`}
>
{f.name}
</Text>
</View>
);
})}
</ScrollView>
)}
{/* Adjust slider — visible when Adjust is the active tool. */}
{activeTool === "adjust" && (
<Mount className="px-6 pb-2">
<View className="flex-row items-center gap-3">
<Icon name="minus" size="sm" color="#F5F3F8" />
<View className="flex-1">
<Slider
value={level}
onValueChange={setLevel}
min={0}
max={100}
accessibilityLabel="Adjust intensity"
/>
</View>
<View className="w-10 rounded-full bg-white/15 px-2 py-0.5">
<Text className="text-center text-xs font-medium text-white">
{Math.round(level)}
</Text>
</View>
</View>
</Mount>
)}
{/* Tool row. */}
<View
className="flex-row justify-around px-2"
style={{ paddingBottom: insets.bottom + 8 }}
>
{tools.map((t) => {
const active = t.id === activeTool;
return (
<View key={t.id} className="items-center gap-1">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => {
haptics.selection();
setActiveTool(t.id);
onTool?.(t.id);
}}
accessibilityLabel={t.label}
accessibilityState={{ selected: active }}
className="h-11 w-11"
>
<Icon
name={t.icon}
size="md"
color={active ? "#BEF04C" : "#F5F3F8"}
/>
</Button>
<Text
className={`text-xs ${active ? "font-medium text-white" : "text-white/60"}`}
>
{t.label}
</Text>
</View>
);
})}
</View>
</SafeAreaView>
</World>
);
}
PhotoEditorScreen.displayName = "PhotoEditorScreen";
Recreate the "Photo Editor Screen" screen in React Native using LumioUI.
Goal: Photo editing canvas with tool row 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 media-photo-editor
```
Or pull just the components: `npx lumioui add button icon image slider 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>` ×4
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×3
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×2
### `slider` — Draggable track with haptic ticks and precise thumb.
- `<Slider>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×5
## 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/media/photo-editor.tsx` exporting PhotoEditorScreen. 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, Icon, type IconName, Image, Slider, Text } from "@lumioui/ui";
import { GlowSpot, Mount, World } from "../shared";
import { useHaptic } from "@lumioui/core";
export interface EditTool {
id: string;
icon: IconName;
label: string;
}
export interface PhotoEditorScreenProps {
photoUrl?: string;
tools?: EditTool[];
/** Currently applied filter, shown on the Adjust row. */
filter?: string;
/** 0–100 adjust intensity. */
intensity?: number;
onTool?: (id: string) => void;
onDone?: () => void;
onBack?: () => void;
}
const DEFAULT_TOOLS: EditTool[] = [
{ id: "crop", icon: "grid", label: "Crop" },
{ id: "filter", icon: "image", label: "Filters" },
{ id: "adjust", icon: "sun", label: "Adjust" },
{ id: "markup", icon: "edit", label: "Markup" },
];
const FILTERS = [
{ id: "chrome", name: "Chrome", tint: "bg-blue-400/50" },
{ id: "vivid", name: "Vivid", tint: "bg-orange-400/50" },
{ id: "matte", name: "Matte", tint: "bg-stone-400/50" },
{ id: "noir", name: "Noir", tint: "bg-neutral-700/70" },
];
/** Photo editor — full photo, tool row, adjust slider, Done. */
export function PhotoEditorScreen({
photoUrl = "https://images.unsplash.com/photo-1445205170230-053b83016050?w=800&h=1100&fit=crop&q=75&auto=format",
tools = DEFAULT_TOOLS,
filter = "Chrome",
intensity = 42,
onTool,
onDone,
onBack,
}: PhotoEditorScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [activeTool, setActiveTool] = React.useState("adjust");
const [level, setLevel] = React.useState(intensity);
const [activeFilter, setActiveFilter] = React.useState(
filter.toLowerCase()
);
React.useEffect(() => setLevel(intensity), [intensity]);
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
<GlowSpot color="#BEF04C" size={220} className="bottom-[10%] right-[-80px] opacity-25" />
{/* Chrome is dark here — the photo is the canvas. */}
<View className="flex-row items-center justify-between px-4 py-1">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Cancel editing"
className="h-10 w-10"
>
<Icon name="x" size="md" color="#F5F3F8" />
</Button>
<View className="items-center">
<Text className="text-sm font-medium text-white/80">
{FILTERS.find((f) => f.id === activeFilter)?.name ?? filter}
</Text>
{/* Non-destructive promise — editors reassure on the way out. */}
<Text className="text-[10px] text-white/40">
Saves a copy — original kept
</Text>
</View>
<Button
haptic="light"
onPress={() => {
haptics.light();
onDone?.();
}}
accessibilityLabel="Done editing"
className="bg-[#BEF04C] h-9 px-4"
textClassName="text-[#1B1B26] font-bold"
>
Done
</Button>
</View>
{/* Photo canvas. */}
<View className="flex-1 items-center justify-center px-4">
<Image
source={{ uri: photoUrl }}
className="aspect-[3/4] w-full rounded-3xl"
accessibilityLabel="Photo being edited"
/>
</View>
{/* Filter rail — photo thumbs with the live filter applied on top. */}
{activeTool === "filter" && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 12, paddingHorizontal: 20, paddingVertical: 8 }}
>
{FILTERS.map((f) => {
const selected = f.id === activeFilter;
return (
<View key={f.id} className="items-center gap-1.5">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => {
haptics.selection();
setActiveFilter(f.id);
onTool?.(f.id);
}}
accessibilityLabel={`${f.name} filter`}
accessibilityState={{ selected }}
className={`h-14 w-14 overflow-hidden rounded-2xl p-0 ${
selected ? "border-2 border-white" : ""
}`}
>
<Image
source={{ uri: photoUrl }}
className="h-full w-full"
accessibilityElementsHidden
importantForAccessibility="no"
/>
<View className={`absolute inset-0 ${f.tint}`} />
</Button>
<Text
className={`text-[11px] ${
selected ? "font-medium text-white" : "text-white/60"
}`}
>
{f.name}
</Text>
</View>
);
})}
</ScrollView>
)}
{/* Adjust slider — visible when Adjust is the active tool. */}
{activeTool === "adjust" && (
<Mount className="px-6 pb-2">
<View className="flex-row items-center gap-3">
<Icon name="minus" size="sm" color="#F5F3F8" />
<View className="flex-1">
<Slider
value={level}
onValueChange={setLevel}
min={0}
max={100}
accessibilityLabel="Adjust intensity"
/>
</View>
<View className="w-10 rounded-full bg-white/15 px-2 py-0.5">
<Text className="text-center text-xs font-medium text-white">
{Math.round(level)}
</Text>
</View>
</View>
</Mount>
)}
{/* Tool row. */}
<View
className="flex-row justify-around px-2"
style={{ paddingBottom: insets.bottom + 8 }}
>
{tools.map((t) => {
const active = t.id === activeTool;
return (
<View key={t.id} className="items-center gap-1">
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => {
haptics.selection();
setActiveTool(t.id);
onTool?.(t.id);
}}
accessibilityLabel={t.label}
accessibilityState={{ selected: active }}
className="h-11 w-11"
>
<Icon
name={t.icon}
size="md"
color={active ? "#BEF04C" : "#F5F3F8"}
/>
</Button>
<Text
className={`text-xs ${active ? "font-medium text-white" : "text-white/60"}`}
>
{t.label}
</Text>
</View>
);
})}
</View>
</SafeAreaView>
</World>
);
}
PhotoEditorScreen.displayName = "PhotoEditorScreen";
```
Screen: https://lumioui.dev/screens/media-photo-editor