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
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
- Just need colors/fonts? → Use Level 1: Theming
- Just need button styles? → Use Level 2: Custom Components
- Want animations? → Try Level 2: Custom Components with custom lists
- Need analytics? → Wrap default components instead
Testing Custom Renderers
- Implement your renderer with the correct interface
- Add conditional in your routing file
- Test both paths:
- Your custom renderer for matching steps
- SDK renderer for non-matching steps
- Verify navigation:
onContinueis called correctly- Progress tracking still works
- Navigation flows properly
- 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.displayProgressHeaderif 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
- 🎨 Theming - Use theme tokens in your custom renderers
- 🔧 Custom Components - Combine with component customization
- 📘 API Reference - Full step type reference
- 🎭 Page Types - Understand all step types