Marcel idiom: one continuous animated flow — a glowing halo orb spring-travels between welcome, press, quote and memory-card states
import * as React from "react";
import { View, useWindowDimensions } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
import { Button, Icon, Text } from "@lumioui/ui";
import { cn, spring, useHaptic, useReducedMotion } from "@lumioui/core";
import { Drift, HeroGradient, Mount, Press, SERIF, StickyBottomBar } from "../shared";
/** The halo — a warm glowing orb, the persistent hero of the flow. Pure SVG
* radial gradient so the edge feathers to nothing; size/position come from
* the parent so each step can move it. */
function Halo({ size }: { size: number }) {
const id = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const r = size / 2;
return (
<Svg width={size} height={size} accessible={false}>
<Defs>
<RadialGradient id={id} cx="50%" cy="50%" r="50%">
<Stop offset={0} stopColor="#FFFDF4" stopOpacity={1} />
<Stop offset={0.35} stopColor="#FDEFC9" stopOpacity={0.95} />
<Stop offset={0.7} stopColor="#FBD9A8" stopOpacity={0.4} />
<Stop offset={1} stopColor="#FBD9A8" stopOpacity={0} />
</RadialGradient>
</Defs>
<Circle cx={r} cy={r} r={r} fill={`url(#${id})`} />
</Svg>
);
}
/** One remembered thing — the white card that rises in the last state. */
function MemoryCard() {
return (
<View className="w-full max-w-[300px] rounded-3xl bg-white p-5 shadow-xl shadow-black/10">
<View className="flex-row items-center gap-2">
<View className="h-8 w-8 items-center justify-center rounded-full bg-[#EEF2FF]">
<Icon name="package" size="md" color="#4F46E5" />
</View>
<Text className="text-[15px] font-semibold text-[#0B0B0F]">Gift ideas</Text>
</View>
<View className="my-3 border-t border-dashed border-[#0B0B0F]/10" />
<View className="mb-2 self-start rounded-full bg-[#0B0B0F]/[0.05] px-2 py-0.5">
<Text className="text-[11px] font-medium text-[#0B0B0F]/60">Dad</Text>
</View>
{["Garmin watch", "Weekend in Lisbon", "Italian coffee machine"].map((item) => (
<View key={item} className="flex-row items-center gap-2 py-1">
<Text className="text-[13px] text-[#0B0B0F]/40">•</Text>
<Text className="text-[14px] text-[#0B0B0F]">{item}</Text>
</View>
))}
<View className="mt-3 flex-row items-center gap-1.5">
<Icon name="refresh" size="sm" color="#0B0B0F" />
<Text className="text-[12px] text-[#0B0B0F]/40">3 memories found</Text>
</View>
</View>
);
}
/** Orb choreography — each step parks the halo somewhere new: top-center
* small for welcome, large centered for press, mid for quote, small top
* for the card reveal. `top` is a fraction of the window height. */
const ORB = [
{ top: 0.16, scale: 0.5 },
{ top: 0.2, scale: 1 },
{ top: 0.14, scale: 0.6 },
{ top: 0.1, scale: 0.45 },
] as const;
/** Halo memory — the Marcel idiom: ONE continuous animated flow, not a set
* of screens. A pastel dawn world with a glowing halo orb that persists
* through four states — welcome, press-the-halo, the remembered quote,
* the memory card — while copy and cards crossfade around it. Tapping the
* halo (or the pill) advances; it spring-travels to each state's spot. */
export function HaloMemoryScreen({ onDone, onSkip }: { onDone?: () => void; onSkip?: () => void }) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const [step, setStep] = React.useState(0);
const { height } = useWindowDimensions();
const top = useSharedValue<number>(ORB[0].top);
const scale = useSharedValue<number>(ORB[0].scale);
React.useEffect(() => {
const t = ORB[step] ?? ORB[0];
top.value = reduce ? t.top : withSpring(t.top, { damping: 18, stiffness: 90 });
scale.value = reduce ? t.scale : withSpring(t.scale, spring.layout);
}, [step, reduce, top, scale]);
const orbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: -110 }, { translateY: top.value * height }, { scale: scale.value }],
}));
const advance = () => {
haptics.light();
setStep((s) => (s < 3 ? s + 1 : 0));
};
const cta = step === 0 ? "Begin" : step === 1 ? "Tap the halo" : step === 3 ? "Continue" : null;
return (
<Press accessibilityRole="button" accessibilityLabel="Continue" onPress={advance} className="flex-1 bg-[#BFD3EE]">
{/* Dawn world — cool blue over warm peach, the Marcel gradient. */}
<HeroGradient from="#AFC4E8" to="#E8D9CE" />
<View className="absolute inset-x-0 bottom-0 h-[420px]">
<HeroGradient from="rgba(253,231,186,0)" to="#F5C99A" />
</View>
<Drift emoji="☁️" cls="left-6 top-24" size="text-[38px]" rotate={0} index={0} />
<Drift emoji="🕊️" cls="right-8 top-[380px]" size="text-[30px]" rotate={8} index={1} />
{/* The halo — always mounted, spring-travels between states. */}
<Animated.View
pointerEvents="none"
className="absolute left-1/2 items-center justify-center"
style={[orbStyle, { width: 220, height: 220 }]}
>
<Halo size={220} />
</Animated.View>
<SafeAreaView className="flex-1" edges={["top", "bottom"]}>
<View className="flex-row items-center justify-between px-4 pt-1">
<Press
onPress={onSkip}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Back"
className="flex-row items-center"
>
<Icon name="chevron-left" size="md" color="#3D4C6B" />
<Text className="text-[14px] font-medium text-[#3D4C6B]">Happily</Text>
</Press>
{step > 0 ? (
<Press
onPress={() => {
haptics.selection();
onSkip?.();
}}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Skip"
>
<Text className="text-[14px] font-medium text-[#3D4C6B]/70">Skip</Text>
</Press>
) : (
<View />
)}
</View>
{/* State copy — each step's text lives in the same column so the
world reads as continuous while content swaps. */}
<View className="flex-1 items-center justify-end px-8 pb-4">
{step === 0 && (
<Mount className="items-center gap-3 pb-52">
<Text
style={{ fontFamily: SERIF }}
className="text-center text-[30px] leading-tight text-[#2A3A5C]"
>
Everything you{"\n"}don't want to forget.
</Text>
<Text className="text-center text-[15px] text-[#2A3A5C]/60">
Lumio remembers it for you.
</Text>
</Mount>
)}
{step === 1 && (
<Mount className="items-center gap-3 pb-8">
<Text className="text-center text-[15px] font-medium text-[#2A3A5C]">
Try Lumio.
</Text>
<Text className="text-center text-[13px] text-[#2A3A5C]/60">
Tap the halo to begin
</Text>
</Mount>
)}
{step === 2 && (
<Mount className="items-center gap-3 pb-20">
<Text className="text-center text-[13px] text-[#2A3A5C]/60">
Tap the halo and say:
</Text>
<Text
style={{ fontFamily: SERIF }}
className="text-center text-[24px] leading-snug text-[#2A3A5C]"
>
« Julie recommended{"\n"}the Tuba restaurant{"\n"}in Marseille. »
</Text>
<Press
onPress={advance}
accessibilityRole="button"
accessibilityLabel="Next"
className="mt-4 rounded-full bg-white/70 px-6 py-2.5"
>
<Text className="text-[14px] font-semibold text-[#2A3A5C]">Next ›</Text>
</Press>
</Mount>
)}
{step === 3 && (
<Mount className="w-full items-center gap-3 pb-36">
<Text className="text-center text-[13px] text-[#2A3A5C]/60">
Tap the halo and ask:
</Text>
<Text
style={{ fontFamily: SERIF }}
className="text-center text-[22px] leading-snug text-[#2A3A5C]"
>
« What gift ideas did I{"\n"}note for my dad? »
</Text>
<MemoryCard />
</Mount>
)}
</View>
{cta && (
<StickyBottomBar className="px-8">
<Button
size="lg"
className={cn("w-full", step === 0 ? "bg-white/80" : "bg-[#2A3A5C]")}
textClassName={step === 0 ? "text-[#2A3A5C]" : "text-white"}
onPress={() => {
if (step === 3) onDone?.();
advance();
}}
accessibilityLabel={cta}
>
{cta}
</Button>
</StickyBottomBar>
)}
</SafeAreaView>
</Press>
);
}
HaloMemoryScreen.displayName = "HaloMemoryScreen";
Recreate the "Halo Memory Screen" screen in React Native using LumioUI.
Goal: Marcel idiom: one continuous animated flow — a glowing halo orb spring-travels between welcome, press, quote and memory-card states 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 misc-halo-memory
```
Or pull just the components: `npx lumioui add button 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>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×16
## 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/misc/halo-memory.tsx` exporting HaloMemoryScreen. 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 { View, useWindowDimensions } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
import { Button, Icon, Text } from "@lumioui/ui";
import { cn, spring, useHaptic, useReducedMotion } from "@lumioui/core";
import { Drift, HeroGradient, Mount, Press, SERIF, StickyBottomBar } from "../shared";
/** The halo — a warm glowing orb, the persistent hero of the flow. Pure SVG
* radial gradient so the edge feathers to nothing; size/position come from
* the parent so each step can move it. */
function Halo({ size }: { size: number }) {
const id = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const r = size / 2;
return (
<Svg width={size} height={size} accessible={false}>
<Defs>
<RadialGradient id={id} cx="50%" cy="50%" r="50%">
<Stop offset={0} stopColor="#FFFDF4" stopOpacity={1} />
<Stop offset={0.35} stopColor="#FDEFC9" stopOpacity={0.95} />
<Stop offset={0.7} stopColor="#FBD9A8" stopOpacity={0.4} />
<Stop offset={1} stopColor="#FBD9A8" stopOpacity={0} />
</RadialGradient>
</Defs>
<Circle cx={r} cy={r} r={r} fill={`url(#${id})`} />
</Svg>
);
}
/** One remembered thing — the white card that rises in the last state. */
function MemoryCard() {
return (
<View className="w-full max-w-[300px] rounded-3xl bg-white p-5 shadow-xl shadow-black/10">
<View className="flex-row items-center gap-2">
<View className="h-8 w-8 items-center justify-center rounded-full bg-[#EEF2FF]">
<Icon name="package" size="md" color="#4F46E5" />
</View>
<Text className="text-[15px] font-semibold text-[#0B0B0F]">Gift ideas</Text>
</View>
<View className="my-3 border-t border-dashed border-[#0B0B0F]/10" />
<View className="mb-2 self-start rounded-full bg-[#0B0B0F]/[0.05] px-2 py-0.5">
<Text className="text-[11px] font-medium text-[#0B0B0F]/60">Dad</Text>
</View>
{["Garmin watch", "Weekend in Lisbon", "Italian coffee machine"].map((item) => (
<View key={item} className="flex-row items-center gap-2 py-1">
<Text className="text-[13px] text-[#0B0B0F]/40">•</Text>
<Text className="text-[14px] text-[#0B0B0F]">{item}</Text>
</View>
))}
<View className="mt-3 flex-row items-center gap-1.5">
<Icon name="refresh" size="sm" color="#0B0B0F" />
<Text className="text-[12px] text-[#0B0B0F]/40">3 memories found</Text>
</View>
</View>
);
}
/** Orb choreography — each step parks the halo somewhere new: top-center
* small for welcome, large centered for press, mid for quote, small top
* for the card reveal. `top` is a fraction of the window height. */
const ORB = [
{ top: 0.16, scale: 0.5 },
{ top: 0.2, scale: 1 },
{ top: 0.14, scale: 0.6 },
{ top: 0.1, scale: 0.45 },
] as const;
/** Halo memory — the Marcel idiom: ONE continuous animated flow, not a set
* of screens. A pastel dawn world with a glowing halo orb that persists
* through four states — welcome, press-the-halo, the remembered quote,
* the memory card — while copy and cards crossfade around it. Tapping the
* halo (or the pill) advances; it spring-travels to each state's spot. */
export function HaloMemoryScreen({ onDone, onSkip }: { onDone?: () => void; onSkip?: () => void }) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const [step, setStep] = React.useState(0);
const { height } = useWindowDimensions();
const top = useSharedValue<number>(ORB[0].top);
const scale = useSharedValue<number>(ORB[0].scale);
React.useEffect(() => {
const t = ORB[step] ?? ORB[0];
top.value = reduce ? t.top : withSpring(t.top, { damping: 18, stiffness: 90 });
scale.value = reduce ? t.scale : withSpring(t.scale, spring.layout);
}, [step, reduce, top, scale]);
const orbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: -110 }, { translateY: top.value * height }, { scale: scale.value }],
}));
const advance = () => {
haptics.light();
setStep((s) => (s < 3 ? s + 1 : 0));
};
const cta = step === 0 ? "Begin" : step === 1 ? "Tap the halo" : step === 3 ? "Continue" : null;
return (
<Press accessibilityRole="button" accessibilityLabel="Continue" onPress={advance} className="flex-1 bg-[#BFD3EE]">
{/* Dawn world — cool blue over warm peach, the Marcel gradient. */}
<HeroGradient from="#AFC4E8" to="#E8D9CE" />
<View className="absolute inset-x-0 bottom-0 h-[420px]">
<HeroGradient from="rgba(253,231,186,0)" to="#F5C99A" />
</View>
<Drift emoji="☁️" cls="left-6 top-24" size="text-[38px]" rotate={0} index={0} />
<Drift emoji="🕊️" cls="right-8 top-[380px]" size="text-[30px]" rotate={8} index={1} />
{/* The halo — always mounted, spring-travels between states. */}
<Animated.View
pointerEvents="none"
className="absolute left-1/2 items-center justify-center"
style={[orbStyle, { width: 220, height: 220 }]}
>
<Halo size={220} />
</Animated.View>
<SafeAreaView className="flex-1" edges={["top", "bottom"]}>
<View className="flex-row items-center justify-between px-4 pt-1">
<Press
onPress={onSkip}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Back"
className="flex-row items-center"
>
<Icon name="chevron-left" size="md" color="#3D4C6B" />
<Text className="text-[14px] font-medium text-[#3D4C6B]">Happily</Text>
</Press>
{step > 0 ? (
<Press
onPress={() => {
haptics.selection();
onSkip?.();
}}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Skip"
>
<Text className="text-[14px] font-medium text-[#3D4C6B]/70">Skip</Text>
</Press>
) : (
<View />
)}
</View>
{/* State copy — each step's text lives in the same column so the
world reads as continuous while content swaps. */}
<View className="flex-1 items-center justify-end px-8 pb-4">
{step === 0 && (
<Mount className="items-center gap-3 pb-52">
<Text
style={{ fontFamily: SERIF }}
className="text-center text-[30px] leading-tight text-[#2A3A5C]"
>
Everything you{"\n"}don't want to forget.
</Text>
<Text className="text-center text-[15px] text-[#2A3A5C]/60">
Lumio remembers it for you.
</Text>
</Mount>
)}
{step === 1 && (
<Mount className="items-center gap-3 pb-8">
<Text className="text-center text-[15px] font-medium text-[#2A3A5C]">
Try Lumio.
</Text>
<Text className="text-center text-[13px] text-[#2A3A5C]/60">
Tap the halo to begin
</Text>
</Mount>
)}
{step === 2 && (
<Mount className="items-center gap-3 pb-20">
<Text className="text-center text-[13px] text-[#2A3A5C]/60">
Tap the halo and say:
</Text>
<Text
style={{ fontFamily: SERIF }}
className="text-center text-[24px] leading-snug text-[#2A3A5C]"
>
« Julie recommended{"\n"}the Tuba restaurant{"\n"}in Marseille. »
</Text>
<Press
onPress={advance}
accessibilityRole="button"
accessibilityLabel="Next"
className="mt-4 rounded-full bg-white/70 px-6 py-2.5"
>
<Text className="text-[14px] font-semibold text-[#2A3A5C]">Next ›</Text>
</Press>
</Mount>
)}
{step === 3 && (
<Mount className="w-full items-center gap-3 pb-36">
<Text className="text-center text-[13px] text-[#2A3A5C]/60">
Tap the halo and ask:
</Text>
<Text
style={{ fontFamily: SERIF }}
className="text-center text-[22px] leading-snug text-[#2A3A5C]"
>
« What gift ideas did I{"\n"}note for my dad? »
</Text>
<MemoryCard />
</Mount>
)}
</View>
{cta && (
<StickyBottomBar className="px-8">
<Button
size="lg"
className={cn("w-full", step === 0 ? "bg-white/80" : "bg-[#2A3A5C]")}
textClassName={step === 0 ? "text-[#2A3A5C]" : "text-white"}
onPress={() => {
if (step === 3) onDone?.();
advance();
}}
accessibilityLabel={cta}
>
{cta}
</Button>
</StickyBottomBar>
)}
</SafeAreaView>
</Press>
);
}
HaloMemoryScreen.displayName = "HaloMemoryScreen";
```
Screen: https://lumioui.dev/screens/misc-halo-memory