Onboarding Flow
A paged welcome flow: spotlit illustrations that drift with the swipe, story-style progress, a button that grows.
import { useRef, useState, type ReactNode } from 'react';
import {
Platform,
Pressable,
StyleSheet,
Text,
View,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import Animated, {
Extrapolation,
interpolate,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AuroraVeil } from '../aurora-veil';
import { GlintCard } from '../glint-card';
import { ScrambleText } from '../scramble-text';
import { TiltDeck, TiltLayer } from '../tilt-deck';
import { WordCascade } from '../word-cascade';
import { gradient } from '../../../lib/kinetik/tokens/gradient';
import { spring } from '../../../lib/kinetik/motion/motion';
import { useReduceMotion } from '../../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';
import { minTouchTarget, palettes, withAlpha } from '../../../lib/kinetik/tokens/tokens';
export type OnboardingSlide = {
key: string;
/** Small label above the title. */
eyebrow?: string;
title: string;
body: string;
/** Illustration above the text. Moves with a little parallax while paging. */
visual?: ReactNode;
};
export type OnboardingFlowProps = {
/** Called from the last slide's button. */
onFinish: () => void;
/** Called by Skip. Defaults to `onFinish`. */
onSkip?: () => void;
/** Defaults to three slides about Kinetik; replace with your own. */
slides?: readonly OnboardingSlide[];
/** Brand name shown top left. */
brand?: string;
/** Accessible name of the round next button. Defaults to 'Continue'. */
nextLabel?: string;
/** Label of the last slide's button. Defaults to 'Get started'. */
finishLabel?: string;
};
/** Size of the round next button. */
const BUTTON = 56;
/**
* A paged welcome flow. Each illustration sits under a soft spotlight and
* drifts with the swipe, titles cascade in as their slide arrives, progress
* fills like stories, and the round next button grows into "Get started" on
* the last slide.
*/
export function OnboardingFlow({
onFinish,
onSkip = onFinish,
slides: slidesProp,
brand = 'Kinetik',
nextLabel = 'Continue',
finishLabel = 'Get started',
}: OnboardingFlowProps) {
const { colors, space, font, scheme } = useKinetikTheme();
const insets = useSafeAreaInsets();
const defaults = useDefaultSlides();
const slides = slidesProp ?? defaults;
const count = slides.length;
const scroller = useAnimatedRef<Animated.ScrollView>();
const offset = useSharedValue(0);
const [width, setWidth] = useState(0);
const [index, setIndex] = useState(0);
const widthRef = useRef(0);
const onScroll = useAnimatedScrollHandler((e) => {
offset.set(e.contentOffset.x);
});
const onLayout = (e: LayoutChangeEvent) => {
const w = e.nativeEvent.layout.width;
widthRef.current = w;
setWidth(w);
};
const settle = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const w = widthRef.current;
if (w > 0) setIndex(Math.round(e.nativeEvent.contentOffset.x / w));
};
const last = index === count - 1;
const goNext = () => {
if (last) return onFinish();
const next = index + 1;
scroller.current?.scrollTo({ x: next * widthRef.current, animated: true });
setIndex(next);
};
const pad = space[6];
const footer = Math.max(BUTTON, width - pad * 2);
return (
<View style={[styles.fill, { backgroundColor: colors.background }]}>
{/* On a light ground the aurora is ink, so it is kept faint there. */}
<AuroraVeil style={StyleSheet.absoluteFill} intensity={scheme === 'dark' ? 0.4 : 0.12} />
{/* The aurora fades out behind the text so the copy sits on a clean ground. */}
<View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
gradient(
`linear-gradient(180deg, ${withAlpha(colors.background, 0)} 0%, ${withAlpha(colors.background, 0.4)} 40%, ${colors.background} 68%)`,
),
]}
/>
<View
style={[styles.fill, { paddingTop: insets.top + space[2], paddingBottom: insets.bottom }]}
onLayout={onLayout}
testID="onboarding-root"
>
<View style={[styles.topBar, { paddingHorizontal: pad }]}>
<View style={styles.brand}>
<View style={styles.mark}>
<View style={[styles.markRing, { borderColor: colors.text }]} />
<View style={[styles.markDot, { backgroundColor: colors.text }]} />
</View>
<Text
style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.bold }}
>
{brand}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Skip introduction"
accessibilityElementsHidden={last}
importantForAccessibility={last ? 'no-hide-descendants' : 'auto'}
disabled={last}
onPress={onSkip}
hitSlop={8}
style={[styles.skip, { opacity: last ? 0 : 1 }]}
>
<Text
style={{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.medium,
}}
>
Skip
</Text>
</Pressable>
</View>
<Animated.ScrollView
ref={scroller}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={onScroll}
scrollEventThrottle={16}
onMomentumScrollEnd={settle}
style={styles.fill}
>
{width > 0 &&
slides.map((slide, i) => (
<Page
key={slide.key}
slide={slide}
index={i}
count={count}
width={width}
offset={offset}
active={i === index}
/>
))}
</Animated.ScrollView>
<View style={[styles.footer, { marginHorizontal: pad, marginBottom: space[5] }]}>
<View
accessible
accessibilityRole="text"
accessibilityLabel={`Step ${index + 1} of ${count}`}
style={styles.segments}
>
{slides.map((s, i) => (
<Segment key={s.key} index={i} count={count} offset={offset} width={width} />
))}
</View>
<NextButton
offset={offset}
width={width}
count={count}
full={footer}
label={last ? finishLabel : nextLabel}
finishLabel={finishLabel}
onPress={goNext}
/>
</View>
</View>
</View>
);
}
/** How far the last slide has arrived, 0–1. */
function arrival(offset: number, width: number, count: number) {
'worklet';
if (count <= 1) return 1;
if (width <= 0) return 0;
return interpolate(offset / width, [count - 2, count - 1], [0, 1], Extrapolation.CLAMP);
}
/**
* A round arrow button that widens into a labelled pill as the last slide
* arrives, and gives a little under the finger.
*/
function NextButton({
offset,
width,
count,
full,
label,
finishLabel,
onPress,
}: {
offset: SharedValue<number>;
width: number;
count: number;
full: number;
label: string;
finishLabel: string;
onPress: () => void;
}) {
const { colors, font, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const pressed = useSharedValue(0);
const shell = useAnimatedStyle(() => {
const t = arrival(offset.get(), width, count);
return {
width: BUTTON + (full - BUTTON) * t,
transform: [{ scale: 1 - pressed.get() * 0.04 }],
};
});
const arrow = useAnimatedStyle(() => {
const t = arrival(offset.get(), width, count);
return { opacity: 1 - Math.min(1, t * 2.5), transform: [{ translateX: t * 24 }] };
});
const text = useAnimatedStyle(() => {
const t = arrival(offset.get(), width, count);
return { opacity: Math.max(0, t * 2 - 1), transform: [{ scale: 0.92 + t * 0.08 }] };
});
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}
style={styles.buttonSlot}
>
<Animated.View
style={[styles.button, { backgroundColor: colors.ember, borderRadius: radius.pill }, shell]}
>
<Animated.View style={[styles.center, arrow]}>
<View style={[styles.arrowStem, { backgroundColor: colors.onEmber }]} />
<View style={[styles.arrowHead, { borderColor: colors.onEmber }]} />
</Animated.View>
<Animated.Text
numberOfLines={1}
style={[
styles.center,
styles.finish,
{ color: colors.onEmber, fontSize: font.size.md, fontWeight: font.weight.bold },
text,
]}
>
{finishLabel}
</Animated.Text>
</Animated.View>
</Pressable>
);
}
function Page({
slide,
index,
count,
width,
offset,
active,
}: {
slide: OnboardingSlide;
index: number;
count: number;
width: number;
offset: SharedValue<number>;
active: boolean;
}) {
const { colors, space, font } = useKinetikTheme();
// The illustration lags the page, so it seems to sit further back.
const visualStyle = useAnimatedStyle(() => {
const progress = offset.get() / width - index;
return {
opacity: interpolate(Math.abs(progress), [0, 0.8], [1, 0], Extrapolation.CLAMP),
transform: [
{ translateX: progress * width * 0.45 },
{ scale: interpolate(Math.abs(progress), [0, 1], [1, 0.9], Extrapolation.CLAMP) },
],
};
});
// The copy fades a little faster than it moves.
const copyStyle = useAnimatedStyle(() => {
const progress = offset.get() / width - index;
return { opacity: interpolate(Math.abs(progress), [0, 0.6], [1, 0], Extrapolation.CLAMP) };
});
return (
<View
style={[styles.page, { width, paddingHorizontal: space[6] }]}
accessibilityElementsHidden={!active}
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
>
<View style={styles.stage}>
{/* A soft spotlight the illustration stands in. */}
<View
pointerEvents="none"
style={[styles.spot, { boxShadow: `0px 0px 120px 90px ${colors.emberSoft}` }]}
/>
<Animated.View style={[styles.visual, visualStyle]}>{slide.visual}</Animated.View>
</View>
<Animated.View style={[{ gap: space[3], paddingBottom: space[6] }, copyStyle]}>
<Text
style={{
color: colors.textSubtle,
fontSize: font.size.xs,
fontWeight: font.weight.semibold,
letterSpacing: 1.4,
fontVariant: ['tabular-nums'],
}}
>
{String(index + 1).padStart(2, '0')} / {String(count).padStart(2, '0')}
{slide.eyebrow ? ` · ${slide.eyebrow.toUpperCase()}` : ''}
</Text>
<WordCascade
text={slide.title}
replayKey={active ? `on-${index}` : 'off'}
animateOnMount={index === 0}
style={{
color: colors.text,
fontSize: 34,
fontWeight: font.weight.bold,
letterSpacing: -1.2,
lineHeight: 38,
}}
/>
<Text style={{ color: colors.textMuted, fontSize: font.size.md, lineHeight: 24 }}>
{slide.body}
</Text>
</Animated.View>
</View>
);
}
/** One story-style progress bar; fills as its slide arrives. */
function Segment({
index,
count,
offset,
width,
}: {
index: number;
count: number;
offset: SharedValue<number>;
width: number;
}) {
const { colors, radius } = useKinetikTheme();
const fill = useAnimatedStyle(() => {
const t = width > 0 ? offset.get() / width - index + 1 : index === 0 ? 1 : 0;
return { width: `${Math.max(0, Math.min(1, t)) * 100}%` };
});
const hide = useAnimatedStyle(() => ({
opacity: 1 - arrival(offset.get(), width, count),
}));
return (
<Animated.View
style={[
styles.segment,
{ backgroundColor: withAlpha(colors.text, 0.14), borderRadius: radius.pill },
hide,
]}
>
<Animated.View style={[styles.segmentFill, { backgroundColor: colors.text }, fill]} />
</Animated.View>
);
}
function useDefaultSlides(): OnboardingSlide[] {
const { colors, space, font, radius, shadow } = useKinetikTheme();
return [
{
key: 'explain',
eyebrow: 'Motion with a purpose',
title: 'Interfaces that move like they mean it.',
body: 'Every transition shows where something came from and where it went, so people never lose their place.',
visual: (
<View style={styles.fan}>
{/* Two more cards stacked behind, peeking over the top, for depth. */}
{[2, 1].map((level) => (
<View
key={level}
style={[
styles.stacked,
{
backgroundColor: colors.surface,
borderColor: colors.borderStrong,
borderRadius: radius.lg,
opacity: 1 - level * 0.3,
transform: [{ translateY: -14 * level }, { scale: 1 - level * 0.06 }],
},
]}
/>
))}
<TiltDeck
accessibilityLabel="Album card"
contentStyle={{ padding: 0 }}
radius={radius.lg}
style={[styles.card, { boxShadow: shadow.lg }]}
>
<View
style={[
styles.art,
gradient(
`linear-gradient(160deg, ${withAlpha(colors.ember, 0.9)} 0%, ${withAlpha(colors.ember, 0.25)} 45%, ${colors.surfaceSunken} 100%)`,
),
]}
>
<TiltLayer
depth={-0.6}
style={[styles.sun, { backgroundColor: withAlpha(colors.highlight, 0.3) }]}
/>
<TiltLayer depth={0.7} style={[styles.hill, { backgroundColor: colors.surface }]} />
</View>
<TiltLayer depth={0.4} style={{ padding: space[4], gap: 2 }}>
<Text
style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.bold }}
>
Press a corner
</Text>
<Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>
Layers shift at their own depth
</Text>
</TiltLayer>
</TiltDeck>
</View>
),
},
{
key: 'physical',
eyebrow: 'Feels physical',
title: 'Light, weight and a little give.',
body: 'Springs instead of timers, a glint that follows your finger, haptics only where they carry meaning.',
visual: (
<GlintCard
glintColor={colors.highlight}
radius={18}
intensity={0.85}
style={[styles.card, styles.metal, gradient(METAL), { boxShadow: shadow.lg }]}
contentStyle={{ flex: 1, padding: space[5], justifyContent: 'space-between' }}
>
<View style={styles.row}>
<Text style={[styles.metalText, { fontSize: 12, fontWeight: '800', letterSpacing: 3 }]}>
KINETIK
</Text>
<Text style={[styles.metalText, { fontSize: 11, opacity: 0.6, letterSpacing: 1.6 }]}>
MEMBER
</Text>
</View>
<View style={[styles.chip, gradient(CHIP)]} />
<View style={{ gap: 2 }}>
<Text style={[styles.metalText, { fontSize: font.size.lg, fontWeight: '700' }]}>
Drag across me
</Text>
<Text style={[styles.metalText, { fontSize: font.size.sm, opacity: 0.6 }]}>
or tilt your phone
</Text>
</View>
</GlintCard>
),
},
{
key: 'yours',
eyebrow: 'Yours to change',
title: 'Copied into your app, not hidden in a package.',
body: 'One command adds the source and installs matching native versions. Edit anything.',
visual: (
<View
style={[
styles.card,
styles.terminal,
{
backgroundColor: colors.surfaceSunken,
borderColor: colors.borderStrong,
borderRadius: radius.lg,
boxShadow: shadow.lg,
},
]}
>
<View
style={[
styles.terminalBar,
{ borderColor: colors.border, paddingHorizontal: space[4] },
]}
>
{[0.9, 0.6, 0.35].map((a) => (
<View
key={a}
style={[styles.light, { backgroundColor: withAlpha(colors.text, a * 0.5) }]}
/>
))}
<Text style={[styles.terminalTitle, { color: colors.textSubtle }]}>my-app</Text>
</View>
<View style={{ padding: space[4], gap: space[2] }}>
<View style={styles.prompt}>
<Text style={{ color: colors.textSubtle, fontSize: font.size.sm, fontFamily: MONO }}>
${' '}
</Text>
<ScrambleText
text="npx kinetik-ui add glint-card"
style={{ color: colors.text, fontSize: font.size.sm, fontFamily: MONO }}
/>
</View>
{[
'components/kinetik/glint-card.tsx',
'lib/kinetik/tokens.ts',
'2 native packages',
].map((line) => (
<Text
key={line}
numberOfLines={1}
style={{ color: colors.textMuted, fontSize: font.size.xs, fontFamily: MONO }}
>
<Text style={{ color: colors.success }}>✓ </Text>
{line}
</Text>
))}
</View>
</View>
),
},
];
}
const MONO = Platform.select({ ios: 'Menlo', default: 'monospace' });
// The member card is black metal on either theme, so it always uses the dark palette.
const METAL_INK = palettes.dark;
const METAL = `linear-gradient(135deg, ${METAL_INK.surfaceRaised} 0%, ${METAL_INK.surface} 45%, ${METAL_INK.background} 72%, ${METAL_INK.skeleton} 100%)`;
const CHIP = `linear-gradient(140deg, ${METAL_INK.text} 0%, ${METAL_INK.textSubtle} 45%, ${METAL_INK.textMuted} 70%, ${METAL_INK.textSubtle} 100%)`;
const styles = StyleSheet.create({
fill: { flex: 1 },
topBar: {
height: minTouchTarget,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
brand: { flexDirection: 'row', alignItems: 'center', gap: 8 },
mark: { width: 22, height: 14, justifyContent: 'center' },
markRing: {
position: 'absolute',
left: 0,
width: 14,
height: 14,
borderRadius: 7,
borderWidth: 1.5,
},
markDot: { position: 'absolute', left: 8, width: 14, height: 14, borderRadius: 7 },
skip: {
minHeight: minTouchTarget,
minWidth: minTouchTarget,
alignItems: 'flex-end',
justifyContent: 'center',
},
page: { flex: 1 },
stage: { flex: 1, alignItems: 'center', justifyContent: 'center' },
spot: { position: 'absolute', width: 1, height: 1, top: '50%', left: '50%' },
visual: { width: '100%', alignItems: 'center' },
card: { width: '100%', maxWidth: 300 },
fan: { width: '100%', maxWidth: 300, alignItems: 'center' },
stacked: {
...StyleSheet.absoluteFill,
borderWidth: StyleSheet.hairlineWidth,
},
art: { height: 150, overflow: 'hidden' },
sun: { position: 'absolute', width: 72, height: 72, borderRadius: 36, top: 28, left: 40 },
hill: {
position: 'absolute',
width: 220,
height: 220,
borderRadius: 110,
right: -50,
bottom: -170,
transform: [{ scaleX: 2.2 }],
},
metal: { aspectRatio: 1.586, borderColor: METAL_INK.borderStrong },
metalText: { color: METAL_INK.text },
chip: { width: 40, height: 30, borderRadius: 6 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
prompt: { flexDirection: 'row', alignItems: 'center' },
terminal: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' },
terminalBar: {
height: 36,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderBottomWidth: StyleSheet.hairlineWidth,
},
terminalTitle: { flex: 1, textAlign: 'center', marginRight: 42, fontSize: 12, fontFamily: MONO },
light: { width: 10, height: 10, borderRadius: 5 },
footer: {
height: BUTTON,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
segments: { flexDirection: 'row', alignItems: 'center', gap: 6 },
segment: { width: 28, height: 3, overflow: 'hidden' },
segmentFill: { height: '100%' },
buttonSlot: { position: 'absolute', right: 0, top: 0 },
button: { height: BUTTON, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
center: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
finish: { left: 0, right: 0, textAlign: 'center' },
arrowStem: { width: 18, height: 2, borderRadius: 1 },
arrowHead: {
position: 'absolute',
right: 0,
width: 9,
height: 9,
borderTopWidth: 2,
borderRightWidth: 2,
borderTopRightRadius: 1,
transform: [{ rotate: '45deg' }],
},
});Installation
npx kinetik-ui add onboarding-flowpnpm dlx kinetik-ui add onboarding-flowbunx kinetik-ui add onboarding-flowyarn dlx kinetik-ui add onboarding-flowThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/onboarding-flow.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets - Copy the source files
components/kinetik/screens/onboarding-flow.tsx
Usage
import { OnboardingFlow } from '@/components/kinetik/screens/onboarding-flow';
export default function Welcome() {
return (
<OnboardingFlow
brand="Acme"
onFinish={() => router.replace('/home')}
slides={[
{ key: 'a', eyebrow: 'Plan', title: 'Your week at a glance.', body: '…', visual: <Hero /> },
{ key: 'b', title: 'Share with your team.', body: '…' },
]}
/>
);
}Render it as a full screen (it fills its parent and handles the safe area).
When to use
- First launch, or after a major update, to explain two to four ideas.
- When each idea has a visual worth showing; the illustrations carry the flow.
When not to use
- To ask for permissions or collect data. Ask in context, where the reason is obvious.
- More than four slides. People skip long tours; teach in the product instead.
Notes
- Default slides show Tilt Deck, Glint Card and Scramble Text; pass
slidesto replace them. - The round next button grows into
finishLabelas the last slide arrives. - The title of each slide replays its cascade when the slide arrives.
onSkipdefaults toonFinish.
Props
OnboardingFlowProps
| Prop | Type | Default | Description |
|---|---|---|---|
onFinishrequired | () => void | — | Called from the last slide's button. |
onSkip | () => void | onFinish | Called by Skip. Defaults to `onFinish`. |
slides | readonly OnboardingSlide[] | — | Defaults to three slides about Kinetik; replace with your own. |
brand | string | 'Kinetik' | Brand name shown top left. |
nextLabel | string | 'Continue' | Accessible name of the round next button. Defaults to 'Continue'. |
finishLabel | string | 'Get started' | Label of the last slide's button. Defaults to 'Get started'. |
Performance and accessibility
Performance
- Budget
- One shader background plus the visible slide. Paging, parallax, the progress bars and the growing button are scroll-driven worklets; nothing runs on the JS thread while swiping.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- Titles appear without cascading and the background holds still (handled by Word Cascade and Aurora Veil). Paging is user-driven, so it stays.
- Screen readers
- Only the current slide is exposed. The progress bars read as "Step 2 of 3". The round button is named Continue, then Get started. Skip is hidden on the last slide.
- Touch target
- Skip is at least 44 pt; the round button is 56 pt and spans the width on the last slide.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/screens/onboarding-flow.tsx
import { useRef, useState, type ReactNode } from 'react';
import {
Platform,
Pressable,
StyleSheet,
Text,
View,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import Animated, {
Extrapolation,
interpolate,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AuroraVeil } from '../aurora-veil';
import { GlintCard } from '../glint-card';
import { ScrambleText } from '../scramble-text';
import { TiltDeck, TiltLayer } from '../tilt-deck';
import { WordCascade } from '../word-cascade';
import { gradient } from '../../../lib/kinetik/tokens/gradient';
import { spring } from '../../../lib/kinetik/motion/motion';
import { useReduceMotion } from '../../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';
import { minTouchTarget, palettes, withAlpha } from '../../../lib/kinetik/tokens/tokens';
export type OnboardingSlide = {
key: string;
/** Small label above the title. */
eyebrow?: string;
title: string;
body: string;
/** Illustration above the text. Moves with a little parallax while paging. */
visual?: ReactNode;
};
export type OnboardingFlowProps = {
/** Called from the last slide's button. */
onFinish: () => void;
/** Called by Skip. Defaults to `onFinish`. */
onSkip?: () => void;
/** Defaults to three slides about Kinetik; replace with your own. */
slides?: readonly OnboardingSlide[];
/** Brand name shown top left. */
brand?: string;
/** Accessible name of the round next button. Defaults to 'Continue'. */
nextLabel?: string;
/** Label of the last slide's button. Defaults to 'Get started'. */
finishLabel?: string;
};
/** Size of the round next button. */
const BUTTON = 56;
/**
* A paged welcome flow. Each illustration sits under a soft spotlight and
* drifts with the swipe, titles cascade in as their slide arrives, progress
* fills like stories, and the round next button grows into "Get started" on
* the last slide.
*/
export function OnboardingFlow({
onFinish,
onSkip = onFinish,
slides: slidesProp,
brand = 'Kinetik',
nextLabel = 'Continue',
finishLabel = 'Get started',
}: OnboardingFlowProps) {
const { colors, space, font, scheme } = useKinetikTheme();
const insets = useSafeAreaInsets();
const defaults = useDefaultSlides();
const slides = slidesProp ?? defaults;
const count = slides.length;
const scroller = useAnimatedRef<Animated.ScrollView>();
const offset = useSharedValue(0);
const [width, setWidth] = useState(0);
const [index, setIndex] = useState(0);
const widthRef = useRef(0);
const onScroll = useAnimatedScrollHandler((e) => {
offset.set(e.contentOffset.x);
});
const onLayout = (e: LayoutChangeEvent) => {
const w = e.nativeEvent.layout.width;
widthRef.current = w;
setWidth(w);
};
const settle = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const w = widthRef.current;
if (w > 0) setIndex(Math.round(e.nativeEvent.contentOffset.x / w));
};
const last = index === count - 1;
const goNext = () => {
if (last) return onFinish();
const next = index + 1;
scroller.current?.scrollTo({ x: next * widthRef.current, animated: true });
setIndex(next);
};
const pad = space[6];
const footer = Math.max(BUTTON, width - pad * 2);
return (
<View style={[styles.fill, { backgroundColor: colors.background }]}>
{/* On a light ground the aurora is ink, so it is kept faint there. */}
<AuroraVeil style={StyleSheet.absoluteFill} intensity={scheme === 'dark' ? 0.4 : 0.12} />
{/* The aurora fades out behind the text so the copy sits on a clean ground. */}
<View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
gradient(
`linear-gradient(180deg, ${withAlpha(colors.background, 0)} 0%, ${withAlpha(colors.background, 0.4)} 40%, ${colors.background} 68%)`,
),
]}
/>
<View
style={[styles.fill, { paddingTop: insets.top + space[2], paddingBottom: insets.bottom }]}
onLayout={onLayout}
testID="onboarding-root"
>
<View style={[styles.topBar, { paddingHorizontal: pad }]}>
<View style={styles.brand}>
<View style={styles.mark}>
<View style={[styles.markRing, { borderColor: colors.text }]} />
<View style={[styles.markDot, { backgroundColor: colors.text }]} />
</View>
<Text
style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.bold }}
>
{brand}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Skip introduction"
accessibilityElementsHidden={last}
importantForAccessibility={last ? 'no-hide-descendants' : 'auto'}
disabled={last}
onPress={onSkip}
hitSlop={8}
style={[styles.skip, { opacity: last ? 0 : 1 }]}
>
<Text
style={{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.medium,
}}
>
Skip
</Text>
</Pressable>
</View>
<Animated.ScrollView
ref={scroller}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={onScroll}
scrollEventThrottle={16}
onMomentumScrollEnd={settle}
style={styles.fill}
>
{width > 0 &&
slides.map((slide, i) => (
<Page
key={slide.key}
slide={slide}
index={i}
count={count}
width={width}
offset={offset}
active={i === index}
/>
))}
</Animated.ScrollView>
<View style={[styles.footer, { marginHorizontal: pad, marginBottom: space[5] }]}>
<View
accessible
accessibilityRole="text"
accessibilityLabel={`Step ${index + 1} of ${count}`}
style={styles.segments}
>
{slides.map((s, i) => (
<Segment key={s.key} index={i} count={count} offset={offset} width={width} />
))}
</View>
<NextButton
offset={offset}
width={width}
count={count}
full={footer}
label={last ? finishLabel : nextLabel}
finishLabel={finishLabel}
onPress={goNext}
/>
</View>
</View>
</View>
);
}
/** How far the last slide has arrived, 0–1. */
function arrival(offset: number, width: number, count: number) {
'worklet';
if (count <= 1) return 1;
if (width <= 0) return 0;
return interpolate(offset / width, [count - 2, count - 1], [0, 1], Extrapolation.CLAMP);
}
/**
* A round arrow button that widens into a labelled pill as the last slide
* arrives, and gives a little under the finger.
*/
function NextButton({
offset,
width,
count,
full,
label,
finishLabel,
onPress,
}: {
offset: SharedValue<number>;
width: number;
count: number;
full: number;
label: string;
finishLabel: string;
onPress: () => void;
}) {
const { colors, font, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const pressed = useSharedValue(0);
const shell = useAnimatedStyle(() => {
const t = arrival(offset.get(), width, count);
return {
width: BUTTON + (full - BUTTON) * t,
transform: [{ scale: 1 - pressed.get() * 0.04 }],
};
});
const arrow = useAnimatedStyle(() => {
const t = arrival(offset.get(), width, count);
return { opacity: 1 - Math.min(1, t * 2.5), transform: [{ translateX: t * 24 }] };
});
const text = useAnimatedStyle(() => {
const t = arrival(offset.get(), width, count);
return { opacity: Math.max(0, t * 2 - 1), transform: [{ scale: 0.92 + t * 0.08 }] };
});
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}
style={styles.buttonSlot}
>
<Animated.View
style={[styles.button, { backgroundColor: colors.ember, borderRadius: radius.pill }, shell]}
>
<Animated.View style={[styles.center, arrow]}>
<View style={[styles.arrowStem, { backgroundColor: colors.onEmber }]} />
<View style={[styles.arrowHead, { borderColor: colors.onEmber }]} />
</Animated.View>
<Animated.Text
numberOfLines={1}
style={[
styles.center,
styles.finish,
{ color: colors.onEmber, fontSize: font.size.md, fontWeight: font.weight.bold },
text,
]}
>
{finishLabel}
</Animated.Text>
</Animated.View>
</Pressable>
);
}
function Page({
slide,
index,
count,
width,
offset,
active,
}: {
slide: OnboardingSlide;
index: number;
count: number;
width: number;
offset: SharedValue<number>;
active: boolean;
}) {
const { colors, space, font } = useKinetikTheme();
// The illustration lags the page, so it seems to sit further back.
const visualStyle = useAnimatedStyle(() => {
const progress = offset.get() / width - index;
return {
opacity: interpolate(Math.abs(progress), [0, 0.8], [1, 0], Extrapolation.CLAMP),
transform: [
{ translateX: progress * width * 0.45 },
{ scale: interpolate(Math.abs(progress), [0, 1], [1, 0.9], Extrapolation.CLAMP) },
],
};
});
// The copy fades a little faster than it moves.
const copyStyle = useAnimatedStyle(() => {
const progress = offset.get() / width - index;
return { opacity: interpolate(Math.abs(progress), [0, 0.6], [1, 0], Extrapolation.CLAMP) };
});
return (
<View
style={[styles.page, { width, paddingHorizontal: space[6] }]}
accessibilityElementsHidden={!active}
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
>
<View style={styles.stage}>
{/* A soft spotlight the illustration stands in. */}
<View
pointerEvents="none"
style={[styles.spot, { boxShadow: `0px 0px 120px 90px ${colors.emberSoft}` }]}
/>
<Animated.View style={[styles.visual, visualStyle]}>{slide.visual}</Animated.View>
</View>
<Animated.View style={[{ gap: space[3], paddingBottom: space[6] }, copyStyle]}>
<Text
style={{
color: colors.textSubtle,
fontSize: font.size.xs,
fontWeight: font.weight.semibold,
letterSpacing: 1.4,
fontVariant: ['tabular-nums'],
}}
>
{String(index + 1).padStart(2, '0')} / {String(count).padStart(2, '0')}
{slide.eyebrow ? ` · ${slide.eyebrow.toUpperCase()}` : ''}
</Text>
<WordCascade
text={slide.title}
replayKey={active ? `on-${index}` : 'off'}
animateOnMount={index === 0}
style={{
color: colors.text,
fontSize: 34,
fontWeight: font.weight.bold,
letterSpacing: -1.2,
lineHeight: 38,
}}
/>
<Text style={{ color: colors.textMuted, fontSize: font.size.md, lineHeight: 24 }}>
{slide.body}
</Text>
</Animated.View>
</View>
);
}
/** One story-style progress bar; fills as its slide arrives. */
function Segment({
index,
count,
offset,
width,
}: {
index: number;
count: number;
offset: SharedValue<number>;
width: number;
}) {
const { colors, radius } = useKinetikTheme();
const fill = useAnimatedStyle(() => {
const t = width > 0 ? offset.get() / width - index + 1 : index === 0 ? 1 : 0;
return { width: `${Math.max(0, Math.min(1, t)) * 100}%` };
});
const hide = useAnimatedStyle(() => ({
opacity: 1 - arrival(offset.get(), width, count),
}));
return (
<Animated.View
style={[
styles.segment,
{ backgroundColor: withAlpha(colors.text, 0.14), borderRadius: radius.pill },
hide,
]}
>
<Animated.View style={[styles.segmentFill, { backgroundColor: colors.text }, fill]} />
</Animated.View>
);
}
function useDefaultSlides(): OnboardingSlide[] {
const { colors, space, font, radius, shadow } = useKinetikTheme();
return [
{
key: 'explain',
eyebrow: 'Motion with a purpose',
title: 'Interfaces that move like they mean it.',
body: 'Every transition shows where something came from and where it went, so people never lose their place.',
visual: (
<View style={styles.fan}>
{/* Two more cards stacked behind, peeking over the top, for depth. */}
{[2, 1].map((level) => (
<View
key={level}
style={[
styles.stacked,
{
backgroundColor: colors.surface,
borderColor: colors.borderStrong,
borderRadius: radius.lg,
opacity: 1 - level * 0.3,
transform: [{ translateY: -14 * level }, { scale: 1 - level * 0.06 }],
},
]}
/>
))}
<TiltDeck
accessibilityLabel="Album card"
contentStyle={{ padding: 0 }}
radius={radius.lg}
style={[styles.card, { boxShadow: shadow.lg }]}
>
<View
style={[
styles.art,
gradient(
`linear-gradient(160deg, ${withAlpha(colors.ember, 0.9)} 0%, ${withAlpha(colors.ember, 0.25)} 45%, ${colors.surfaceSunken} 100%)`,
),
]}
>
<TiltLayer
depth={-0.6}
style={[styles.sun, { backgroundColor: withAlpha(colors.highlight, 0.3) }]}
/>
<TiltLayer depth={0.7} style={[styles.hill, { backgroundColor: colors.surface }]} />
</View>
<TiltLayer depth={0.4} style={{ padding: space[4], gap: 2 }}>
<Text
style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.bold }}
>
Press a corner
</Text>
<Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>
Layers shift at their own depth
</Text>
</TiltLayer>
</TiltDeck>
</View>
),
},
{
key: 'physical',
eyebrow: 'Feels physical',
title: 'Light, weight and a little give.',
body: 'Springs instead of timers, a glint that follows your finger, haptics only where they carry meaning.',
visual: (
<GlintCard
glintColor={colors.highlight}
radius={18}
intensity={0.85}
style={[styles.card, styles.metal, gradient(METAL), { boxShadow: shadow.lg }]}
contentStyle={{ flex: 1, padding: space[5], justifyContent: 'space-between' }}
>
<View style={styles.row}>
<Text style={[styles.metalText, { fontSize: 12, fontWeight: '800', letterSpacing: 3 }]}>
KINETIK
</Text>
<Text style={[styles.metalText, { fontSize: 11, opacity: 0.6, letterSpacing: 1.6 }]}>
MEMBER
</Text>
</View>
<View style={[styles.chip, gradient(CHIP)]} />
<View style={{ gap: 2 }}>
<Text style={[styles.metalText, { fontSize: font.size.lg, fontWeight: '700' }]}>
Drag across me
</Text>
<Text style={[styles.metalText, { fontSize: font.size.sm, opacity: 0.6 }]}>
or tilt your phone
</Text>
</View>
</GlintCard>
),
},
{
key: 'yours',
eyebrow: 'Yours to change',
title: 'Copied into your app, not hidden in a package.',
body: 'One command adds the source and installs matching native versions. Edit anything.',
visual: (
<View
style={[
styles.card,
styles.terminal,
{
backgroundColor: colors.surfaceSunken,
borderColor: colors.borderStrong,
borderRadius: radius.lg,
boxShadow: shadow.lg,
},
]}
>
<View
style={[
styles.terminalBar,
{ borderColor: colors.border, paddingHorizontal: space[4] },
]}
>
{[0.9, 0.6, 0.35].map((a) => (
<View
key={a}
style={[styles.light, { backgroundColor: withAlpha(colors.text, a * 0.5) }]}
/>
))}
<Text style={[styles.terminalTitle, { color: colors.textSubtle }]}>my-app</Text>
</View>
<View style={{ padding: space[4], gap: space[2] }}>
<View style={styles.prompt}>
<Text style={{ color: colors.textSubtle, fontSize: font.size.sm, fontFamily: MONO }}>
${' '}
</Text>
<ScrambleText
text="npx kinetik-ui add glint-card"
style={{ color: colors.text, fontSize: font.size.sm, fontFamily: MONO }}
/>
</View>
{[
'components/kinetik/glint-card.tsx',
'lib/kinetik/tokens.ts',
'2 native packages',
].map((line) => (
<Text
key={line}
numberOfLines={1}
style={{ color: colors.textMuted, fontSize: font.size.xs, fontFamily: MONO }}
>
<Text style={{ color: colors.success }}>✓ </Text>
{line}
</Text>
))}
</View>
</View>
),
},
];
}
const MONO = Platform.select({ ios: 'Menlo', default: 'monospace' });
// The member card is black metal on either theme, so it always uses the dark palette.
const METAL_INK = palettes.dark;
const METAL = `linear-gradient(135deg, ${METAL_INK.surfaceRaised} 0%, ${METAL_INK.surface} 45%, ${METAL_INK.background} 72%, ${METAL_INK.skeleton} 100%)`;
const CHIP = `linear-gradient(140deg, ${METAL_INK.text} 0%, ${METAL_INK.textSubtle} 45%, ${METAL_INK.textMuted} 70%, ${METAL_INK.textSubtle} 100%)`;
const styles = StyleSheet.create({
fill: { flex: 1 },
topBar: {
height: minTouchTarget,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
brand: { flexDirection: 'row', alignItems: 'center', gap: 8 },
mark: { width: 22, height: 14, justifyContent: 'center' },
markRing: {
position: 'absolute',
left: 0,
width: 14,
height: 14,
borderRadius: 7,
borderWidth: 1.5,
},
markDot: { position: 'absolute', left: 8, width: 14, height: 14, borderRadius: 7 },
skip: {
minHeight: minTouchTarget,
minWidth: minTouchTarget,
alignItems: 'flex-end',
justifyContent: 'center',
},
page: { flex: 1 },
stage: { flex: 1, alignItems: 'center', justifyContent: 'center' },
spot: { position: 'absolute', width: 1, height: 1, top: '50%', left: '50%' },
visual: { width: '100%', alignItems: 'center' },
card: { width: '100%', maxWidth: 300 },
fan: { width: '100%', maxWidth: 300, alignItems: 'center' },
stacked: {
...StyleSheet.absoluteFill,
borderWidth: StyleSheet.hairlineWidth,
},
art: { height: 150, overflow: 'hidden' },
sun: { position: 'absolute', width: 72, height: 72, borderRadius: 36, top: 28, left: 40 },
hill: {
position: 'absolute',
width: 220,
height: 220,
borderRadius: 110,
right: -50,
bottom: -170,
transform: [{ scaleX: 2.2 }],
},
metal: { aspectRatio: 1.586, borderColor: METAL_INK.borderStrong },
metalText: { color: METAL_INK.text },
chip: { width: 40, height: 30, borderRadius: 6 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
prompt: { flexDirection: 'row', alignItems: 'center' },
terminal: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' },
terminalBar: {
height: 36,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderBottomWidth: StyleSheet.hairlineWidth,
},
terminalTitle: { flex: 1, textAlign: 'center', marginRight: 42, fontSize: 12, fontFamily: MONO },
light: { width: 10, height: 10, borderRadius: 5 },
footer: {
height: BUTTON,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
segments: { flexDirection: 'row', alignItems: 'center', gap: 6 },
segment: { width: 28, height: 3, overflow: 'hidden' },
segmentFill: { height: '100%' },
buttonSlot: { position: 'absolute', right: 0, top: 0 },
button: { height: BUTTON, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
center: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
finish: { left: 0, right: 0, textAlign: 'center' },
arrowStem: { width: 18, height: 2, borderRadius: 1 },
arrowHead: {
position: 'absolute',
right: 0,
width: 9,
height: 9,
borderTopWidth: 2,
borderRightWidth: 2,
borderTopRightRadius: 1,
transform: [{ rotate: '45deg' }],
},
});