Checklist
Task List
Checks draw, labels strike through, and completed tasks settle below
Expo code
Original web component
Rare UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
task-list.tsx
import React, { useEffect, useRef, useState } from "react"
import { AccessibilityInfo, Pressable, Text, View } from "react-native"
import Animated, {
cancelAnimation,
LinearTransition,
ReduceMotion,
useAnimatedProps,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, { Circle, Path } from "react-native-svg"
const APath = Animated.createAnimatedComponent(Path),
ACircle = Animated.createAnimatedComponent(Circle)
export type TaskSize = "sm" | "md" | "lg"
export interface Task {
id: string
label: string
done?: boolean
}
export interface TaskItemProps {
label: string
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
onSettled?: () => void
onReverted?: () => void
size?: TaskSize
accent?: string
backgroundColor?: string
textColor?: string
}
export function TaskItem({
label,
checked,
defaultChecked = false,
onCheckedChange,
onSettled,
onReverted,
size = "md",
accent = "#FF5F2E",
backgroundColor = "#1f1f1f",
textColor = "#e8e8e8",
}: TaskItemProps) {
const [local, setLocal] = useState(defaultChecked),
done = checked ?? local,
reduced = useReducedMotion()
const tick = useSharedValue(Number(done)),
strike = useSharedValue(Number(done)),
nudge = useSharedValue(0),
initial = useRef(true)
const settled = useRef(onSettled),
reverted = useRef(onReverted)
settled.current = onSettled
reverted.current = onReverted
function finish(next: boolean) {
;(next ? settled.current : reverted.current)?.()
}
useEffect(() => {
if (initial.current) {
initial.current = false
return
}
cancelAnimation(tick)
cancelAnimation(strike)
cancelAnimation(nudge)
if (reduced) {
tick.value = Number(done)
strike.value = Number(done)
nudge.value = 0
finish(done)
return
}
if (done) {
tick.value = withTiming(1, { duration: 240 })
strike.value = withDelay(240, withTiming(1, { duration: 380 }))
nudge.value = withDelay(
620,
withSequence(
withTiming(8, { duration: 100 }),
withTiming(-2, { duration: 90 }),
withTiming(0, { duration: 110 }, (finished) => {
if (finished) scheduleOnRN(finish, true)
})
)
)
} else {
strike.value = withTiming(0, { duration: 280 })
tick.value = withDelay(
280,
withTiming(0, { duration: 200 }, (finished) => {
if (finished) scheduleOnRN(finish, false)
})
)
nudge.value = withSpring(0, { stiffness: 320, damping: 30 })
}
}, [done, reduced, tick, strike, nudge])
const row = useAnimatedStyle(() => ({
transform: [{ translateX: nudge.value }],
}))
const line = useAnimatedStyle(() => ({
transformOrigin: "left center",
transform: [{ scaleX: strike.value }],
opacity: strike.value,
}))
const checkProps = useAnimatedProps(() => ({
strokeDashoffset: 16 * (1 - tick.value),
}))
const circleProps = useAnimatedProps(() => ({
fillOpacity: tick.value,
strokeOpacity: 1 - tick.value,
}))
const fontSize = size === "sm" ? 13 : size === "lg" ? 17 : 15,
diameter = size === "sm" ? 20 : size === "lg" ? 28 : 24
return (
<Animated.View
layout={LinearTransition.springify()
.stiffness(320)
.damping(30)
.reduceMotion(ReduceMotion.System)}
style={row}
>
<Pressable
accessibilityRole="checkbox"
accessibilityLabel={label}
aria-checked={done}
accessibilityState={{ checked: done }}
onPress={() => {
if (checked === undefined) setLocal(!done)
onCheckedChange?.(!done)
}}
style={({ pressed }) => ({
alignSelf: "flex-start",
flexDirection: "row",
gap: 12,
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 14,
backgroundColor,
opacity: pressed ? 0.8 : 1,
})}
>
<Svg width={diameter} height={diameter} viewBox="0 0 24 24">
<ACircle
animatedProps={circleProps}
cx={12}
cy={12}
r={10}
fill={accent}
stroke="#777"
strokeWidth={1}
strokeDasharray="2 3"
/>
<APath
animatedProps={checkProps}
d="m7 12 3 3 7-7"
fill="none"
stroke="#fff"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={16}
/>
</Svg>
<View style={{ flexShrink: 1 }}>
<Text
style={{ color: textColor, fontSize, lineHeight: fontSize + 9 }}
>
{label}
</Text>
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
left: 0,
right: 0,
top: "50%",
height: size === "lg" ? 2.5 : size === "sm" ? 1.5 : 2,
backgroundColor: textColor,
},
line,
]}
/>
</View>
</Pressable>
</Animated.View>
)
}
export interface TaskListProps {
tasks?: Task[]
defaultTasks?: Task[]
onTasksChange?: (tasks: Task[]) => void
size?: TaskSize
accent?: string
backgroundColor?: string
textColor?: string
testID?: string
}
export function TaskList({
tasks,
defaultTasks = [],
onTasksChange,
size = "md",
accent = "#FF5F2E",
backgroundColor = "#1f1f1f",
textColor = "#e8e8e8",
testID,
}: TaskListProps) {
const [local, setLocal] = useState(defaultTasks),
current = tasks ?? local
const [parked, setParked] = useState<Set<string>>(
() => new Set(current.filter((t) => t.done).map((t) => t.id))
)
const ordered = [
...current.filter((t) => !parked.has(t.id)),
...current.filter((t) => parked.has(t.id)),
]
function change(id: string, done: boolean) {
const next = current.map((t) => (t.id === id ? { ...t, done } : t))
if (tasks === undefined) setLocal(next)
onTasksChange?.(next)
AccessibilityInfo.announceForAccessibility(
`${current.find((t) => t.id === id)?.label} ${done ? "completed" : "reopened"}`
)
}
return (
<View
testID={testID}
style={{ alignSelf: "center", gap: size === "sm" ? 6 : size === "lg" ? 10 : 8 }}
>
{ordered.map((task) => (
<TaskItem
key={task.id}
label={task.label}
checked={!!task.done}
onCheckedChange={(done) => change(task.id, done)}
onSettled={() => setParked((p) => new Set([...p, task.id]))}
onReverted={() =>
setParked((p) => {
const next = new Set(p)
next.delete(task.id)
return next
})
}
size={size}
accent={accent}
backgroundColor={backgroundColor}
textColor={textColor}
/>
))}
</View>
)
}
export default TaskList
Give the gestures and motion a run on a real Android phone.