← Back to list

Build a Beautiful Todo App with React Native and Tailwind CSS

NativeWind brings the power of Tailwind’s utility classes to mobile. Here’s how to build a fully functional, polished Todo app from…

Jathurshan Santhirasekaram | C#.NET | JS | SQL ✨ in .NET|C# Hub · 2026-06-08 04:20 · 0 claps · 7.0 min read paywalled
#react-native #mobile-app-development #android-app-development #ios-development #todo-reactnative
Open on Medium ↗
Wiki topics: GEN · Genomics & Sequencing 🌐 · Web Development 📱 · Mobile Development

Build a Beautiful Todo App with React Native and Tailwind CSS

NativeWind brings the power of Tailwind’s utility classes to mobile. Here’s how to build a fully functional, polished Todo app from scratch.

AI Generated Image

AI Generated Image

If you’ve built web apps with Tailwind CSS, you already know how fast it makes styling. Utility classes, no context switching, zero boilerplate CSS files.

Now imagine that same workflow on mobile.

That’s exactly what NativeWind gives you — Tailwind CSS for React Native. The same classes. The same mental model. The same speed. Just on iOS and Android instead of a browser.

In this tutorial, we’ll build a complete Todo app with:

  • Add, complete, and delete tasks
  • Filter by All / Active / Completed
  • Smooth animations
  • Clean, polished UI using only Tailwind utility classes
  • Dark mode support

Let’s build it.

Prerequisites

Make sure you have these installed:

  • Node.js 18+
  • Expo CLI (npm install -g expo-cli)
  • A smartphone with the Expo Go app, or an emulator running

Basic React Native knowledge helps, but we’ll walk through everything.

Step 1: Create the Project

npx create-expo-app TodoApp --template blank
cd TodoApp

Step 2: Install NativeWind and Dependencies

npm install nativewind
npm install --save-dev tailwindcss
npx tailwindcss init

Also install a few helpers we’ll use:

npm install react-native-reanimated react-native-gesture-handler
npm install @expo/vector-icons

Step 3: Configure Tailwind

Open tailwind.config.js and update it:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./App.{js,jsx,ts,tsx}",
    "./src/**/*.{js,jsx,ts,tsx}"
  ],
  theme: {
    extend: {
      colors: {
        primary: {
          50:  '#eff6ff',
          100: '#dbeafe',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        }
      }
    },
  },
  plugins: [],
}

Step 4: Configure Babel

Open babel.config.js and add the NativeWind plugin:

module.exports = function(api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ["nativewind/babel"],
  };
};

Step 5: Set Up TypeScript Types (Optional but Recommended)

Create a file nativewind-env.d.ts in your root:

/// <reference types="nativewind/types" />

This gives you proper TypeScript support for the className prop on React Native components.

Step 6: Build the App

Now the fun part. Replace the contents of App.tsx with the full implementation:

import React, { useState, useCallback } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  FlatList,
  StatusBar,
  KeyboardAvoidingView,
  Platform,
  Pressable,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';

