Build a Movie Swiper App
Build a Tinder-style movie recommender powered by the TMDB API.
Introduction
30 Second Summary
You know how streaming services always seem to recommend the same movies you have already seen? Imagine if you could train your own recommendation engine just by swiping through movies like a dating app.
In this project, you will build a Tinder-style movie swiper app that learns your genre preferences as you swipe right (like) or left (pass) on real movie cards pulled from a live database.
What You'll Build
A React app with swipeable movie cards that fetches real movie data from the TMDB API and serves increasingly personalized recommendations based on your swipes.
By the end of this project, you'll have:
- A swipeable card interface that displays movie posters, titles, ratings, and genres pulled live from TMDB.
- A genre-based recommendation engine that adapts to your preferences in real time as you swipe.
- A liked movies panel that tracks your picks and proves the app responds to your taste.
- Secret Mission: Add swipe direction indicators and an undo button for a polished, forgiving user experience.
Are there any prerequisites?
This project assumes basic React knowledge. You should be comfortable with components, state, props, and useEffect.
No prior experience with APIs or animation libraries is needed.
Before We Start
Before We Start...
Set Up Your Development Environment
Before you can build the movie swiper, you need a local development environment that can run a React app.
In this step, you will install Node.js (the JavaScript runtime), scaffold a new React project using Vite, and install the swipe card library that powers the Tinder-style interactions.
In this step, get ready to:
- Install and verify Node.js on your machine.
- Scaffold a Vite + React project and clean up its default files.
- Install the react-tinder-card library for swipe gestures.
Install Node.js
Node.js is the JavaScript runtime that lets you run React tooling on your computer. Vite (the build tool you will use) requires Node.js version 20.19 or higher.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Check if Node.js is installed by running this command:
node --version
✔️ I see version 20.19 or higher
You are good to go. Also confirm npm is available by running:
npm --version
You should see a version number printed. Move on to the next substep.
ⓧ I see an older version
Your installed Node.js is below the minimum 20.19 required by Vite. You need to upgrade.
- Visit https://nodejs.org and download the LTS installer for your operating system.
- Run the installer and follow the prompts to replace your current version.
- Close and reopen your terminal, then run node --version again to confirm you see 20.19 or higher.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit https://nodejs.org and download the LTS installer for your operating system.
- Run the installer and follow the default prompts.
- Close and reopen your terminal, then run node --version to confirm you see 20.19 or higher.
- Also run npm --version to confirm npm is available.
Scaffold the React project
Vite is a modern build tool that scaffolds a ready-to-run React project in seconds. The command below creates a new folder called movie-swiper with all the React boilerplate already wired up.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the Vite + React project by running this command:
npm create vite@latest movie-swiper -- --template react
- Move into the new project folder and install the base dependencies by running these commands:
cd movie-swiper
npm install
What did these commands do?
The npm create vite@latest command downloaded the Vite scaffolding tool and generated a React project with a development server, hot module reloading, and a production build pipeline.
The npm install command downloaded all the packages listed in the generated package.json into a local node_modules folder.
The scaffolded project comes with demo styles and content that would clash with the movie swiper. Let's clean those up now.
- Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the movie-swiper folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, expand the src folder and click index.css to open it.
- Select all the existing content (Cmd+A on macOS or Ctrl+A on Windows) and replace it with the following:
#root {
overflow: hidden;
}
What does this code do?
The overflow: hidden rule prevents swiped cards from remaining visible after they fly off the edge of the screen. Without it, you would see a horizontal scrollbar appear every time a card leaves.
- Now open src/main.jsx from the Explorer sidebar.
- Select all the existing content and replace it with the following:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
What does this code do?
This is the entry point of your React app. It finds the root element in your HTML file and renders the App component inside it.
We removed the default index.css import that Vite generates because our App.css will handle all styling for the movie swiper.
- Save both files (Cmd+S on macOS or Ctrl+S on Windows).
✔️ Awesome, I've got everything!
Great. Make sure you have saved both src/index.css and src/main.jsx.
ⓧ I'd like to double check the full code
Here is the full content of each file you edited in this substep.
#root {
overflow: hidden;
}
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
VS Code not showing the src folder?
Make sure you opened the movie-swiper folder itself, not its parent (the Desktop). In the Explorer sidebar, you should see src, public, and package.json at the top level.
help me find and open the movie-swiper folder in VS Code
Install the swipe card library
The react-tinder-card library provides a ready-made component that handles drag gestures, release animations, and swipe callbacks. This saves you from writing complex touch and mouse event logic from scratch.
- Open a terminal inside VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Install the swipe card library by running this command:
npm install react-tinder-card@1.6.4
What does this command do?
This installs version 1.6.4 of the react-tinder-card package into your project's node_modules folder. Pinning the version ensures your project behaves consistently regardless of when you run this tutorial.
Peer dependency warning
You may see a warning about react-tinder-card expecting React 18 while your project uses React 19. This is safe to ignore. The library works with React 19 without issues.
- Confirm everything works by starting the development server:
npm run dev
- Open the URL shown in the terminal (usually http://localhost:5173) in your browser.
You should see the default Vite + React welcome page. This confirms your environment is working correctly.
Don't see the welcome page?
Make sure you ran npm install before npm run dev. Without the install step, the dev server cannot start.
Also check that no other process is using port 5173. If the port is taken, Vite will pick the next available port and display it in the terminal output.
help me troubleshoot why npm run dev is not working in my movie-swiper project
Your environment is ready. Next up, you will get a TMDB API key so your app can fetch real movie data.
Get Your TMDB API Key
Your app needs real movie data to display on each card. That means connecting to an external movie database that can provide posters, titles, ratings, and genre information.
The TMDB (The Movie Database) API is free for non-commercial use and provides exactly what you need. In this step, you'll create a TMDB account, grab your API token, and build a helper file that handles all API communication.
In this step, get ready to:
- Create a TMDB account and obtain your API Read Access Token.
- Build an API helper file that centralizes all movie data fetching.
- Test the connection to confirm your token works.
Create a TMDB account and get your API token
TMDB provides two types of credentials: an API Key and an API Read Access Token. Your app uses the Read Access Token because it works as a Bearer token in request headers.
- Open your browser and go to themoviedb.org.
- Click Join TMDB in the top navigation bar.
- Fill in your username, email, and password, then click Sign Up.
- Verify your email if prompted.
- Once logged in, click your profile avatar in the top right corner.
- Select Settings from the dropdown menu.
- Click API in the left sidebar.
- Click the link to request an API key.
- Select Developer when asked what type of use.
- Fill in the required application details (any name and description will do for a personal project) and submit.
After approval, you'll see both your API Key and your API Read Access Token on the same page.
- Copy the value labeled API Read Access Token. This is the longer string. Keep it somewhere safe for the next substep.
API Key vs API Read Access Token
TMDB shows two credentials on the API settings page. The shorter one is the API Key (used as a query parameter). The longer one is the API Read Access Token (used as a Bearer token in headers).
Your app uses the Read Access Token because Bearer token authentication is the preferred method for TMDB's API.
Keep your token private
Never commit your API token to a public Git repository. For this project, you'll paste it directly into a source file. In a production app, you'd use environment variables instead.
Set up the API helper file
All API calls will live in a single file. This keeps your components clean and makes it easy to swap endpoints or add new queries later.
- In VS Code, right-click the src folder in the Explorer sidebar and select New File.
- Name the file api.js and press Enter.
- Add the API configuration at the top of src/api.js by pasting this code:
const API_TOKEN = 'your-tmdb-api-read-access-token-here';
const BASE_URL = 'https://api.themoviedb.org/3';
// Set up headers for all API requests
const headers = {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
};
What does this code do?
API_TOKEN stores your TMDB Read Access Token. Replace the placeholder string with the token you copied from TMDB.
BASE_URL is the root address for all TMDB API v3 endpoints. The headers object attaches your token as a Bearer credential on every request.
- Replace 'your-tmdb-api-read-access-token-here' with your actual API Read Access Token (keep the quotes around it).
You won't see a visible change yet. The next chunk adds the function that fetches movie data using these constants.
- Below the headers object, add the main discovery function by pasting this code:
export async function discoverMovies(genreIds = [], page = 1) {
// Build query parameters for the discover endpoint
const params = new URLSearchParams({
language: 'en-US',
sort_by: 'popularity.desc',
include_adult: 'false',
include_video: 'false',
page: String(page),
'vote_count.gte': '100',
});
// Add genre filter if preferences exist (pipe-separated = OR logic)
if (genreIds.length > 0) {
params.set('with_genres', genreIds.join('|'));
}
const response = await fetch(`${BASE_URL}/discover/movie?${params}`, { headers });
const data = await response.json();
return data.results;
}
What does this code do?
discoverMovies calls TMDB's /discover/movie endpoint with filters for language, popularity sorting, and a minimum vote count of 100 (to filter out obscure titles).
The genreIds parameter lets you filter by genre later. Pipe-separated IDs (|) mean "match ANY of these genres." The page parameter enables pagination so you can request different sets of results.
You won't see a change yet. The next chunk adds two more helper functions.
- Below the discoverMovies function, add the genre list and image helpers by pasting this code:
export async function getGenres() {
// Fetch the full list of movie genre names and IDs
const response = await fetch(`${BASE_URL}/genre/movie/list?language=en`, { headers });
const data = await response.json();
return data.genres;
}
export function getImageUrl(path) {
// Build a full poster image URL using TMDB's w500 size
return `https://image.tmdb.org/t/p/w500${path}`;
}
What does this code do?
getGenres fetches TMDB's complete list of movie genres (Action, Comedy, Drama, etc.) with their numeric IDs. You'll use this to display genre names on each card.
getImageUrl builds a full image URL from the partial poster_path that the API returns. The w500 size gives a good balance of quality and load speed for card-sized posters.
- Save api.js.
Seeing a red underline in VS Code?
If VS Code shows a warning about the template literal in the Authorization header, make sure you used backticks (`) and not regular quotes around Bearer ${API_TOKEN}. Backticks are on the key to the left of the 1 key on most keyboards.
help me fix a syntax error in my api.js file
✔️ Awesome, I've got everything!
Great. Double check you've saved src/api.js before moving on.
ⓧ I'd like to double check the full code
const API_TOKEN = 'your-tmdb-api-read-access-token-here';
const BASE_URL = 'https://api.themoviedb.org/3';
const headers = {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
};
export async function discoverMovies(genreIds = [], page = 1) {
const params = new URLSearchParams({
language: 'en-US',
sort_by: 'popularity.desc',
include_adult: 'false',
include_video: 'false',
page: String(page),
'vote_count.gte': '100',
});
if (genreIds.length > 0) {
params.set('with_genres', genreIds.join('|'));
}
const response = await fetch(`${BASE_URL}/discover/movie?${params}`, { headers });
const data = await response.json();
return data.results;
}
export async function getGenres() {
const response = await fetch(`${BASE_URL}/genre/movie/list?language=en`, { headers });
const data = await response.json();
return data.genres;
}
export function getImageUrl(path) {
return `https://image.tmdb.org/t/p/w500${path}`;
}
Test the connection
Before building the UI, confirm that your token works and TMDB is sending back real movie data.
- Test your API connection by running this command in your terminal (replace the placeholder with your actual token):
curl -s "https://api.themoviedb.org/3/discover/movie?language=en-US&sort_by=popularity.desc&page=1&vote_count.gte=100" -H "Authorization: Bearer your-tmdb-api-read-access-token-here" | head -c 500
What does this command do?
curl sends an HTTP request directly from your terminal. The -s flag silences progress output. The -H flag sets the Authorization header with your Bearer token.
The head -c 500 at the end trims the response to the first 500 characters so your terminal isn't flooded with data.
You should see JSON output containing movie objects with fields like title, vote_average, and poster_path. If you see movie titles in the response, your API connection is working.
Seeing an error or empty results?
If you get a status_code: 7 or "Invalid API key" message, double check that you copied the API Read Access Token (the longer string), not the shorter API Key.
If the command hangs or times out, check your internet connection. If you're on Windows and head isn't recognized, remove the | head -c 500 part and run the curl command on its own.
help me debug my TMDB API connection
Your API helper is ready and confirmed working. Next up, you'll build the movie card UI that displays this data as swipeable cards.
Build the Movie Card UI
Your API helper is wired up and tested. Now it's time to build the visual heart of the app.
In this step, you'll create a movie card component that displays poster images, titles, ratings, and genre tags pulled from the TMDB API. You'll style the cards to look like a phone-sized card stack, then make them swipeable.
In this step, get ready to:
- Create a movie card component that fetches and displays movie data.
- Style the card stack with a gradient overlay for readability.
- Wire up TinderCard to make cards swipeable.
Create the MovieCard component
The card is the core UI element of this app. Each card displays a movie poster as a full-bleed background image, with the title, star rating, release year, and genre tags overlaid at the bottom.
- Open src/App.jsx in VS Code.
- Replace the entire file contents with the following code:
import { useState, useEffect, useRef, useCallback } from 'react';
import { discoverMovies, getGenres, getImageUrl } from './api';
import './App.css';
function App() {
// State for the active movie deck and genre lookup map
const [movies, setMovies] = useState([]);
const [genreMap, setGenreMap] = useState({});
const cardRefs = useRef([]);
// Fetch genre names and initial movies on mount
useEffect(() => {
async function init() {
const genres = await getGenres();
const map = {};
genres.forEach((g) => {
map[g.id] = g.name;
});
setGenreMap(map);
const initialMovies = await discoverMovies([], Math.ceil(Math.random() * 5));
setMovies(initialMovies);
}
init();
}, []);
return (
<div className="app">
<h1 className="app-title">🎬 Movie Swiper</h1>
<p className="app-subtitle">Swipe right to like, left to pass</p>
<div className="card-container">
{movies.map((movie, index) => (
<div
key={movie.id}
className="movie-card"
style={{
backgroundImage: movie.poster_path
? `url(${getImageUrl(movie.poster_path)})`
: 'none',
}}
>
<div className="movie-card-info">
<h2 className="movie-title">{movie.title}</h2>
<div className="movie-meta">
<span className="movie-rating">⭐ {movie.vote_average.toFixed(1)}</span>
<span className="movie-year">
{movie.release_date ? movie.release_date.split('-')[0] : 'N/A'}
</span>
</div>
<div className="movie-genres">
{movie.genre_ids.slice(0, 3).map((id) => (
<span key={id} className="genre-tag">
{genreMap[id] || 'Unknown'}
</span>
))}
</div>
</div>
</div>
))}
</div>
</div>
);
}
export default App;
What does this code do?
- useEffect runs once on mount to fetch genre names and an initial batch of movies from the TMDB Discover endpoint.
- genreMap converts the genre array into an object keyed by ID so you can look up genre names instantly when rendering tags.
- Each card renders the poster as a CSS background image and overlays the title, rating, year, and up to 3 genre tags.
- cardRefs stores references to each card. You'll use these later to trigger programmatic swipes.
- Save src/App.jsx.
- Start your dev server by running:
npm run dev
- Open the URL shown in your terminal (usually http://localhost:5173) in your browser.
- You should see movie titles and poster images stacking on screen. They will be unstyled for now.
Seeing a blank page or errors?
- Check the browser console for errors. If you see a 401 response, your TMDB Bearer token in src/api.js is incorrect or missing.
- Make sure you saved both src/api.js and src/App.jsx. Vite only hot-reloads saved files.
- If you see an import error mentioning ./api, confirm that src/api.js exists and exports discoverMovies, getGenres, and getImageUrl.
Still stuck? Help me debug why my movie cards aren't rendering in App.jsx.
Style the card stack
The cards need to look like a phone-screen-sized stack with a dark gradient at the bottom so the text stays readable over any poster image.
- Open src/App.css and replace its contents with the following page layout and card positioning styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
.app {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
min-height: 100vh;
}
.app-title {
font-size: 2rem;
margin-bottom: 4px;
}
.app-subtitle {
color: #888;
margin-bottom: 20px;
font-size: 0.9rem;
}
.card-container {
position: relative;
width: 350px;
height: 550px;
margin-bottom: 20px;
}
.swipe-card {
position: absolute;
width: 100%;
height: 100%;
}
.movie-card {
width: 350px;
height: 550px;
border-radius: 20px;
background-size: cover;
background-position: center;
background-color: #2a2a3e;
position: relative;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
cursor: grab;
}
What do these styles do?
- The reset (*) removes default browser margins so your layout starts from a clean slate.
- card-container sets a fixed 350x550px area. Cards stack inside it using position: absolute so they overlap like a deck of cards.
- movie-card uses background-size: cover to fill the card with the poster image. The fallback background-color shows if a movie has no poster.
- Now add the card content overlay styles below the existing code in the same file:
.movie-card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 30px 20px 20px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
border-radius: 0 0 20px 20px;
}
.movie-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.movie-meta {
display: flex;
gap: 12px;
margin-bottom: 10px;
font-size: 0.9rem;
}
.movie-rating {
color: #ffd700;
}
.movie-year {
color: #ccc;
}
.movie-genres {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.genre-tag {
background: rgba(255, 255, 255, 0.15);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
color: #ddd;
}
What does the gradient overlay do?
The linear-gradient(transparent, rgba(0, 0, 0, 0.9)) on .movie-card-info creates a fade from clear to near-black at the bottom of the card. This ensures white text stays readable regardless of how light or dark the poster image is underneath.
Genre tags use a semi-transparent white background (rgba(255, 255, 255, 0.15)) to appear as subtle pills without clashing with any poster color.
- Save src/App.css and check your browser.
- You should see a centered card (350px wide, 550px tall) with a movie poster filling the background, a gradient fading to black at the bottom, and the movie title, star rating, year, and genre tags overlaid in white text.
Cards not showing the poster image?
- If you see a plain dark card without a poster, check your browser's Network tab. A 404 on the image URL means the movie's poster_path is null. Scroll through the stack to find a card with an image.
- Make sure the getImageUrl function in src/api.js prepends https://image.tmdb.org/t/p/w500 to the path.
- If the card text is invisible, confirm you pasted both CSS chunks. The gradient overlay in the second chunk is what makes the text readable.
Need help? Why aren't my movie card styles applying correctly?
✔️ Awesome, I've got everything!
Great. Double check you have saved src/App.css with both chunks of styles.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
.app {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
min-height: 100vh;
}
.app-title {
font-size: 2rem;
margin-bottom: 4px;
}
.app-subtitle {
color: #888;
margin-bottom: 20px;
font-size: 0.9rem;
}
.card-container {
position: relative;
width: 350px;
height: 550px;
margin-bottom: 20px;
}
.swipe-card {
position: absolute;
width: 100%;
height: 100%;
}
.movie-card {
width: 350px;
height: 550px;
border-radius: 20px;
background-size: cover;
background-position: center;
background-color: #2a2a3e;
position: relative;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
cursor: grab;
}
.movie-card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 30px 20px 20px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
border-radius: 0 0 20px 20px;
}
.movie-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.movie-meta {
display: flex;
gap: 12px;
margin-bottom: 10px;
font-size: 0.9rem;
}
.movie-rating {
color: #ffd700;
}
.movie-year {
color: #ccc;
}
.movie-genres {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.genre-tag {
background: rgba(255, 255, 255, 0.15);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
color: #ddd;
}
Wire up TinderCard for swipe gestures
The cards look great, but they don't move yet. The react-tinder-card library handles the physics-based drag animation and fires callbacks when a card leaves the screen.
- Replace the contents of src/App.jsx with this updated version that wraps each card in a TinderCard component:
import { useState, useEffect, useRef, useCallback } from 'react';
import TinderCard from 'react-tinder-card';
import { discoverMovies, getGenres, getImageUrl } from './api';
import './App.css';
function App() {
// State for the active movie deck and genre lookup map
const [movies, setMovies] = useState([]);
const [genreMap, setGenreMap] = useState({});
const cardRefs = useRef([]);
// Fetch genre names and initial movies on mount
useEffect(() => {
async function init() {
const genres = await getGenres();
const map = {};
genres.forEach((g) => {
map[g.id] = g.name;
});
setGenreMap(map);
const initialMovies = await discoverMovies([], Math.ceil(Math.random() * 5));
setMovies(initialMovies);
}
init();
}, []);
return (
<div className="app">
<h1 className="app-title">🎬 Movie Swiper</h1>
<p className="app-subtitle">Swipe right to like, left to pass</p>
<div className="card-container">
{movies.map((movie, index) => (
<TinderCard
ref={(el) => { cardRefs.current[index] = el; }}
key={movie.id}
onSwipe={(dir) => console.log(dir, movie.title)}
onCardLeftScreen={() => console.log('left screen', movie.title)}
preventSwipe={['up', 'down']}
className="swipe-card"
>
<div
className="movie-card"
style={{
backgroundImage: movie.poster_path
? `url(${getImageUrl(movie.poster_path)})`
: 'none',
}}
>
<div className="movie-card-info">
<h2 className="movie-title">{movie.title}</h2>
<div className="movie-meta">
<span className="movie-rating">⭐ {movie.vote_average.toFixed(1)}</span>
<span className="movie-year">
{movie.release_date ? movie.release_date.split('-')[0] : 'N/A'}
</span>
</div>
<div className="movie-genres">
{movie.genre_ids.slice(0, 3).map((id) => (
<span key={id} className="genre-tag">
{genreMap[id] || 'Unknown'}
</span>
))}
</div>
</div>
</div>
</TinderCard>
))}
</div>
</div>
);
}
export default App;
What did TinderCard add?
- TinderCard wraps each movie card and handles all the drag physics. You don't need to write any touch/mouse event code.
- onSwipe fires when the user releases the card past the swipe threshold. It receives the direction ('left' or 'right').
- onCardLeftScreen fires after the card animates completely off-screen. You'll use this later to remove the card from state.
- preventSwipe={['up', 'down']} locks movement to horizontal only, matching the Tinder-style left/right pattern.
- Save src/App.jsx and check your browser.
- Click and drag a card left or right. It should follow your cursor with a tilting animation and fly off-screen when you release.
- Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). You should see the swipe direction and movie title logged each time a card leaves.
Cards not swiping?
- Make sure the import TinderCard from 'react-tinder-card' line is present at the top of your file.
- Confirm you ran npm install react-tinder-card@1.6.4 in a previous step. If the module is missing, you'll see an import error in your terminal.
- If cards render but won't drag, check that className="swipe-card" is on the TinderCard component, not on the inner movie-card div.
Still stuck? Help me figure out why react-tinder-card swipe gestures aren't working.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/App.jsx with the TinderCard wrapper in place.
ⓧ I'd like to double check the full code
import { useState, useEffect, useRef, useCallback } from 'react';
import TinderCard from 'react-tinder-card';
import { discoverMovies, getGenres, getImageUrl } from './api';
import './App.css';
function App() {
const [movies, setMovies] = useState([]);
const [genreMap, setGenreMap] = useState({});
const cardRefs = useRef([]);
useEffect(() => {
async function init() {
const genres = await getGenres();
const map = {};
genres.forEach((g) => {
map[g.id] = g.name;
});
setGenreMap(map);
const initialMovies = await discoverMovies([], Math.ceil(Math.random() * 5));
setMovies(initialMovies);
}
init();
}, []);
return (
<div className="app">
<h1 className="app-title">🎬 Movie Swiper</h1>
<p className="app-subtitle">Swipe right to like, left to pass</p>
<div className="card-container">
{movies.map((movie, index) => (
<TinderCard
ref={(el) => { cardRefs.current[index] = el; }}
key={movie.id}
onSwipe={(dir) => console.log(dir, movie.title)}
onCardLeftScreen={() => console.log('left screen', movie.title)}
preventSwipe={['up', 'down']}
className="swipe-card"
>
<div
className="movie-card"
style={{
backgroundImage: movie.poster_path
? `url(${getImageUrl(movie.poster_path)})`
: 'none',
}}
>
<div className="movie-card-info">
<h2 className="movie-title">{movie.title}</h2>
<div className="movie-meta">
<span className="movie-rating">⭐ {movie.vote_average.toFixed(1)}</span>
<span className="movie-year">
{movie.release_date ? movie.release_date.split('-')[0] : 'N/A'}
</span>
</div>
<div className="movie-genres">
{movie.genre_ids.slice(0, 3).map((id) => (
<span key={id} className="genre-tag">
{genreMap[id] || 'Unknown'}
</span>
))}
</div>
</div>
</div>
</TinderCard>
))}
</div>
</div>
);
}
export default App;
Your card UI is alive. You can swipe through movies and see the direction logged in the console. Next up, you'll turn those swipe events into real logic that tracks your preferences and removes cards from the deck.
Implement the Swipe Logic and State
Your movie cards are on screen and looking great. Right now though, swiping them just logs to the console.
In this step, you will make swipes meaningful. Each right-swipe adds a movie to a "liked" list, each card that leaves the screen is removed from the deck, and you will add physical buttons for learners who prefer clicking over dragging.
In this step, get ready to:
- Track which movies you liked with React state.
- Remove swiped cards from the active deck.
- Add Like and Pass buttons that trigger swipes programmatically.
Track liked and passed movies
The app needs to remember which movies you swiped right on. It also needs to remove cards from the deck once they leave the screen so the DOM stays clean.
- In src/App.jsx, find the line const cardRefs = useRef([]); and add the following state line directly below it:
const [likedMovies, setLikedMovies] = useState([]);
- Now add the swipe handler functions. Find the closing }, []); of the useEffect block and add these two functions below it:
const handleSwipe = (direction, movie) => {
if (direction === 'right') {
setLikedMovies((prev) => [...prev, movie]);
}
};
const handleCardLeftScreen = (movie) => {
setMovies((prev) => prev.filter((m) => m.id !== movie.id));
};
What does this code do?
- likedMovies is a state array that stores every movie the user swipes right on.
- handleSwipe checks if the direction is 'right'. If so, it appends the movie to the liked list.
- handleCardLeftScreen filters the swiped movie out of the active deck so it disappears from the DOM entirely.
Remove swiped cards from the stack
The handler functions exist but the TinderCard component is still using console.log. You need to connect your new handlers to the component callbacks.
- Inside the <TinderCard> component in your JSX, find these two lines:
onSwipe={(dir) => console.log(dir, movie.title)}
onCardLeftScreen={() => console.log('left screen', movie.title)}
- Replace both lines with these updated callbacks:
onSwipe={(dir) => handleSwipe(dir, movie)}
onCardLeftScreen={() => handleCardLeftScreen(movie)}
- Next, add a button that shows the liked count. Find the closing </div> tag of the card-container div and add this below it:
<button className="liked-toggle">
❤️ Liked ({likedMovies.length})
</button>
- Save src/App.jsx and refresh your browser.
- Drag a card to the right. You should see the card fly off the screen and not come back. The liked count at the bottom should update from 0 to 1.
- Drag a card to the left. It should disappear without changing the liked count.
Cards not disappearing after swiping?
- Make sure onCardLeftScreen calls handleCardLeftScreen(movie) with the movie argument. Without it, the filter has nothing to match against.
- Check that you replaced both console.log lines. If only one is updated, you will get mixed behavior.
Help me debug why my swiped cards are still visible after leaving the screen.
Add Like and Pass buttons
Not everyone wants to drag cards around. Physical buttons give users a click-based alternative that triggers the same swipe animation programmatically.
- In src/App.jsx, add the swipe function below handleCardLeftScreen by pasting this code:
const swipe = (direction) => {
// Find the top card in the stack (last in the array)
const lastIndex = movies.length - 1;
if (lastIndex >= 0 && cardRefs.current[lastIndex]) {
cardRefs.current[lastIndex].swipe(direction);
}
};
What does this code do?
The swipe function grabs the last card in the movies array (which renders as the top card visually due to stacking order). It then calls the TinderCard library's .swipe() method on that ref, which triggers the same animation as a physical drag.
- Now add the button JSX. Find your liked-toggle button and add this buttons div directly above it (between the card-container closing </div> and the liked-toggle button):
<div className="buttons">
<button className="btn btn-pass" onClick={() => swipe('left')}>
✕ Pass
</button>
<button className="btn btn-like" onClick={() => swipe('right')}>
♥ Like
</button>
</div>
- Now add the styles for these new elements. Open src/App.css and add the following at the end of the file:
.buttons {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.btn {
width: 70px;
height: 70px;
border-radius: 50%;
border: none;
font-size: 1.4rem;
cursor: pointer;
transition: transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
transform: scale(1.1);
}
.btn-pass {
background: #ff4458;
color: white;
font-size: 1.2rem;
}
.btn-like {
background: #4ceb79;
color: white;
font-size: 1.2rem;
}
.liked-toggle {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 10px 20px;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
margin-bottom: 20px;
}
.liked-toggle:hover {
background: rgba(255, 255, 255, 0.2);
}
What do these styles do?
- .btn creates large circular buttons (70px) with a subtle scale-up on hover.
- .btn-pass uses red (#ff4458) and .btn-like uses green (#4ceb79) to give instant visual feedback on intent.
- .liked-toggle is a pill-shaped button with a semi-transparent background that fits the dark theme.
- Save both src/App.jsx and src/App.css.
- Refresh your browser. You should see a red circle button and a green circle button below the card stack, with a "Liked (0)" pill beneath them.
- Click the green button. The top card should animate to the right and the liked count should increment to 1.
- Click the red button. The top card should animate to the left and the liked count should stay the same.
Buttons not triggering a swipe?
- Check that each TinderCard has the ref callback: ref={(el) => { cardRefs.current[index] = el; }}. Without this, cardRefs.current[lastIndex] is undefined.
- If you see the swipe animation but cards stay visible, the ref index might be out of sync. Make sure the swipe function uses movies.length - 1 as the index.
Help me debug why clicking the Like/Pass buttons does nothing.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full src/App.jsx at this point:
import { useState, useEffect, useRef, useCallback } from 'react';
import TinderCard from 'react-tinder-card';
import { discoverMovies, getGenres, getImageUrl } from './api';
import './App.css';
function App() {
const [movies, setMovies] = useState([]);
const [likedMovies, setLikedMovies] = useState([]);
const [genreMap, setGenreMap] = useState({});
const cardRefs = useRef([]);
useEffect(() => {
async function init() {
const genres = await getGenres();
const map = {};
genres.forEach((g) => {
map[g.id] = g.name;
});
setGenreMap(map);
const initialMovies = await discoverMovies([], Math.ceil(Math.random() * 5));
setMovies(initialMovies);
}
init();
}, []);
const handleSwipe = (direction, movie) => {
if (direction === 'right') {
setLikedMovies((prev) => [...prev, movie]);
}
};
const handleCardLeftScreen = (movie) => {
setMovies((prev) => prev.filter((m) => m.id !== movie.id));
};
const swipe = (direction) => {
const lastIndex = movies.length - 1;
if (lastIndex >= 0 && cardRefs.current[lastIndex]) {
cardRefs.current[lastIndex].swipe(direction);
}
};
return (
<div className="app">
<h1 className="app-title">🎬 Movie Swiper</h1>
<p className="app-subtitle">Swipe right to like, left to pass</p>
<div className="card-container">
{movies.map((movie, index) => (
<TinderCard
ref={(el) => { cardRefs.current[index] = el; }}
key={movie.id}
onSwipe={(dir) => handleSwipe(dir, movie)}
onCardLeftScreen={() => handleCardLeftScreen(movie)}
preventSwipe={['up', 'down']}
className="swipe-card"
>
<div
className="movie-card"
style={{
backgroundImage: movie.poster_path
? `url(${getImageUrl(movie.poster_path)})`
: 'none',
}}
>
<div className="movie-card-info">
<h2 className="movie-title">{movie.title}</h2>
<div className="movie-meta">
<span className="movie-rating">⭐ {movie.vote_average.toFixed(1)}</span>
<span className="movie-year">
{movie.release_date ? movie.release_date.split('-')[0] : 'N/A'}
</span>
</div>
<div className="movie-genres">
{movie.genre_ids.slice(0, 3).map((id) => (
<span key={id} className="genre-tag">
{genreMap[id] || 'Unknown'}
</span>
))}
</div>
</div>
</div>
</TinderCard>
))}
</div>
<div className="buttons">
<button className="btn btn-pass" onClick={() => swipe('left')}>
✕ Pass
</button>
<button className="btn btn-like" onClick={() => swipe('right')}>
♥ Like
</button>
</div>
<button className="liked-toggle">
❤️ Liked ({likedMovies.length})
</button>
</div>
);
}
export default App;
And here is the full src/App.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
.app {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
min-height: 100vh;
}
.app-title {
font-size: 2rem;
margin-bottom: 4px;
}
.app-subtitle {
color: #888;
margin-bottom: 20px;
font-size: 0.9rem;
}
.card-container {
position: relative;
width: 350px;
height: 550px;
margin-bottom: 20px;
}
.swipe-card {
position: absolute;
width: 100%;
height: 100%;
}
.movie-card {
width: 350px;
height: 550px;
border-radius: 20px;
background-size: cover;
background-position: center;
background-color: #2a2a3e;
position: relative;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
cursor: grab;
}
.movie-card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 30px 20px 20px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
border-radius: 0 0 20px 20px;
}
.movie-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.movie-meta {
display: flex;
gap: 12px;
margin-bottom: 10px;
font-size: 0.9rem;
}
.movie-rating {
color: #ffd700;
}
.movie-year {
color: #ccc;
}
.movie-genres {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.genre-tag {
background: rgba(255, 255, 255, 0.15);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
color: #ddd;
}
.buttons {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.btn {
width: 70px;
height: 70px;
border-radius: 50%;
border: none;
font-size: 1.4rem;
cursor: pointer;
transition: transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
transform: scale(1.1);
}
.btn-pass {
background: #ff4458;
color: white;
font-size: 1.2rem;
}
.btn-like {
background: #4ceb79;
color: white;
font-size: 1.2rem;
}
.liked-toggle {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 10px 20px;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
margin-bottom: 20px;
}
.liked-toggle:hover {
background: rgba(255, 255, 255, 0.2);
}
Your swipe logic is working and cards disappear after every swipe. Next up, you will build the recommendation engine that uses your likes to fetch better movies.
Build the Recommendation Engine
Your card UI is working and you can swipe through movies. The missing piece is making the app actually learn from your choices.
In this step, you'll build a recommendation engine that tracks which genres you like and uses that data to fetch increasingly personalized movies from the TMDB API.
In this step, get ready to:
- Track genre preferences from each swipe.
- Fetch personalized movies when the deck runs low.
- Show a liked movies panel.
Track genre preferences
Every movie from the TMDB API comes with an array of genre IDs. By counting how often each genre appears in movies you swipe right on, the app builds a preference profile over time.
- In src/App.jsx, find this line near the top of the App function:
const [genreMap, setGenreMap] = useState({});
- Add this new state variable directly below it:
const [genreScores, setGenreScores] = useState({});
What is genreScores?
This object acts as a scorecard. Each key is a genre ID (like 28 for Action), and each value is the number of times you've swiped right on a movie with that genre.
Now you need to update the handleSwipe function so it records genre preferences whenever you swipe right.
- Find the existing handleSwipe function in src/App.jsx:
const handleSwipe = (direction, movie) => {
if (direction === 'right') {
setLikedMovies((prev) => [...prev, movie]);
}
};
- Replace the entire handleSwipe function with this updated version:
const handleSwipe = (direction, movie) => {
if (direction === 'right') {
// Add movie to liked list
setLikedMovies((prev) => [...prev, movie]);
// Increment scores for each of this movie's genres
setGenreScores((prev) => {
const updated = { ...prev };
movie.genre_ids.forEach((id) => {
updated[id] = (updated[id] || 0) + 1;
});
return updated;
});
}
};
What does this code do?
When the user swipes right, the function now does two things. It adds the movie to the likedMovies array (same as before) and it increments the score for every genre that movie belongs to.
For example, if you swipe right on an Action/Sci-Fi movie, both the Action and Sci-Fi genre scores go up by 1. Over time, your top genres emerge from the data.
- Save src/App.jsx.
- Swipe right on a movie in your browser. Open the browser console (F12) and type genreScores to confirm the state is updating. You won't see a visible change yet, but the next substep uses this data.
Seeing an error about setGenreScores?
Make sure the const [genreScores, setGenreScores] = useState({}); line is placed inside the App function, alongside the other state declarations.
help me debug my genreScores state
Fetch personalized movies when the deck runs low
With genre scores tracked, you need two helper functions. One to determine the user's top genres, and another to fetch new movies filtered by those genres.
- In src/App.jsx, add the getTopGenres function directly after the useEffect block (after its closing }, []); line):
const getTopGenres = useCallback((scores) => {
// Sort genres by score and return the top 3 IDs
const sorted = Object.entries(scores)
.filter(([, score]) => score > 0)
.sort((a, b) => b[1] - a[1]);
return sorted.slice(0, 3).map(([id]) => Number(id));
}, []);
What does this code do?
This function takes the genreScores object, filters out genres with zero or negative scores, sorts them highest to lowest, and returns the top 3 genre IDs.
These top genres are what the app passes to the TMDB API to fetch more relevant movies. The useCallback wrapper prevents unnecessary re-creation of this function on every render.
- You won't see a change yet. The next function uses getTopGenres to fetch new movies.
- Add the fetchMore function directly below getTopGenres:
const fetchMore = useCallback(async (scores) => {
// Get the user's top preferred genres
const topGenres = getTopGenres(scores);
// Pick a random page to avoid showing the same movies
const page = Math.ceil(Math.random() * 20);
const newMovies = await discoverMovies(topGenres, page);
setMovies((prev) => {
// Filter out any movies already in the deck
const existingIds = new Set(prev.map((m) => m.id));
const fresh = newMovies.filter((m) => !existingIds.has(m.id));
return [...prev, ...fresh];
});
}, [getTopGenres]);
What does this code do?
When the deck needs refilling, fetchMore calls discoverMovies with the user's top genres. The TMDB API returns movies matching those genres (pipe-separated for OR logic).
The random page number avoids showing the same results every time. The Set of existing IDs ensures duplicate movies don't sneak into the deck.
- You won't see a change yet. The next edit connects fetchMore to the swipe lifecycle.
Now you need to trigger fetchMore when the card deck gets low. The right place is inside handleCardLeftScreen, which fires every time a card exits the screen.
- Find the existing handleCardLeftScreen function:
const handleCardLeftScreen = (movie) => {
setMovies((prev) => prev.filter((m) => m.id !== movie.id));
};
- Replace the entire function with this updated version:
const handleCardLeftScreen = (movie) => {
setMovies((prev) => {
const remaining = prev.filter((m) => m.id !== movie.id);
// Fetch more movies when deck is running low
if (remaining.length < 3) {
fetchMore(genreScores);
}
return remaining;
});
};
What does this code do?
Instead of just filtering the swiped movie out, the function now checks if fewer than 3 cards remain. If so, it calls fetchMore with the current genreScores to load personalized movies before the deck empties completely.
- Save src/App.jsx.
- Refresh your browser and swipe through several movies (right for ones you like). When you get down to the last 2-3 cards, new movies should automatically appear in the stack.
Cards not refilling?
Check that fetchMore is defined above handleCardLeftScreen in your file. JavaScript hoists function declarations but not const arrow functions.
Also confirm your TMDB API token is still valid. Open the browser console (F12) and look for any network errors.
help me debug my fetchMore function
Show the liked movies panel
The liked movies toggle button already shows a count, but clicking it does nothing. You'll wire it up to reveal a panel listing every movie you swiped right on.
- In src/App.jsx, find the genreScores state line you added earlier:
const [genreScores, setGenreScores] = useState({});
- Add this new state variable directly below it:
const [showLiked, setShowLiked] = useState(false);
Next, update the liked-toggle button to actually toggle this state.
- Find this button near the bottom of your JSX:
<button className="liked-toggle">
❤️ Liked ({likedMovies.length})
</button>
- Replace it with this version that includes the onClick handler:
<button className="liked-toggle" onClick={() => setShowLiked(!showLiked)}>
❤️ Liked ({likedMovies.length})
</button>
- Now add the liked movies panel. Paste this block directly below the closing </button> tag of the liked-toggle (and before the final closing </div> of the app):
{showLiked && (
<div className="liked-panel">
<h3>Liked Movies</h3>
<div className="liked-list">
{likedMovies.map((movie) => (
<div key={movie.id} className="liked-item">
{movie.poster_path && (
<img src={getImageUrl(movie.poster_path)} alt={movie.title} />
)}
<span>{movie.title}</span>
</div>
))}
</div>
</div>
)}
What does this code do?
The {showLiked && (...)} pattern is conditional rendering. When showLiked is true, React renders the panel. When false, the panel disappears from the DOM entirely.
The panel maps over likedMovies and renders each one with a small poster thumbnail and the title.
The panel needs CSS to look polished. You'll add styles for the panel container, the list layout, and individual items.
- Add these styles at the bottom of src/App.css (after the existing .liked-toggle:hover rule):
.liked-panel {
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
}
.liked-panel h3 {
margin-bottom: 12px;
font-size: 1.1rem;
}
.liked-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 300px;
overflow-y: auto;
}
.liked-item {
display: flex;
align-items: center;
gap: 12px;
}
.liked-item img {
width: 40px;
height: 60px;
border-radius: 6px;
object-fit: cover;
}
.liked-item span {
font-size: 0.9rem;
}
What do these styles do?
- .liked-panel creates a semi-transparent container with rounded corners, matching the dark theme.
- .liked-list uses a vertical flex layout with a max height and scroll overflow, so a long list doesn't push the page.
- .liked-item img sizes each poster thumbnail to 40x60 pixels with rounded corners.
- Save both src/App.jsx and src/App.css.
- Refresh your browser. Swipe right on a few movies, then click the Liked button. You should see a panel appear below with poster thumbnails and titles for each movie you liked.
Panel not appearing when you click Liked?
Make sure the onClick={() => setShowLiked(!showLiked)} is on the button element, not inside the button text.
Check that the {showLiked && (...)} block is placed after the liked-toggle button's closing tag, inside the main .app div.
help me fix my liked movies panel
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/App.jsx at this point:
import { useState, useEffect, useRef, useCallback } from 'react';
import TinderCard from 'react-tinder-card';
import { discoverMovies, getGenres, getImageUrl } from './api';
import './App.css';
function App() {
const [movies, setMovies] = useState([]);
const [likedMovies, setLikedMovies] = useState([]);
const [genreMap, setGenreMap] = useState({});
const [genreScores, setGenreScores] = useState({});
const [showLiked, setShowLiked] = useState(false);
const cardRefs = useRef([]);
useEffect(() => {
async function init() {
const genres = await getGenres();
const map = {};
genres.forEach((g) => {
map[g.id] = g.name;
});
setGenreMap(map);
const initialMovies = await discoverMovies([], Math.ceil(Math.random() * 5));
setMovies(initialMovies);
}
init();
}, []);
const getTopGenres = useCallback((scores) => {
const sorted = Object.entries(scores)
.filter(([, score]) => score > 0)
.sort((a, b) => b[1] - a[1]);
return sorted.slice(0, 3).map(([id]) => Number(id));
}, []);
const fetchMore = useCallback(async (scores) => {
const topGenres = getTopGenres(scores);
const page = Math.ceil(Math.random() * 20);
const newMovies = await discoverMovies(topGenres, page);
setMovies((prev) => {
const existingIds = new Set(prev.map((m) => m.id));
const fresh = newMovies.filter((m) => !existingIds.has(m.id));
return [...prev, ...fresh];
});
}, [getTopGenres]);
const handleSwipe = (direction, movie) => {
if (direction === 'right') {
setLikedMovies((prev) => [...prev, movie]);
setGenreScores((prev) => {
const updated = { ...prev };
movie.genre_ids.forEach((id) => {
updated[id] = (updated[id] || 0) + 1;
});
return updated;
});
}
};
const handleCardLeftScreen = (movie) => {
setMovies((prev) => {
const remaining = prev.filter((m) => m.id !== movie.id);
if (remaining.length < 3) {
fetchMore(genreScores);
}
return remaining;
});
};
const swipe = (direction) => {
const lastIndex = movies.length - 1;
if (lastIndex >= 0 && cardRefs.current[lastIndex]) {
cardRefs.current[lastIndex].swipe(direction);
}
};
return (
<div className="app">
<h1 className="app-title">🎬 Movie Swiper</h1>
<p className="app-subtitle">Swipe right to like, left to pass</p>
<div className="card-container">
{movies.map((movie, index) => (
<TinderCard
ref={(el) => { cardRefs.current[index] = el; }}
key={movie.id}
onSwipe={(dir) => handleSwipe(dir, movie)}
onCardLeftScreen={() => handleCardLeftScreen(movie)}
preventSwipe={['up', 'down']}
className="swipe-card"
>
<div
className="movie-card"
style={{
backgroundImage: movie.poster_path
? `url(${getImageUrl(movie.poster_path)})`
: 'none',
}}
>
<div className="movie-card-info">
<h2 className="movie-title">{movie.title}</h2>
<div className="movie-meta">
<span className="movie-rating">⭐ {movie.vote_average.toFixed(1)}</span>
<span className="movie-year">
{movie.release_date ? movie.release_date.split('-')[0] : 'N/A'}
</span>
</div>
<div className="movie-genres">
{movie.genre_ids.slice(0, 3).map((id) => (
<span key={id} className="genre-tag">
{genreMap[id] || 'Unknown'}
</span>
))}
</div>
</div>
</div>
</TinderCard>
))}
</div>
<div className="buttons">
<button className="btn btn-pass" onClick={() => swipe('left')}>
✕ Pass
</button>
<button className="btn btn-like" onClick={() => swipe('right')}>
♥ Like
</button>
</div>
<button className="liked-toggle" onClick={() => setShowLiked(!showLiked)}>
❤️ Liked ({likedMovies.length})
</button>
{showLiked && (
<div className="liked-panel">
<h3>Liked Movies</h3>
<div className="liked-list">
{likedMovies.map((movie) => (
<div key={movie.id} className="liked-item">
{movie.poster_path && (
<img src={getImageUrl(movie.poster_path)} alt={movie.title} />
)}
<span>{movie.title}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export default App;
And here is the complete src/App.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
.app {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
min-height: 100vh;
}
.app-title {
font-size: 2rem;
margin-bottom: 4px;
}
.app-subtitle {
color: #888;
margin-bottom: 20px;
font-size: 0.9rem;
}
.card-container {
position: relative;
width: 350px;
height: 550px;
margin-bottom: 20px;
}
.swipe-card {
position: absolute;
width: 100%;
height: 100%;
}
.movie-card {
width: 350px;
height: 550px;
border-radius: 20px;
background-size: cover;
background-position: center;
background-color: #2a2a3e;
position: relative;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
cursor: grab;
}
.movie-card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 30px 20px 20px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
border-radius: 0 0 20px 20px;
}
.movie-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.movie-meta {
display: flex;
gap: 12px;
margin-bottom: 10px;
font-size: 0.9rem;
}
.movie-rating {
color: #ffd700;
}
.movie-year {
color: #ccc;
}
.movie-genres {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.genre-tag {
background: rgba(255, 255, 255, 0.15);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
color: #ddd;
}
.buttons {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.btn {
width: 70px;
height: 70px;
border-radius: 50%;
border: none;
font-size: 1.4rem;
cursor: pointer;
transition: transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
transform: scale(1.1);
}
.btn-pass {
background: #ff4458;
color: white;
font-size: 1.2rem;
}
.btn-like {
background: #4ceb79;
color: white;
font-size: 1.2rem;
}
.liked-toggle {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 10px 20px;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
margin-bottom: 20px;
}
.liked-toggle:hover {
background: rgba(255, 255, 255, 0.2);
}
.liked-panel {
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
}
.liked-panel h3 {
margin-bottom: 12px;
font-size: 1.1rem;
}
.liked-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 300px;
overflow-y: auto;
}
.liked-item {
display: flex;
align-items: center;
gap: 12px;
}
.liked-item img {
width: 40px;
height: 60px;
border-radius: 6px;
object-fit: cover;
}
.liked-item span {
font-size: 0.9rem;
}
Your movie swiper now learns from your taste in real time. Next up, you'll take the app to the next level in the Secret Mission with swipe direction indicators and an undo feature.
Secret mission
Add Swipe Indicators and Undo
Your movie swiper works, but real apps give instant visual feedback. In this secret mission, add LIKE and NOPE overlays that appear as you drag, plus an Undo button to bring back a card you swiped too quickly.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free API, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The movie-swiper project folder on your Desktop.
- Your TMDB API key (managed in your TMDB account settings).
- The Vite development server (running process in your terminal).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep swiping movies.
- Your movie-swiper folder stays on your Desktop with all your code intact.
- Your TMDB API key remains active for future API calls.
- The dev server keeps running in your terminal until you close it.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay on disk for when you return.
- In the terminal where the dev server is running, press Ctrl+C to stop it.
- Your movie-swiper folder and TMDB API key remain ready for next time.
- To resume, open a terminal in the movie-swiper folder and run npm run dev.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Stop the dev server by pressing Ctrl+C in the terminal.
- Delete the project folder on macOS/Linux by running:
rm -rf ~/Desktop/movie-swiper
- On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\movie-swiper
- Delete your TMDB API key by going to your TMDB account, opening Settings, clicking API in the left sidebar, and deleting the key.
Nice Work!
Nice Work!
You just built a Tinder-style movie recommendation app that learns your taste in real time. Every swipe trains the recommendation engine, and every new batch of movies reflects your preferences.
You've learned how to:
- Build a swipeable card interface with physics-based gesture animations using react-tinder-card.
- Integrate the TMDB Discover API to fetch real movie data with posters, ratings, and genres.
- Implement a genre-based recommendation engine that adapts to user preferences with every swipe.
- Added swipe direction indicators and an undo feature for a polished user experience.
Ready to quiz yourself?