Kinetik UI

Onboarding Flow

.md

A paged welcome flow: spotlit illustrations that drift with the swipe, story-style progress, a button that grows.

v1.0.0Live on webreact-native-reanimatedreact-native-safe-area-contextreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add onboarding-flow

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets
  2. Copy the source files
    • components/kinetik/screens/onboarding-flow.tsx
    View source files

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 slides to replace them.
  • The round next button grows into finishLabel as the last slide arrives.
  • The title of each slide replays its cascade when the slide arrives.
  • onSkip defaults to onFinish.

Props

OnboardingFlowProps

PropTypeDefaultDescription
onFinishrequired() => void—Called from the last slide's button.
onSkip() => voidonFinishCalled by Skip. Defaults to `onFinish`.
slidesreadonly OnboardingSlide[]—Defaults to three slides about Kinetik; replace with your own.
brandstring'Kinetik'Brand name shown top left.
nextLabelstring'Continue'Accessible name of the round next button. Defaults to 'Continue'.
finishLabelstring'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' }],
  },
});