Skip to main content

Custom Renderers (Level 3)

Take complete control over entire screens by replacing the SDK's renderers with your own custom implementations.

Overview

Custom renderers provide:

  • Complete control over screen layout and logic
  • Custom state management beyond what the SDK provides
  • Integration with external libraries
  • Custom animations and transitions
  • Business logic specific to your app
Advanced Feature

Custom renderers require you to handle everything yourself. Use this only when theming and custom components don't meet your needs.


How It Works

The SDK's OnboardingPage component routes steps to renderers based on step.type:

<OnboardingPage step={step} onContinue={handleContinue} />

To override, add conditional logic in your routing file before calling OnboardingPage:

// app/onboarding/[questionId].tsx
import { useOnboardingQuestions, OnboardingPage } from "@rocapine/react-native-onboarding";
import { MyCustomScreen } from "@/components/MyCustomScreen";

export default function OnboardingScreen() {
const { questionId } = useLocalSearchParams();
const { step, isLastStep } = useOnboardingQuestions({
stepNumber: parseInt(questionId as string, 10),
});

const onContinue = (args?: any) => {
// Your navigation logic
};

// ✅ Override specific screens
if (step.type === "Question" && step.id === "special-question") {
return <MyCustomScreen step={step} onContinue={onContinue} />;
}

// Default SDK rendering
return <OnboardingPage step={step} onContinue={onContinue} />;
}

Implementation Patterns

Pattern 1: Override by Step ID

Override a specific step while keeping others using SDK renderers:

export default function OnboardingScreen() {
const { step } = useOnboardingQuestions({ stepNumber });

const onContinue = (args?: any) => {
// Navigation logic
};

// Override specific step by ID
if (step.id === "custom-welcome") {
return <CustomWelcomeScreen step={step} onContinue={onContinue} />;
}

if (step.id === "special-picker") {
return <CustomPickerScreen step={step} onContinue={onContinue} />;
}

// Default for all other steps
return <OnboardingPage step={step} onContinue={onContinue} />;
}

Use when:

  • Specific screens need unique layouts
  • CMS-driven content with app-specific overrides
  • A/B testing specific screens

Pattern 2: Override by Step Type

Replace all screens of a specific type:

export default function OnboardingScreen() {
const { step } = useOnboardingQuestions({ stepNumber });

const onContinue = (args?: any) => {
// Navigation logic
};

// Override all Question screens
if (step.type === "Question") {
return <CustomQuestionRenderer step={step} onContinue={onContinue} />;
}

// Override all Picker screens
if (step.type === "Picker") {
return <CustomPickerRenderer step={step} onContinue={onContinue} />;
}

// Default for all other types
return <OnboardingPage step={step} onContinue={onContinue} />;
}

Use when:

  • You want consistent custom behavior across all screens of a type
  • Replacing SDK's implementation entirely for a screen type
  • Heavy customization requirements

Pattern 3: Switch Case for Multiple Overrides

Clean switch statement for multiple custom renderers:

export default function OnboardingScreen() {
const { step } = useOnboardingQuestions({ stepNumber });

const onContinue = (args?: any) => {
// Navigation logic
};

switch (step.id) {
case "welcome":
return <CustomWelcomeScreen step={step} onContinue={onContinue} />;

case "profile-builder":
return <CustomProfileScreen step={step} onContinue={onContinue} />;

case "preferences":
return <CustomPreferencesScreen step={step} onContinue={onContinue} />;

default:
// SDK default rendering
return <OnboardingPage step={step} onContinue={onContinue} />;
}
}

Use when:

  • Multiple specific overrides needed
  • Clear separation of custom vs. default screens
  • Easy to maintain and understand

Pattern 4: Conditional by Custom Payload

Use custom payload fields to determine rendering:

export default function OnboardingScreen() {
const { step } = useOnboardingQuestions({ stepNumber });

const onContinue = (args?: any) => {
// Navigation logic
};

// Check custom payload for rendering hints
if (step.customPayload?.useCustomRenderer === true) {
return <FullyCustomScreen step={step} onContinue={onContinue} />;
}

if (step.customPayload?.variant === "premium") {
return <PremiumVariantScreen step={step} onContinue={onContinue} />;
}

// Default SDK rendering
return <OnboardingPage step={step} onContinue={onContinue} />;
}

