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…
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
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