Skip to content
pixelfor Expo
Ctrl K

Onboarding

Onboarding Checklist

A spring-open checklist with completion bars and responsive press feedback

Try it on your phone

Onboarding Checklist

Scan to open Onboarding Checklist in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Watermelon UI

Our Expo component

React Native Web

Component 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.

Back to Watermelon UI