Use when:

  • CMS drives which screens get custom rendering
  • Feature flags or experiments
  • A/B testing variants

Custom Renderer Requirements

Your custom renderer must implement this interface:

interface CustomRendererProps {
step: OnboardingStepType; // Step data (with correct type)
onContinue: (args?: any) => void; // Callback when done
}

Minimal Example

import { View, Text, Button } from "react-native";
import { QuestionStepType } from "@rocapine/react-native-onboarding";

interface CustomQuestionScreenProps {
step: QuestionStepType;
onContinue: (args?: any) => void;
}

export const CustomQuestionScreen: React.FC<CustomQuestionScreenProps> = ({
step,
onContinue,
}) => {
return (
<View style={{ flex: 1, padding: 20 }}>
<Text style={{ fontSize: 24, marginBottom: 20 }}>
{step.payload.title}
</Text>

{/* Your custom UI */}
<Text>{step.payload.subtitle}</Text>

<Button title="Continue" onPress={() => onContinue()} />
</View>
);
};

Complete Examples

Example 1: Custom Question Screen with Animation

// components/CustomQuestionScreen.tsx
import React, { useState, useEffect } from "react";
import { View, Text, TouchableOpacity, StyleSheet, Animated } from "react-native";
import { QuestionStepType, useTheme } from "@rocapine/react-native-onboarding";

interface Props {
step: QuestionStepType;
onContinue: (selectedValues: string[]) => void;
}

export const CustomQuestionScreen: React.FC<Props> = ({ step, onContinue }) => {
const { theme } = useTheme();
const [selected, setSelected] = useState<Record<string, boolean>>({});
const fadeAnim = new Animated.Value(0);

useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}).start();
}, []);

const handleSelect = (value: string) => {
if (step.payload.multipleAnswer) {
setSelected(prev => ({ ...prev, [value]: !prev[value] }));
} else {
setSelected({ [value]: true });
}
};

const handleContinue = () => {
const selectedValues = Object.keys(selected).filter(k => selected[k]);
onContinue(selectedValues);
};

return (
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
<Text style={[styles.title, { color: theme.colors.text.primary }]}>
{step.payload.title}
</Text>

{step.payload.subtitle && (
<Text style={[styles.subtitle, { color: theme.colors.text.secondary }]}>
{step.payload.subtitle}
</Text>
)}

<View style={styles.answersContainer}>
{step.payload.answers.map((answer) => (
<TouchableOpacity
key={answer.value}
style={[
styles.answer,
{
backgroundColor: selected[answer.value]
? theme.colors.primary
: theme.colors.surface.lowest,
borderColor: theme.colors.neutral.lower,
},
]}
onPress={() => handleSelect(answer.value)}
>
<Text
style={{
color: selected[answer.value]
? theme.colors.text.opposite
: theme.colors.text.primary,
}}
>
{answer.label}
</Text>
</TouchableOpacity>
))}
</View>

<TouchableOpacity
style={[styles.button, { backgroundColor: theme.colors.primary }]}
onPress={handleContinue}
disabled={Object.keys(selected).length === 0}
>
<Text style={{ color: theme.colors.text.opposite }}>
{step.continueButtonLabel || "Continue"}
</Text>
</TouchableOpacity>
</Animated.View>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: "space-between",
},
title: {
fontSize: 32,
fontWeight: "600",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
marginBottom: 24,
},
answersContainer: {
flex: 1,
gap: 12,
},
answer: {
padding: 20,
borderRadius: 12,
borderWidth: 2,
alignItems: "center",
},
button: {
padding: 16,
borderRadius: 12,
alignItems: "center",
marginTop: 20,
},
});

// app/onboarding/[questionId].tsx
export default function OnboardingScreen() {
const { step } = useOnboardingQuestions({ stepNumber });

if (step.type === "Question" && step.id === "personality-quiz") {
return <CustomQuestionScreen step={step} onContinue={handleContinue} />;
}

return <OnboardingPage step={step} onContinue={handleContinue} />;
}

Example 2: Integrating External Library

// components/CustomVideoScreen.tsx
import React from "react";
import { View, Text, Button, StyleSheet } from "react-native";
import { Video } from "expo-av";
import { MediaContentStepType, useTheme } from "@rocapine/react-native-onboarding";

interface Props {
step: MediaContentStepType;
onContinue: () => void;
}

