Skip to content
pixelfor Expo
Ctrl K

Checklist

Todo List

Progress states live inside a spring disclosure that closes when finished

Try it on your phone

Todo List

Scan to open Todo List 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

Be UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

todo-list.tsx

import React, { useEffect, useRef, useState } from "react"
import { Platform, Pressable, ScrollView, Text, View } from "react-native"
import Animated, {
  LinearTransition,
  ReduceMotion,
  useAnimatedProps,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withRepeat,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import Svg, { Circle, Path } from "react-native-svg"
const ACircle = Animated.createAnimatedComponent(Circle)
export interface TodoListItem {
  id: string
  title: React.ReactNode
  status: "pending" | "in-progress" | "completed" | "cancelled"
  progress?: number
  detail?: React.ReactNode
}
export interface TodoListProps {
  items: TodoListItem[]
  title?: string
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  collapseOnComplete?: boolean
  maxHeight?: number
  backgroundColor?: string
  textColor?: string
  mutedColor?: string
  accentColor?: string
  testID?: string
}
function Status({ item, color }: { item: TodoListItem; color: string }) {
  const reduced = useReducedMotion(),
    rotation = useSharedValue(0)
  useEffect(() => {
    rotation.value =
      item.status === "in-progress" && item.progress === undefined && !reduced
        ? withRepeat(withTiming(360, { duration: 1000 }), -1)
        : 0
  }, [item.status, item.progress, reduced, rotation])
  const spin = useAnimatedStyle(() => ({
    transform: [{ rotate: `${rotation.value}deg` }],
  }))
  return (
    <Animated.View style={spin}>
      <Svg width={16} height={16} viewBox="0 0 20 20">
        <Circle
          cx={10}
          cy={10}
          r={7}
          stroke={color}
          strokeWidth={1.5}
          opacity={item.status === "in-progress" ? 0.2 : 1}
          strokeDasharray={item.status === "pending" ? "2 3" : undefined}
          fill="none"
        />
        {item.status === "in-progress" && (
          <Circle
            cx={10}
            cy={10}
            r={7}
            stroke={color}
            strokeWidth={1.5}
            strokeLinecap="round"
            strokeDasharray={`${44 * (item.progress === undefined ? 0.68 : Math.min(100, Math.max(0, item.progress)) / 100)} 44`}
            rotation={-90}
            origin="10, 10"
            fill="none"
          />
        )}
        {item.status === "completed" && (
          <Path
            d="m6 10 3 3 5-6"
            stroke={color}
            strokeWidth={1.5}
            fill="none"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        )}
        {item.status === "cancelled" && (
          <Path d="m7 7 6 6m0-6-6 6" stroke={color} strokeWidth={1.5} />
        )}
      </Svg>
    </Animated.View>
  )
}
export function TodoList({
  items,
  title = "To-dos",
  open,
  defaultOpen = true,
  onOpenChange,
  collapseOnComplete = true,
  maxHeight = 248,
  backgroundColor = "transparent",
  textColor = "#f1f1f1",
  mutedColor = "#868686",
  accentColor = "#f1f1f1",
  testID,
}: TodoListProps) {
  const [localOpen, setLocalOpen] = useState(defaultOpen),
    [measured, setMeasured] = useState(0)
  const visible = open ?? localOpen,
    reduced = useReducedMotion(),
    height = useSharedValue(0),
    arrow = useSharedValue(visible ? 180 : 0)
  const scroll = useRef<ScrollView>(null),
    previousLength = useRef(items.length)
  const completed = items.filter((i) => i.status === "completed").length
  const change = (next: boolean) => {
    if (open === undefined) setLocalOpen(next)
    onOpenChange?.(next)
  }
  useEffect(() => {
    if (!collapseOnComplete || !items.length) return
    change(completed !== items.length)
  }, [completed, items.length, collapseOnComplete])
  useEffect(() => {
    const target = visible ? Math.min(measured, maxHeight) : 0
    height.value = reduced
      ? target
      : withSpring(target, { damping: 28, stiffness: 300 })
    arrow.value = reduced
      ? Number(visible) * 180
      : withTiming(Number(visible) * 180, { duration: 180 })
  }, [visible, measured, maxHeight, reduced, height, arrow])
  useEffect(() => {
    if (items.length > previousLength.current && visible)
      requestAnimationFrame(() =>
        scroll.current?.scrollToEnd({ animated: !reduced })
      )
    previousLength.current = items.length
  }, [items.length, visible, reduced])
  const disclosure = useAnimatedStyle(() => ({
    height: height.value,
    opacity: measured
      ? Math.min(1, height.value / Math.min(measured, maxHeight))
      : 0,
  }))
  const caret = useAnimatedStyle(() => ({
    transform: [{ rotate: `${arrow.value}deg` }],
  }))
  return (
    <View
      testID={testID}
      style={{ borderRadius: 16, backgroundColor, borderWidth: 1, borderColor: "rgba(255,255,255,0.035)" }}
    >
      <Pressable
        accessibilityRole="button"
        accessibilityState={{ expanded: visible }}
        onPress={() => change(!visible)}
        style={{
          flexDirection: "row",
          alignItems: "center",
          gap: 10,
          height: 44,
          paddingHorizontal: 14,
        }}
      >
        <View style={{ width: 24, height: 24, alignItems: "center", justifyContent: "center" }}>{completed === items.length && items.length > 0 ? <Svg width={22} height={22} viewBox="0 0 24 24"><Circle cx={12} cy={12} r={10} fill="#10b981" /><Path d="m7 12 3 3 7-7" fill="none" stroke="#fff" strokeWidth={2.25} strokeLinecap="round" strokeLinejoin="round" /></Svg> : <Svg width={16} height={16} viewBox="0 0 24 24"><Path d="M13 5h8M13 12h8M13 19h8M3 5l2 2 4-4M3 12l2 2 4-4M3 19l2 2 4-4" fill="none" stroke={mutedColor} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /></Svg>}</View>
        <Text style={{ flex: 1, fontSize: 14, fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined, fontWeight: "500", color: textColor }}>{title}</Text>
        <Text style={{ color: completed === items.length && items.length > 0 ? "#34d399" : mutedColor, fontSize: 12, fontWeight: "500", fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined }}>
          {completed}/{items.length}
        </Text>
        <Animated.View style={caret}><Svg width={14} height={14} viewBox="0 0 24 24"><Path d="m6 9 6 6 6-6" fill="none" stroke={mutedColor} opacity={0.5} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /></Svg></Animated.View>
      </Pressable>
      <Animated.View
        style={[{ overflow: "hidden" }, disclosure]}
        pointerEvents={visible ? "auto" : "none"}
        accessibilityElementsHidden={!visible}
      >
        <ScrollView
          ref={scroll}
          showsVerticalScrollIndicator={false}
          onContentSizeChange={(_, h) => setMeasured(h)}
          style={{ maxHeight }}
          contentContainerStyle={{ paddingHorizontal: 8, paddingBottom: 8 }}
        >
          {items.map((item) => (
            <Animated.View
              key={item.id}
              layout={LinearTransition.springify().reduceMotion(
                ReduceMotion.System
              )}
              style={{
                flexDirection: "row",
                alignItems: "center",
                gap: 10,
                minHeight: 36,
                paddingHorizontal: 6,
                paddingVertical: 4,
              }}
            >
              <Status
                item={item}
                color={item.status === "in-progress" ? accentColor : mutedColor}
              />
              <View style={{ flex: 1, minWidth: 0 }}>
                <Text
                  numberOfLines={1}
                  style={{
                    color:
                      item.status === "completed" || item.status === "cancelled"
                        ? "rgba(134,134,134,0.60)"
                        : item.status === "pending" ? "rgba(134,134,134,0.65)" : textColor,
                    fontSize: 14,
                    lineHeight: 20,
                    fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined,
                    textDecorationLine:
                      item.status === "completed" ? "line-through" : "none",
                  }}
                >
                  {item.title}
                </Text>
              </View>
              {item.detail && (
                  <Text
                    style={{ fontSize: 14, lineHeight: 20, color: "rgba(134,134,134,0.55)", fontFamily: Platform.OS === "web" ? '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif' : undefined }}
                  >
                    {item.detail}
                  </Text>
                )}
            </Animated.View>
          ))}
        </ScrollView>
      </Animated.View>
    </View>
  )
}

Give the gestures and motion a run on a real Android phone.

Back to Be UI