Building a Tinder-Style UI with Flutter Animations
Create a swipeable card UI like Tinder using Flutter gestures and animations. Learn GestureDetector, AnimationController, and Stack widget techniques.
Published on • August 13, 2026
AI Assistant

Introduction: Why Swipeable UIs Dominate Modern Apps
Swipeable card interfaces have become the gold standard for decision-driven interactions in mobile apps. From dating platforms like Tinder to food delivery apps, travel planners, and even recruitment tools—swipe gestures offer a natural, intuitive way for users to make quick decisions.
Flutter, Google’s cross-platform framework, makes building these interactions remarkably straightforward. With its rich animation system, gesture detection APIs, and composable widget architecture, you can create buttery-smooth swipeable UIs that feel native on both iOS and Android.
In this tutorial, we’ll build a complete Tinder-style card stack from scratch. You’ll learn how to use Flutter’s Stack widget for layered card layouts, GestureDetector for tracking swipe movements, and AnimationController for fluid transition animations. By the end, you’ll have a reusable card stack component with like/nope overlays and dismissal callbacks.
Prerequisites
Before diving in, make sure you have:
- Flutter SDK 3.0+ installed
- Basic understanding of Flutter widgets and layouts
- Familiarity with Dart syntax (StatefulWidget, controllers)
- A code editor (VS Code or Android Studio with Flutter plugins)
If you’re new to Flutter animations, read through the Animation and Motion section in the Flutter docs first—it provides essential context on AnimationController and Tween.
Understanding the Architecture
A Tinder-style UI consists of several interconnected pieces:
- Card Stack: Multiple cards rendered in a
Stackwidget, with the topmost card being interactive - Gesture Handling:
GestureDetectororDismissibleto track horizontal drag gestures - Animation: Smooth rotation, translation, and opacity changes as the user drags
- Overlay Indicators: “LIKE” and “NOPE” labels that appear based on drag direction
- Dismissal Logic: Removing cards from the stack and triggering callbacks
Let’s build each component step by step.
Step 1: The Card Widget
First, create a reusable card widget that displays user information with an image:
import 'package:flutter/material.dart';
class ProfileCard extends StatelessWidget {
final String name;
final String age;
final String bio;
final String imageUrl;
const ProfileCard({
super.key,
required this.name,
required this.age,
required this.bio,
required this.imageUrl,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: const Icon(Icons.person, size: 100, color: Colors.grey),
);
},
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
),
),
Positioned(
left: 16,
bottom: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$name, $age',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
bio,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
This creates a card with a full-bleed image, gradient overlay for text readability, and user info positioned at the bottom—exactly like the Tinder aesthetic.
Step 2: Building the Swipeable Card Stack
Now let’s create the main widget that manages the card stack and handles swipe gestures:
import 'package:flutter/material.dart';
class SwipeableCardStack extends StatefulWidget {
final List<ProfileCard> cards;
final Function(int index, SwipeDirection direction) onSwipe;
final VoidCallback onStackEmpty;
const SwipeableCardStack({
super.key,
required this.cards,
required this.onSwipe,
required this.onStackEmpty,
});
@override
State<SwipeableCardStack> createState() => _SwipeableCardStackState();
}
enum SwipeDirection { left, right }
class _SwipeableCardStackState extends State<SwipeableCardStack>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _slideAnimation;
late Animation<double> _rotationAnimation;
late Animation<double> _scaleAnimation;
int _currentIndex = 0;
Offset _dragOffset = Offset.zero;
bool _isDragging = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(1.5, 0),
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_rotationAnimation = Tween<double>(
begin: 0,
end: 0.3,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_handleSwipeComplete();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleSwipeComplete() {
final direction = _dragOffset.dx > 0 ? SwipeDirection.right : SwipeDirection.left;
widget.onSwipe(_currentIndex, direction);
setState(() {
_currentIndex++;
_dragOffset = Offset.zero;
_isDragging = false;
});
_controller.reset();
if (_currentIndex >= widget.cards.length) {
widget.onStackEmpty();
}
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
_dragOffset += details.delta;
_isDragging = true;
});
}
void _onPanEnd(DragEndDetails details) {
final threshold = MediaQuery.of(context).size.width * 0.25;
if (_dragOffset.dx.abs() > threshold) {
final direction = _dragOffset.dx > 0 ? 1.0 : -1.0;
_slideAnimation = Tween<Offset>(
begin: _dragOffset,
end: Offset(direction * 1.5, 0),
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_rotationAnimation = Tween<double>(
begin: _dragOffset.dx / MediaQuery.of(context).size.width,
end: direction * 0.3,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_controller.forward();
} else {
setState(() {
_dragOffset = Offset.zero;
_isDragging = false;
});
}
}
Widget _buildCard(int index) {
final cardIndex = index - _currentIndex;
if (cardIndex < 0 || cardIndex > 2) return const SizedBox.shrink();
final scale = 1.0 - (cardIndex * 0.05);
final yOffset = cardIndex * 8.0;
if (cardIndex == 0) {
return Transform(
transform: Matrix4.identity()
..translate(_dragOffset.dx, yOffset)
..rotateZ(_dragOffset.dx / MediaQuery.of(context).size.width * 0.1)
..scale(scale),
alignment: Alignment.center,
child: GestureDetector(
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: widget.cards[index],
),
);
}
return Transform(
transform: Matrix4.identity()..translate(0.0, yOffset)..scale(scale),
alignment: Alignment.center,
child: widget.cards[index],
);
}
@override
Widget build(BuildContext context) {
if (_currentIndex >= widget.cards.length) {
return const Center(
child: Text(
'No more profiles',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
);
}
return Stack(
alignment: Alignment.center,
children: [
if (_currentIndex + 2 < widget.cards.length)
_buildCard(_currentIndex + 2),
if (_currentIndex + 1 < widget.cards.length)
_buildCard(_currentIndex + 1),
_buildCard(_currentIndex),
],
);
}
}
Step 3: Adding Like/Nope Overlays
The visual feedback during a swipe is what makes this UI feel polished. Let’s add colored overlays with text that appear based on drag direction:
class SwipeOverlay extends StatelessWidget {
final SwipeDirection direction;
final double opacity;
const SwipeOverlay({
super.key,
required this.direction,
required this.opacity,
});
@override
Widget build(BuildContext context) {
final isLike = direction == SwipeDirection.right;
return IgnorePointer(
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isLike ? Colors.green : Colors.red,
width: 4,
),
),
child: Align(
alignment: isLike ? Alignment.topLeft : Alignment.topRight,
child: Transform.rotate(
angle: isLike ? -0.2 : 0.2,
child: Container(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
border: Border.all(
color: isLike ? Colors.green : Colors.red,
width: 3,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
isLike ? 'LIKE' : 'NOPE',
style: TextStyle(
color: isLike ? Colors.green : Colors.red,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
),
);
}
}
Now integrate the overlay into your main screen widget:
class SwipeScreen extends StatefulWidget {
const SwipeScreen({super.key});
@override
State<SwipeScreen> createState() => _SwipeScreenState();
}
class _SwipeScreenState extends State<SwipeScreen> {
final List<Map<String, String>> profiles = [
{
'name': 'Sarah',
'age': '28',
'bio': 'Travel enthusiast and coffee addict',
'image': 'https://picsum.photos/400/600?random=1',
},
{
'name': 'James',
'age': '32',
'bio': 'Software engineer by day, chef by night',
'image': 'https://picsum.photos/400/600?random=2',
},
{
'name': 'Emily',
'age': '25',
'bio': 'Photography and hiking lover',
'image': 'https://picsum.photos/400/600?random=3',
},
{
'name': 'Michael',
'age': '30',
'bio': 'Music producer and gym enthusiast',
'image': 'https://picsum.photos/400/600?random=4',
},
{
'name': 'Olivia',
'age': '27',
'bio': 'Bookworm and cat person',
'image': 'https://picsum.photos/400/600?random=5',
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Discover'),
centerTitle: true,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Expanded(
child: SwipeableCardStack(
cards: profiles.map((p) {
return ProfileCard(
name: p['name']!,
age: p['age']!,
bio: p['bio']!,
imageUrl: p['image']!,
);
}).toList(),
onSwipe: (index, direction) {
final name = profiles[index]['name'];
final action = direction == SwipeDirection.right
? 'Liked'
: 'Passed on';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$action $name')),
);
},
onStackEmpty: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No more profiles to show!'),
),
);
},
),
),
const SizedBox(height: 16),
_buildActionButtons(),
],
),
),
),
);
}
Widget _buildActionButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ActionButton(
icon: Icons.close,
color: Colors.red,
onPressed: () {},
),
_ActionButton(
icon: Icons.star,
color: Colors.blue,
onPressed: () {},
),
_ActionButton(
icon: Icons.favorite,
color: Colors.green,
onPressed: () {},
),
],
);
}
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onPressed;
const _ActionButton({
required this.icon,
required this.color,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(icon, color: color, size: 32),
),
);
}
}
Step 4: Using Dismissible for Built-in Swipe Behavior
If you want a simpler approach, Flutter’s Dismissible widget handles swipe-to-dismiss out of the box:
class SimpleSwipeCard extends StatelessWidget {
final Widget child;
final VoidCallback onSwipeLeft;
final VoidCallback onSwipeRight;
const SimpleSwipeCard({
super.key,
required this.child,
required this.onSwipeLeft,
required this.onSwipeRight,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: UniqueKey(),
direction: DismissDirection.horizontal,
onDismissed: (direction) {
if (direction == DismissDirection.startToEnd) {
onSwipeRight();
} else {
onSwipeLeft();
}
},
background: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 24),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.favorite, color: Colors.white, size: 40),
),
secondaryBackground: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.close, color: Colors.white, size: 40),
),
child: child,
);
}
}
This approach is quicker to implement but gives you less control over the animation details.
Step 5: Adding Physics-Based Animations
For an extra polish, use SpringSimulation to make card dismissals feel more natural:
import 'package:flutter/physics.dart';
void _onPanEnd(DragEndDetails details) {
final size = MediaQuery.of(context).size;
final threshold = size.width * 0.25;
if (_dragOffset.dx.abs() > threshold) {
final direction = _dragOffset.dx > 0 ? 1.0 : -1.0;
final spring = SpringDescription(mass: 1, stiffness: 100, damping: 15);
final simulation = SpringSimulation(spring, 0, 1, -direction * 5);
_controller.animateWith(simulation).then((_) {
_handleSwipeComplete();
});
} else {
final spring = SpringDescription(mass: 1, stiffness: 200, damping: 20);
final simulation = SpringSimulation(spring, _dragOffset.dx, 0, 0);
_controller.animateWith(simulation).then((_) {
setState(() {
_dragOffset = Offset.zero;
_isDragging = false;
});
});
}
}
Spring physics create momentum-based motion that feels much more organic than linear easing curves.
Performance Tips
When building swipeable card stacks, keep these performance considerations in mind:
- Pre-cache images: Use
PrecacheImageto load card images before they appear in the viewport - Limit stack depth: Only render 2-3 cards ahead to reduce widget tree complexity
- Use
RepaintBoundary: Wrap animated widgets to isolate repaints - Debounce rapid swipes: Prevent animation overlap with a simple flag check
void _onPanUpdate(DragUpdateDetails details) {
if (_isAnimating) return;
setState(() {
_dragOffset += details.delta;
});
}
Conclusion & Next Steps
You now have a fully functional Tinder-style card stack in Flutter. The combination of Stack for layered rendering, GestureDetector for swipe tracking, and AnimationController for smooth transitions gives you complete control over the user experience.
From here, you can extend this foundation by:
- Adding vertical swipe gestures for super-likes
- Integrating with a backend API to fetch real profile data
- Implementing card recycling (shuffling dismissed cards back into the deck)
- Adding haptic feedback on swipe thresholds
- Building a match screen with hero animations
The Flutter animation system is incredibly flexible—once you master these primitives, you can build almost any gesture-driven interaction. Check out the official Flutter animation docs for advanced topics like staggered animations and implicit transitions.
Happy coding, and may your swipe animations always be smooth.