// ─── Types ────────────────────────────────────────────────────────────────────
type FilterType = 'all' | 'active' | 'completed';
interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: Date;
}
// ─── Main App ─────────────────────────────────────────────────────────────────
export default function App() {
  const [todos, setTodos]     = useState<Todo[]>([]);
  const [input, setInput]     = useState('');
  const [filter, setFilter]   = useState<FilterType>('all');
  // Add a new todo
  const addTodo = useCallback(() => {
    const trimmed = input.trim();
    if (!trimmed) return;
    const newTodo: Todo = {
      id:        Date.now().toString(),
      text:      trimmed,
      completed: false,
      createdAt: new Date(),
    };
    setTodos(prev => [newTodo, ...prev]);
    setInput('');
  }, [input]);
  // Toggle completion
  const toggleTodo = useCallback((id: string) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }, []);
  // Delete a todo
  const deleteTodo = useCallback((id: string) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);
  // Clear all completed
  const clearCompleted = useCallback(() => {
    setTodos(prev => prev.filter(todo => !todo.completed));
  }, []);
  // Filter todos
  const filteredTodos = todos.filter(todo => {
    if (filter === 'active')    return !todo.completed;
    if (filter === 'completed') return  todo.completed;
    return true;
  });
  const activeCount    = todos.filter(t => !t.completed).length;
  const completedCount = todos.filter(t =>  t.completed).length;
  return (
    <View className="flex-1 bg-slate-950">
      <StatusBar barStyle="light-content" />
      {/* ── Header ─────────────────────────────────────────── */}
      <View className="px-6 pt-16 pb-8">
        <Text className="text-4xl font-bold text-white tracking-tight">
          My Tasks
        </Text>
        <Text className="text-slate-400 mt-1 text-base">
          {activeCount} remaining · {completedCount} done
        </Text>
      </View>
      {/* ── Input ──────────────────────────────────────────── */}
      <KeyboardAvoidingView
        behavior={Platform.OS === 'ios' ? 'padding' : undefined}
        className="px-6 mb-4"
      >
        <View className="flex-row items-center bg-slate-800 rounded-2xl px-4 py-1 border border-slate-700">
          <Ionicons name="add-circle-outline" size={22} color="#64748b" />
          <TextInput
            className="flex-1 text-white text-base py-3 px-3"
            placeholder="Add a new task..."
            placeholderTextColor="#64748b"
            value={input}
            onChangeText={setInput}
            onSubmitEditing={addTodo}
            returnKeyType="done"
          />
          {input.trim().length > 0 && (
            <TouchableOpacity
              onPress={addTodo}
              className="bg-blue-500 rounded-xl px-4 py-2"
            >
              <Text className="text-white font-semibold text-sm">Add</Text>
            </TouchableOpacity>
          )}
        </View>
      </KeyboardAvoidingView>
      {/* ── Filter Tabs ────────────────────────────────────── */}
      <View className="flex-row px-6 mb-6 gap-2">
        {(['all', 'active', 'completed'] as FilterType[]).map(f => (
          <TouchableOpacity
            key={f}
            onPress={() => setFilter(f)}
            className={`px-4 py-2 rounded-xl ${
              filter === f
                ? 'bg-blue-500'
                : 'bg-slate-800 border border-slate-700'
            }`}
          >
            <Text
              className={`text-sm font-medium capitalize ${
                filter === f ? 'text-white' : 'text-slate-400'
              }`}
            >
              {f}
            </Text>
          </TouchableOpacity>
        ))}
      </View>
      {/* ── Todo List ──────────────────────────────────────── */}
      <FlatList
        data={filteredTodos}
        keyExtractor={item => item.id}
        contentContainerStyle={{ paddingHorizontal: 24, paddingBottom: 40 }}
        ItemSeparatorComponent={() => <View className="h-3" />}
        ListEmptyComponent={<EmptyState filter={filter} />}
        renderItem={({ item }) => (
          <TodoItem
            todo={item}
            onToggle={toggleTodo}
            onDelete={deleteTodo}
          />
        )}
      />
      {/* ── Footer: Clear Completed ─────────────────────────── */}
      {completedCount > 0 && (
        <TouchableOpacity
          onPress={clearCompleted}
          className="mx-6 mb-8 py-3 rounded-2xl border border-slate-700 items-center"
        >
          <Text className="text-slate-400 text-sm font-medium">
            Clear {completedCount} completed {completedCount === 1 ? 'task' : 'tasks'}
          </Text>
        </TouchableOpacity>
      )}
    </View>
  );
}
// ─── Todo Item Component ──────────────────────────────────────────────────────
interface TodoItemProps {
  todo:     Todo;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
}
function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
  return (
    <View
      className={`flex-row items-center bg-slate-800 rounded-2xl px-4 py-4 border ${
        todo.completed ? 'border-slate-700/50' : 'border-slate-700'
      }`}
    >
      {/* Checkbox */}
      <Pressable
        onPress={() => onToggle(todo.id)}
        className={`w-6 h-6 rounded-full border-2 mr-4 items-center justify-center ${
          todo.completed
            ? 'bg-blue-500 border-blue-500'
            : 'border-slate-500'
        }`}
      >
        {todo.completed && (
          <Ionicons name="checkmark" size={14} color="white" />
        )}
      </Pressable>
      {/* Text */}
      <Text
        className={`flex-1 text-base ${
          todo.completed
            ? 'line-through text-slate-500'
            : 'text-white'
        }`}
      >
        {todo.text}
      </Text>
      {/* Delete button */}
      <TouchableOpacity
        onPress={() => onDelete(todo.id)}
        className="ml-3 p-1"
        hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
      >
        <Ionicons
          name="trash-outline"
          size={18}
          color={todo.completed ? '#475569' : '#64748b'}
        />
      </TouchableOpacity>
    </View>
  );
}
// ─── Empty State Component ────────────────────────────────────────────────────
function EmptyState({ filter }: { filter: FilterType }) {
  const messages = {
    all:       { icon: 'checkmark-done-circle-outline', text: 'No tasks yet.\nAdd one above!' },
    active:    { icon: 'trophy-outline',                text: 'All tasks completed!\nYou\'re on fire 🔥'  },
    completed: { icon: 'timer-outline',                 text: 'No completed tasks yet.\nGet to work!'     },
  };
  const { icon, text } = messages[filter];
  return (
    <View className="items-center justify-center py-20">
      <Ionicons name={icon as any} size={56} color="#334155" />
      <Text className="text-slate-500 text-center mt-4 text-base leading-7">
        {text}
      </Text>
    </View>
  );
}

Step 7: Run It

npx expo start