export const CustomVideoScreen: React.FC<Props> = ({ step, onContinue }) => {
const { theme } = useTheme();
const video = React.useRef(null);

return (
<View style={styles.container}>
<Text style={[styles.title, { color: theme.colors.text.primary }]}>
{step.payload.title}
</Text>

<Video
ref={video}
source={{ uri: step.payload.media.url }}
style={styles.video}
useNativeControls
resizeMode="contain"
isLooping
/>

{step.payload.description && (
<Text style={[styles.description, { color: theme.colors.text.secondary }]}>
{step.payload.description}
</Text>
)}

<Button
title={step.continueButtonLabel || "Continue"}
onPress={onContinue}
/>
</View>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: "space-between",
},
title: {
fontSize: 32,
fontWeight: "600",
marginBottom: 20,
},
video: {
flex: 1,
borderRadius: 12,
},
description: {
fontSize: 16,
marginTop: 20,
marginBottom: 20,
},
});

// app/onboarding/[questionId].tsx
export default function OnboardingScreen() {
const { step } = useOnboardingQuestions({ stepNumber });

if (step.type === "MediaContent" && step.payload.media.type === "video") {
return <CustomVideoScreen step={step} onContinue={handleContinue} />;
}

return <OnboardingPage step={step} onContinue={handleContinue} />;
}

Best Practices

1. Type Your Props Correctly

// ✅ Good - Exact step type
interface Props {
step: QuestionStepType;
onContinue: (args?: any) => void;
}

// ❌ Bad - Generic step type
interface Props {
step: OnboardingStepType;
onContinue: any;
}

2. Always Call onContinue

// ✅ Good
<Button onPress={() => onContinue(selectedValues)} />

// ❌ Bad - Never calls onContinue (user gets stuck)
<Button onPress={() => console.log("Done")} />

3. Use Theme When Possible

// ✅ Good - Uses theme
const { theme } = useTheme();
<View style={{ backgroundColor: theme.colors.surface.lowest }} />

// ⚠️ Consider - Hard-coded colors (no theme support)
<View style={{ backgroundColor: "#FFFFFF" }} />

4. Handle Loading and Error States

const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

if (loading) return <LoadingScreen />;
if (error) return <ErrorScreen error={error} />;

return <YourCustomUI />;

5. Follow Step Interface Strictly

// Access step data correctly
const { title, subtitle, answers } = step.payload;

// Access custom data
const customData = step.customPayload;

// Use optional fields safely
const buttonLabel = step.continueButtonLabel || "Continue";

When to Use Custom Renderers

✅ Good Use Cases

  • Completely unique screen designs
  • Complex interactions not supported by SDK
  • Integration with specialized libraries (video players, maps, charts)
  • Custom state management requirements
  • Specialized business logic

❌ Consider Alternatives First


Testing Custom Renderers

  1. Implement your renderer with the correct interface
  2. Add conditional in your routing file
  3. Test both paths:
    • Your custom renderer for matching steps
    • SDK renderer for non-matching steps
  4. Verify navigation:
    • onContinue is called correctly
    • Progress tracking still works
    • Navigation flows properly
  5. Test with real data from your CMS

Limitations and Considerations

Manual Theme Integration

Custom renderers don't automatically use theme tokens. You must:

  • Import useTheme() manually
  • Apply theme colors explicitly
  • Handle mode changes if needed

No Automatic Progress

Progress tracking still works, but you're responsible for:

  • Showing/hiding progress yourself if needed
  • Respecting step.displayProgressHeader if desired

Maintenance

Custom renderers require more maintenance:

  • SDK updates won't affect your custom UI
  • You're responsible for accessibility
  • You handle all state management

Combining with Other Levels

You can combine custom renderers with theming and custom components:

<OnboardingProvider
// Level 1: Theme
theme={{ colors: { primary: "#007AFF" } }}

// Level 2: Custom components
customComponents={{ QuestionAnswerButton: AnimatedButton }}
>
<YourApp />
</OnboardingProvider>

// Level 3: Custom renderers
export default function OnboardingScreen() {
const { step } = useOnboardingQuestions({ stepNumber });

// Custom renderer for specific screen
if (step.id === "special-screen") {
return <MyCustomRenderer step={step} onContinue={onContinue} />;
}

// SDK rendering (uses theme + custom components)
return <OnboardingPage step={step} onContinue={onContinue} />;
}

Next Steps