Checklist
Todo List
Progress states live inside a spring disclosure that closes when finished
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent 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.