Skip to content
pixelfor Expo
Ctrl K

Checklist

Task List

Checks draw, labels strike through, and completed tasks settle below

Try it on your phone

Task List

Scan to open Task 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

Rare UI

Our Expo component

React Native Web

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

Back to Rare UI