Scan the QR code with Expo Go on your phone, or press i for iOS simulator / a for Android emulator.

You’ll see a dark, polished Todo app with smooth interactions.

Breaking Down the Key Patterns

NativeWind className vs StyleSheet

Traditional React Native:

// Verbose — separate stylesheet, lots of boilerplate
const styles = StyleSheet.create({
  container: {
    backgroundColor: '#1e293b',
    borderRadius: 16,
    padding: 16,
    flexDirection: 'row',
    alignItems: 'center',
  }
});
<View style={styles.container}>

With NativeWind:

// Clean — everything inline, readable at a glance
<View className="bg-slate-800 rounded-2xl p-4 flex-row items-center">

Same result. A fraction of the code.

Conditional Classes

One of the most powerful patterns — switching styles based on state:

<View
  className={`rounded-full border-2 ${
    todo.completed
      ? 'bg-blue-500 border-blue-500'  // completed state
      : 'border-slate-500'              // default state
  }`}
>

No StyleSheet.create gymnastics. Just a ternary in the className string.

The hitSlop Pattern for Touch Targets

Small icons are hard to tap. hitSlop extends the touchable area without changing the visual size:

<TouchableOpacity
  onPress={() => onDelete(todo.id)}
  hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
  <Ionicons name="trash-outline" size={18} />
</TouchableOpacity>

Always use this on small action buttons. Your users’ thumbs will thank you.

KeyboardAvoidingView

On iOS, the keyboard covers your input without this:

<KeyboardAvoidingView
  behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
  <TextInput ... />
</KeyboardAvoidingView>

The behavior differs between iOS and Android — 'padding' on iOS, undefined (or 'height') on Android.

Taking It Further

The foundation is solid. Here’s how to extend it:

Persist Data with AsyncStorage

npm install @react-native-async-storage/async-storage
import AsyncStorage from '@react-native-async-storage/async-storage';

// Save todos whenever they change
useEffect(() => {
  AsyncStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
// Load todos on startup
useEffect(() => {
  AsyncStorage.getItem('todos').then(data => {
    if (data) setTodos(JSON.parse(data));
  });
}, []);

Add Swipe-to-Delete

npm install react-native-swipeable-item

Wrap TodoItem with a swipeable component and add a red delete action on swipe-left — a much more native-feeling interaction than a trash icon.

Drag-to-Reorder

npm install react-native-draggable-flatlist

Replace FlatList with DraggableFlatList and let users reorder tasks by long-pressing and dragging.

Due Dates and Priorities

Extend the Todo type:

interface Todo {
  id:        string;
  text:      string;
  completed: boolean;
  createdAt: Date;
  dueDate?:  Date;           // optional due date
  priority:  'low' | 'medium' | 'high'; // priority level
}

Then color-code the left border of each task based on priority:

<View
  className={`border-l-4 ${
    todo.priority === 'high'   ? 'border-l-red-500'    :
    todo.priority === 'medium' ? 'border-l-yellow-500' :
                                 'border-l-slate-600'
  }`}
>

The Complete File Structure

TodoApp/
├── App.tsx              ← Everything lives here (for simplicity)
├── babel.config.js      ← NativeWind babel plugin
├── tailwind.config.js   ← Tailwind configuration
├── nativewind-env.d.ts  ← TypeScript types for className
├── package.json
└── app.json

For a production app, split into:

TodoApp/
├── src/
│   ├── components/
│   │   ├── TodoItem.tsx
│   │   └── EmptyState.tsx
│   ├── hooks/
│   │   └── useTodos.ts    ← extract all state logic here
│   ├── types/
│   │   └── index.ts
│   └── screens/
│       └── HomeScreen.tsx
└── App.tsx               ← just imports HomeScreen

What You Built

A production-quality Todo app featuring:

  • Full CRUD — add, toggle, delete tasks
  • Filter tabs — All, Active, Completed
  • Clear completed button
  • Empty state per filter
  • Polished dark UI using only Tailwind utility classes
  • Proper keyboard handling
  • Accessible touch targets

And the best part: if you already know Tailwind from web development, you were reading this CSS like a native language. That’s the point of NativeWind — it closes the gap between web and mobile styling so you can move fast on both.

Enjoyed this? Follow for more React Native and mobile development content. Next up: Adding navigation with Expo Router — screens, tabs, and deep links.


메타데이터
post_id
4cdfe95fbb91
slug
build-a-beautiful-todo-app-with-react-native-and-tailwind-css-4cdfe95fbb91
url
https://medium.com/we-are-developers/build-a-beautiful-todo-app-with-react-native-and-tailwind-css-4cdfe95fbb91
canonical_url
https://medium.com/we-are-developers/build-a-beautiful-todo-app-with-react-native-and-tailwind-css-4cdfe95fbb91
author_url
https://medium.com/@code_santa
status
ok
fetched_at
2026-06-09 15:37:30