Onboarding
Onboarding Checklist
A spring-open checklist with completion bars and responsive press feedback
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
onboarding-checklist.tsx
import React, { useCallback, useEffect, useState } from "react"
import {
PixelRatio,
Platform,
Pressable,
StyleSheet,
Text,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated"
import Svg, { Path } from "react-native-svg"
import { scheduleOnRN } from "react-native-worklets"
const FONT =
Platform.OS === "web"
? "Arial"
: Platform.OS === "ios"
? "Helvetica"
: "sans-serif"
const MODERN_SHADOW =
Platform.OS !== "android" || Number(Platform.Version) >= 28
const DISCLOSURE = {
stiffness: 300,
damping: 30,
mass: 1,
reduceMotion: ReduceMotion.System,
} as const
const CHEVRON = {
stiffness: 500,
damping: 25,
mass: 1,
reduceMotion: ReduceMotion.System,
} as const
const ROW = {
duration: 150,
easing: Easing.bezier(0.4, 0, 0.2, 1),
reduceMotion: ReduceMotion.System,
} as const
const PROGRESS = { ...ROW, duration: 500 } as const
export type ChecklistStep = { id: number; title: string; isCompleted: boolean }
export type OnboardingChecklistProps = {
steps: ChecklistStep[]
title?: string
defaultExpanded?: boolean
onStepPress?: (step: ChecklistStep) => void
theme?: "light" | "dark" | "system"
backgroundColor?: string
contentColor?: string
titleColor?: string
textColor?: string
subduedColor?: string
borderColor?: string
progressColor?: string
progressTrackColor?: string
completedColor?: string
radius?: number
contentRadius?: number
style?: StyleProp<ViewStyle>
testID?: string
}
function shadow(color: string, large = false): ViewStyle {
if (!MODERN_SHADOW) return { elevation: large ? 4 : 1 }
return {
boxShadow: large
? [
{
offsetX: 0,
offsetY: 10,
blurRadius: 15,
spreadDistance: -3,
color,
},
{ offsetX: 0, offsetY: 4, blurRadius: 6, spreadDistance: -4, color },
]
: [
{ offsetX: 0, offsetY: 1, blurRadius: 3, color },
{ offsetX: 0, offsetY: 1, blurRadius: 2, spreadDistance: -1, color },
],
}
}
function ProgressBar({
filled,
fill,
track,
tablet,
}: {
filled: boolean
fill: string
track: string
tablet: boolean
}) {
const color = useSharedValue(filled ? fill : track)
useEffect(() => {
color.set(withTiming(filled ? fill : track, PROGRESS))
}, [filled, fill, track, color])
const style = useAnimatedStyle(() => ({ backgroundColor: color.get() }))
return (
<Animated.View
style={[
{
width: tablet ? 3.5 : 2.5,
height: tablet ? 16 : 14,
borderRadius: 999,
},
style,
]}
/>
)
}
function ChecklistRow({
step,
tablet,
dark,
textColor,
subduedColor,
completedColor,
radius,
onPress,
testID,
}: {
step: ChecklistStep
tablet: boolean
dark: boolean
textColor: string
subduedColor: string
completedColor: string
radius: number
onPress?: (step: ChecklistStep) => void
testID?: string
}) {
const scale = useSharedValue(1),
background = useSharedValue("rgba(0,0,0,0)")
const text = useSharedValue(step.isCompleted ? subduedColor : textColor)
useEffect(() => {
text.set(withTiming(step.isCompleted ? subduedColor : textColor, ROW))
}, [step.isCompleted, textColor, subduedColor, text])
const style = useAnimatedStyle(() => ({
backgroundColor: background.get(),
transform: [{ scale: scale.get() }],
}))
const textStyle = useAnimatedStyle(() => ({ color: text.get() }))
const active = step.id === 3
const badgeWidth = tablet ? 24 : 20,
badgeHeight = tablet ? 20 : 18
const badgeColor = step.isCompleted
? completedColor
: active
? dark
? "#ededed"
: "#292929"
: "transparent"
const badgeBorder = active
? dark
? "#ededed"
: "#292929"
: dark
? "#333"
: "#e5e5e7"
return (
<Animated.View style={[{ borderRadius: radius }, style]}>
<Pressable
accessibilityRole={onPress ? "button" : undefined}
accessibilityLabel={`${step.title}, ${step.isCompleted ? "completed" : `step ${step.id}`}`}
onPress={() => onPress?.(step)}
onPressIn={() => scale.set(withTiming(tablet ? 1 : 0.98, ROW))}
onPressOut={() => scale.set(withTiming(1, ROW))}
onHoverIn={() =>
background.set(
withTiming(dark ? "rgba(255,255,255,.05)" : "#f9f9f9", ROW)
)
}
onHoverOut={() => background.set(withTiming("rgba(0,0,0,0)", ROW))}
testID={testID}
style={{
paddingVertical: tablet ? 12 : 10,
paddingHorizontal: tablet ? 16 : 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<View
style={{
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: tablet ? 12 : 10,
}}
>
<View
style={[
{
width: badgeWidth,
height: badgeHeight,
borderRadius: 999,
borderWidth: step.isCompleted ? 0 : 2,
borderColor: badgeBorder,
backgroundColor: badgeColor,
alignItems: "center",
justifyContent: "center",
},
shadow(
step.isCompleted
? "#238848"
: active
? "#1a1919"
: "rgba(143,142,142,.4)"
),
]}
>
{step.isCompleted ? (
<Svg
width={tablet ? 12 : 10}
height={tablet ? 12 : 10}
viewBox="0 0 24 24"
accessible={false}
>
<Path
d="m20 6-11 11-5-5"
stroke="#fff"
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
) : (
<Text
style={{
fontFamily: FONT,
fontSize: tablet ? 12 : 10,
fontWeight: "700",
color: active
? dark
? "#1a1a1a"
: "#fff"
: dark
? "#555"
: "#a1a1a1",
}}
>
{step.id}
</Text>
)}
</View>
<Animated.Text
numberOfLines={1}
style={[
{
flexShrink: 1,
fontFamily: FONT,
fontSize: tablet ? 14 : 13,
lineHeight: tablet ? 21 : 19.5,
fontWeight: "500",
},
textStyle,
]}
>
{step.title}
</Animated.Text>
</View>
{!step.isCompleted && (
<Svg
width={tablet ? 16 : 14}
height={tablet ? 16 : 14}
viewBox="0 0 24 24"
accessible={false}
>
<Path
d="m9 18 6-6-6-6"
stroke={dark ? "#444" : "#d1d1d6"}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
)}
</Pressable>
</Animated.View>
)
}
export function OnboardingChecklist({
steps,
title = "Getting started",
defaultExpanded = false,
onStepPress,
theme = "system",
backgroundColor,
contentColor,
titleColor,
textColor,
subduedColor,
borderColor,
progressColor = "#22c55e",
progressTrackColor,
completedColor = "#00b613",
radius = 14,
contentRadius,
style,
testID,
}: OnboardingChecklistProps) {
const system = useColorScheme(),
{ width } = useWindowDimensions()
const dark = theme === "dark" || (theme === "system" && system === "dark")
const tablet = width >= 640
const [expanded, setExpanded] = useState(defaultExpanded),
[naturalHeight, setNaturalHeight] = useState(0)
const [visible, setVisible] = useState(defaultExpanded)
const hide = useCallback(() => setVisible(false), [])
const rotation = useSharedValue(0),
height = useSharedValue(0),
opacity = useSharedValue(defaultExpanded ? 1 : 0)
const completed = steps.filter((step) => step.isCompleted).length
const innerBorder = PixelRatio.roundToNearestPixel(1.4)
useEffect(() => {
rotation.set(withSpring(expanded ? 0 : 180, CHEVRON))
}, [expanded, rotation])
useEffect(() => {
if (expanded) setVisible(true)
height.set(
withSpring(
expanded ? naturalHeight + innerBorder : 0,
DISCLOSURE,
(finished) => {
if (finished && !expanded) scheduleOnRN(hide)
}
)
)
opacity.set(withSpring(expanded ? 1 : 0, DISCLOSURE))
}, [expanded, naturalHeight, innerBorder, height, opacity, hide])
const arrowStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.get()}deg` }],
}))
const bodyStyle = useAnimatedStyle(() => ({
height: Math.max(0, height.get()),
opacity: Math.max(0, Math.min(1, opacity.get())),
}))
const color = textColor ?? (dark ? "#d4d4d4" : "#1a1a1a")
const subdued = subduedColor ?? (dark ? "#555" : "#a1a1a1")
return (
<View
style={[
{ width: "100%", padding: tablet ? 40 : 16, alignItems: "center" },
style,
]}
testID={testID}
>
<View
style={[
{
width: "100%",
maxWidth: 400,
backgroundColor: backgroundColor ?? (dark ? "#161616" : "#f5f5f7"),
borderWidth: 1,
borderColor:
borderColor ?? (dark ? "rgba(255,255,255,.1)" : "#e5e5e5"),
borderRadius: radius,
overflow: "hidden",
},
shadow("rgba(0,0,0,.1)", true),
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${title}, ${completed} of ${steps.length} completed`}
accessibilityState={{ expanded }}
aria-expanded={expanded}
onPress={() => setExpanded((value) => !value)}
testID={testID ? `${testID}-toggle` : undefined}
style={{
padding: tablet ? 14 : 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<View
style={{
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: tablet ? 12 : 8,
}}
>
<Animated.View
style={[
{
width: tablet ? 32 : 28,
height: tablet ? 32 : 28,
alignItems: "center",
justifyContent: "center",
},
arrowStyle,
]}
>
<Svg
width={tablet ? 22 : 20}
height={tablet ? 22 : 20}
viewBox="0 0 24 24"
accessible={false}
>
<Path
d="m18 15-6-6-6 6"
stroke="#a1a1a1"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</Animated.View>
<Text
numberOfLines={1}
style={{
flexShrink: 1,
paddingRight: 4,
fontFamily: FONT,
fontSize: tablet ? 15 : 14,
lineHeight: tablet ? 22.5 : 21,
fontWeight: "700",
color: titleColor ?? (dark ? "#ededed" : "#1a1a1a"),
}}
>
{title}
</Text>
</View>
<View
style={{
marginLeft: 4,
flexDirection: "row",
alignItems: "center",
gap: tablet ? 12 : 8,
}}
>
<View aria-hidden style={{ flexDirection: "row", gap: 2 }}>
{Array.from({ length: 14 }, (_, index) => (
<ProgressBar
key={index}
filled={index < (completed / steps.length) * 14}
fill={progressColor}
track={progressTrackColor ?? (dark ? "#2a2a2a" : "#e5e5e7")}
tablet={tablet}
/>
))}
</View>
<Text
style={{
minWidth: tablet ? 30 : 28,
textAlign: "right",
fontFamily: FONT,
fontSize: tablet ? 13 : 12,
lineHeight: tablet ? 19.5 : 18,
fontWeight: "700",
color: dark ? "#888" : "#71717a",
}}
>
{completed}/{steps.length}
</Text>
</View>
</Pressable>
<Animated.View
pointerEvents={expanded ? "auto" : "none"}
accessibilityElementsHidden={!expanded}
importantForAccessibility={expanded ? "auto" : "no-hide-descendants"}
aria-hidden={!expanded}
style={[
{
borderTopWidth: visible ? innerBorder : 0,
borderColor: dark ? "rgba(255,255,255,.05)" : "#e9e8ef",
borderTopLeftRadius: contentRadius ?? (tablet ? 24 : 26),
borderTopRightRadius: contentRadius ?? (tablet ? 24 : 26),
backgroundColor: contentColor ?? (dark ? "#1c1c1c" : "#fff"),
overflow: "hidden",
},
bodyStyle,
]}
>
<View
style={{ padding: tablet ? 8 : 6, gap: 2 }}
onLayout={({ nativeEvent }) =>
setNaturalHeight(nativeEvent.layout.height)
}
>
{steps.map((step) => (
<ChecklistRow
key={step.id}
step={step}
tablet={tablet}
dark={dark}
textColor={color}
subduedColor={subdued}
completedColor={completedColor}
radius={radius}
onPress={onStepPress}
testID={testID ? `${testID}-step-${step.id}` : undefined}
/>
))}
</View>
</Animated.View>
</View>
</View>
)
}
Give the gestures and motion a run on a real Android phone.