GitHub Trending Dashboard - Part 2 of 2
Visualize GitHub Trending data with React, Tailwind CSS, and Recharts.
Introduction
30 Second Summary
Ever tried to make sense of a wall of raw data? Dashboards turn numbers into something visual, interactive, and instantly understandable.
In this project, you will build a React dashboard that visualizes GitHub Trending data stored in a JSON file. You will create metric cards, interactive charts, language filters, and a responsive grid of repo cards.
What You'll Build
A locally-running React dashboard styled with Tailwind CSS that reads your GitHub Trending dataset and renders it as a dark, tactical-style UI with charts, filters, and repo cards.
By the end of this project, you'll have:
- A header bar with a search input and language filter pills that update every section of the dashboard in real time.
- Four glowing metric cards and two Recharts data visualizations (a horizontal bar chart and a donut pie chart) that respond to your filters.
- A responsive repo card grid with colored accent bars, status badges, and direct links to each GitHub repository.
- Secret Mission: Add a sort dropdown and a dark/light theme toggle for extra user control.
Are there any prerequisites?
This is Part 2 of a two-part series. You will need the data.json file from Part 1 (Scrape GitHub Trending Data with Python) and Node.js v20 or higher.
Scaffold the Frontend Project
Your Part 1 Python scraper produced a data.json file full of GitHub Trending repos. Now you need a frontend to turn that raw data into something visual.
You will scaffold a React project with Vite, then install Tailwind CSS for styling and Recharts for data visualization.
In this step, get ready to:
- Scaffold a Vite + React project and install all dependencies.
- Configure Tailwind CSS and clean up unnecessary scaffold files.
- Verify the dev server runs with a minimal test component.
Create the Vite + React project and install dependencies
Vite is a modern build tool that starts a dev server almost instantly and refreshes your browser every time you save a file. You will use it to scaffold a React project inside your existing github-trending-dashboard folder.
- Open your terminal.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
Where is my Part 1 project folder?
In Part 1 (Scrape GitHub Trending Data with Python), you created a folder called github-trending-dashboard on your Desktop. Inside it, you have a backend/ folder containing scraper.py, cleaner.py, and data.json.
You will continue working inside this same folder. Do not create a new root folder. In this step, you will create a frontend/ directory alongside your existing backend/ folder, so your final structure looks like this:
github-trending-dashboard/
├── backend/
│ ├── scraper.py
│ ├── cleaner.py
│ └── data.json
└── frontend/ ← you will create this now
├── src/
├── vite.config.js
└── package.json
- Navigate to your github-trending-dashboard folder on your Desktop by running this command:
cd ~/Desktop/github-trending-dashboard
- Scaffold a new React project inside a frontend folder by running this command:
npm create vite@latest frontend -- --template react
What does this command do?
npm create vite@latest downloads and runs the Vite project scaffolder.
frontend tells it to create a folder called frontend inside your current directory. The --template react flag tells Vite to set up a React project with all the starter files you need.
- Confirm the frontend folder was created by running:
ls
You should see both backend and frontend listed in the output.
Don't see a frontend folder?
Make sure you are inside the github-trending-dashboard folder, not inside backend or another subdirectory.
Also confirm that Node.js is installed by running node --version. You need v20 or higher.
Why isn't my Vite scaffold command working?
- Move into the frontend folder and install the base dependencies by running these commands:
cd frontend
npm install
This installs React, ReactDOM, and the Vite dev server from the scaffold's package.json.
- Install Tailwind CSS, its Vite plugin, and Recharts by running:
npm install tailwindcss @tailwindcss/vite recharts
What are these three packages?
- tailwindcss is the core Tailwind CSS engine that lets you style elements using utility classes directly in your HTML.
- @tailwindcss/vite is the official Vite plugin that connects Tailwind to your build pipeline. No extra PostCSS config needed.
- recharts is a charting library built with React components. You will use it to create bar charts and pie charts.
🙋♀️ Seeing permission or network errors?
If you see EACCES errors on macOS, try prefixing the command with sudo. If you see ETIMEDOUT or ENOTFOUND errors, check your internet connection and try again.
Help me fix my npm install error.
Configure Vite for Tailwind and clean up scaffold files
Tailwind CSS v4 uses a dedicated Vite plugin instead of a separate config file. You need to register this plugin in your Vite config so Tailwind processes your styles at build time.
- Open frontend/vite.config.js in VS Code and replace all of its contents with this updated configuration:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// Register both the Tailwind CSS and React plugins
export default defineConfig({
plugins: [tailwindcss(), react()],
})
What does this config do?
- tailwindcss() tells Vite to process Tailwind utility classes in your CSS files.
- react() enables JSX transformation and React Fast Refresh so the browser updates instantly when you save a file.
- Save vite.config.js.
✔️ Awesome, I've got everything!
Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss(), react()],
})
The Vite scaffold also created some starter files you will not need. Time to clean those up.
- Delete the scaffold CSS file and assets folder by running these commands from inside the frontend folder:
macOS
rm src/App.css
rm -rf src/assets
Windows
del src\App.css
rmdir /s /q src\assets
- Confirm the files are gone by running:
ls src/
You should see App.jsx, index.css, and main.jsx. No App.css or assets folder.
Still see App.css or assets?
Make sure you are running the delete commands from inside the frontend folder, not the project root. Run pwd (macOS) or cd (Windows) to check your current directory.
Help me delete the Vite scaffold files.
Set up CSS, data file, and verify the dev server
Tailwind CSS v4 uses a single CSS import instead of the old directive-based setup. You also need to bring your Part 1 data into the frontend so React can read it.
- Open frontend/src/index.css and replace all of its contents with:
@import "tailwindcss";
Why just one line?
Tailwind CSS v4 replaced the old @tailwind base directives with a single import. This one line loads the entire Tailwind utility system so you can use classes like bg-gray-950 and text-cyan-400 directly in your JSX.
- Save index.css.
Next, copy the data.json file from your Part 1 backend into the frontend's src folder. Vite can natively import JSON files, so placing it here lets React read the data directly.
- Copy data.json into the frontend by running this command from the frontend folder:
macOS
cp ../backend/data.json src/data.json
Windows
copy ..\backend\data.json src\data.json
- Confirm the file was copied by running:
ls src/
You should see data.json listed alongside App.jsx, index.css, and main.jsx.
No data.json from Part 1?
If you do not have a backend/data.json from Part 1, create frontend/src/data.json manually and paste in the sample data provided in the project resources.
Now replace the scaffold's starter component with a minimal test to verify that Vite, React, and Tailwind are all connected.
- Open frontend/src/App.jsx and replace all of its contents with this test component:
import data from './data.json'
function App() {
return (
// Dark background with white text to test Tailwind utility classes
<div className="min-h-screen bg-gray-950 text-white p-8">
<h1 className="text-2xl font-bold text-cyan-400">
Dashboard loaded: {data.length} repos
</h1>
</div>
)
}
export default App
What does this code do?
- import data from './data.json' loads your scraped repo data as a JavaScript array. Vite handles JSON imports natively.
- The className attributes use Tailwind utility classes. bg-gray-950 sets a near-black background. text-cyan-400 makes the heading cyan.
- {data.length} renders the number of repos in your JSON file. If you see a number here, the data import is working.
- Save App.jsx.
✔️ Awesome, I've got everything!
Make sure you have saved the file before starting the dev server.
ⓧ I'd like to double check the full code
import data from './data.json'
function App() {
return (
<div className="min-h-screen bg-gray-950 text-white p-8">
<h1 className="text-2xl font-bold text-cyan-400">
Dashboard loaded: {data.length} repos
</h1>
</div>
)
}
export default App
- Go back to your terminal and make sure you are still inside the frontend folder. If you are not sure, run pwd (macOS) or cd (Windows) to check. The output should look something like this:
~/Desktop/github-trending-dashboard/frontend
- If your path matches, start the Vite dev server by running this command:
npm run dev
- Open http://localhost:5173 in your browser.
You should see white text on a near-black background that reads "Dashboard loaded: X repos" in cyan, where X matches the number of repos in your data.json. If you see this, Vite, React, and Tailwind are all working.
Seeing a blank page or an error?
- If the page is blank, open your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for errors mentioning missing files like App.css. If you see one, check that main.jsx does not import App.css (it should not by default).
- If the text appears but is not styled (no dark background, no cyan), your Tailwind setup is not connected. Double check that vite.config.js includes tailwindcss() in the plugins array and that index.css contains @import "tailwindcss";.
- If the repo count shows 0 or the page throws a JSON error, make sure src/data.json exists and contains valid JSON. Open it in VS Code and check for trailing commas or missing brackets.
My Vite dev server is running but the dashboard test page doesn't look right.
💡 How to restart your dev server
Throughout this project, you need the Vite dev server running to see your changes. If your terminal closes, your computer sleeps, or the server crashes, here is how to get it back:
- Open your terminal and navigate back to the frontend folder by running:
- Press Ctrl+C to stop the old server process (if one is still running).
- Run npm run dev to start the server again.
- Open http://localhost:5173 in your browser (or refresh if it is already open).
cd ~/Desktop/github-trending-dashboard/frontend
npm run dev
Your frontend project is ready to go. Next up, you will build the header bar, language filter pills, and glowing metric cards.
Build the Header, Filters, and Metric Cards
Your React project is scaffolded and the dev server is running. Right now the page just says "Dashboard loaded: X repos" on a dark background.
In this step, you will replace that placeholder with the top half of the dashboard: a dark header bar with a pulsing status dot, a search input, language filter pills, and four glowing metric cards. Every section will react instantly to filter clicks and search input using React hooks.
In this step, get ready to:
- Add imports, color constants, and a number-formatting helper function.
- Build the App function with state hooks, computed values, header bar, and filter pills.
- Create four glowing metric cards that update when filters change.
Add imports, constants, and state hooks
Before writing any visible UI, you need the building blocks: React hooks for managing state, Recharts components for charting (used later), color mappings for each programming language, and a helper that turns raw numbers like 42000 into readable strings like "42K".
- Open frontend/src/App.jsx and replace everything with this foundation code:
import { useState, useMemo } from 'react'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell
} from 'recharts'
import data from './data.json'
// Maps each programming language to a hex color used across the dashboard
const LANGUAGE_COLORS = {
Python: '#06b6d4',
TypeScript: '#f59e0b',
Rust: '#22c55e',
Go: '#14b8a6',
JavaScript: '#f97316',
'C++': '#ef4444',
Java: '#a855f7',
Unknown: '#64748b',
}
// Tailwind class strings for the HOT, NEW, and RISING status badges
const STATUS_STYLES = {
HOT: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
NEW: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
RISING: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
}
// Language options shown as filter pills
const FILTER_LANGUAGES = ['All', 'Python', 'TypeScript', 'Rust', 'Go', 'JavaScript', 'C++', 'Java']
// Converts raw numbers into compact display strings (e.g. 42000 -> "42K")
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) {
const formatted = (num / 1000).toFixed(1) + 'K'
return formatted.replace('.0K', 'K')
}
return String(num)
}
// ===== B-START: Paste the App function =====
What does this code do?
useState and useMemo are React hooks that manage interactive state and computed values. The Recharts imports (BarChart, Pie, Cell, etc.) are for the charts you will build in the next step.
LANGUAGE_COLORS maps each language to a hex color used for chart bars, pill badges, and card accents. STATUS_STYLES defines Tailwind CSS class strings for the HOT, NEW, and RISING badges. formatNumber turns raw numbers into compact display strings so a card can show "42K" instead of "42000".
- Save App.jsx.
The browser will show an error because the App function does not exist yet. That is expected. You will add it in the next substep.
Build the App function with state, computed values, header, and filter pills
The App function is the heart of your dashboard. It holds two pieces of React state: what the user typed in the search box, and which language filter pill is active. Every other value on the page (filtered repos, top language, average stars, etc.) is computed from those two inputs using useMemo.
- Add the App function by pasting the following code at the end of App.jsx:
// ===== B-START: Paste the App function =====
function App() {
// Reactive state: search text and active language filter
const [search, setSearch] = useState('')
const [activeFilter, setActiveFilter] = useState('All')
// Filter the full dataset by language pill and search text
const filteredData = useMemo(() => {
let result = data
if (activeFilter !== 'All') {
result = result.filter(repo => repo.language === activeFilter)
}
if (search.trim()) {
const q = search.toLowerCase()
result = result.filter(repo =>
repo.name.toLowerCase().includes(q) ||
repo.description.toLowerCase().includes(q) ||
repo.language.toLowerCase().includes(q)
)
}
return result
}, [search, activeFilter])
const totalRepos = filteredData.length
// Find the most common language in the filtered results
const topLanguage = useMemo(() => {
const counts = {}
filteredData.forEach(r => {
if (r.language) counts[r.language] = (counts[r.language] || 0) + 1
})
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
return sorted.length > 0 ? sorted[0] : ['N/A', 0]
}, [filteredData])
// Calculate the average star count across filtered repos
const avgStars = useMemo(() => {
if (filteredData.length === 0) return '0'
const avg = filteredData.reduce((sum, r) => sum + r.stars, 0) / filteredData.length
return formatNumber(Math.round(avg))
}, [filteredData])
// Sum all forks across filtered repos
const totalForks = useMemo(() => {
return formatNumber(filteredData.reduce((sum, r) => sum + r.forks, 0))
}, [filteredData])
const topLangPercent = totalRepos > 0 ? Math.round((topLanguage[1] / totalRepos) * 100) : 0
// Count repos per language for the bar chart (top 6)
const languageChartData = useMemo(() => {
const counts = {}
filteredData.forEach(r => {
if (r.language) counts[r.language] = (counts[r.language] || 0) + 1
})
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([name, value]) => ({ name, value }))
}, [filteredData])
// Add percentage values for the pie chart
const pieData = useMemo(() => {
const total = filteredData.length
return languageChartData.map(item => ({
...item,
percent: total > 0 ? Math.round((item.value / total) * 100) : 0,
}))
}, [filteredData, languageChartData])
return (
<div className="min-h-screen bg-[#0a0e17] text-white font-mono p-4 md:p-6">
{/* Header */}
<header className="flex flex-wrap items-center justify-between gap-4 mb-6 bg-[#0d1117] border border-[#1e293b] rounded-lg px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-emerald-400 rounded-full animate-pulse" />
<h1 className="text-lg md:text-xl font-bold tracking-wider text-cyan-400">
GITHUB TRENDING DASHBOARD
</h1>
</div>
<div className="flex-1 max-w-md mx-4">
<input
type="text"
placeholder="Search repos, languages..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#1a1f2e] border border-[#2d3748] rounded-lg px-4 py-2 text-sm text-gray-300 placeholder-gray-500 focus:outline-none focus:border-cyan-500 transition-colors"
/>
</div>
<div className="text-right text-xs">
<div className="text-gray-400 tracking-wider">UPDATED: 2 MIN AGO</div>
<div className="text-emerald-400 font-bold tracking-wider">
LIVE • {data.length} REPOS SCRAPED
</div>
</div>
</header>
{/* Filter Pills */}
<div className="flex flex-wrap gap-2 mb-6">
{FILTER_LANGUAGES.map(lang => (
<button
key={lang}
onClick={() => setActiveFilter(lang)}
className={`px-5 py-2 rounded-lg text-sm font-medium tracking-wide transition-all duration-200 ${
activeFilter === lang
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500 shadow-[0_0_10px_rgba(6,182,212,0.3)]'
: 'bg-[#0d1117] text-gray-400 border border-[#1e293b] hover:border-gray-500'
}`}
>
{lang}
</button>
))}
</div>
{/* ===== C-START: Paste the metric cards below this line ===== */}
</div>
)
}
// ===== B-END: Paste the App function =====
export default App
// ===== C-END: Paste the metric cards =====
What does this code do?
useState('') creates a reactive search variable. Every time the user types in the search input, React re-renders the component with the new value. useMemo creates computed values that only recalculate when their dependencies change.
filteredData filters the full dataset by both the active language pill and the search text. The topLanguage, avgStars, totalForks, languageChartData, and pieData values all derive from filteredData, so they automatically update when the user interacts with filters or search.
The header uses flex flex-wrap so it stacks gracefully on small screens. The animate-pulse class creates the pulsing green dot. Each filter pill checks whether it matches activeFilter and applies either the glowing cyan style or the default dark style.
The {/* */} syntax is how you write comments inside JSX (the HTML-like code inside a return statement). Regular // comments do not work there because React would try to render them as text. The C-START and C-END markers show the exact zone where the next chunk of code belongs. In the next substep, you will select from the C-START line all the way to the end of the file and replace your entire selection with the new code.
- Save App.jsx.
- Check your browser at http://localhost:5173. You should see the dark header with a pulsing green dot, the title "GITHUB TRENDING DASHBOARD", a search bar, the LIVE badge, and a row of eight language filter pills.
- Click a filter pill. It should highlight in cyan with a subtle glow.
Don't see the header or filter pills?
- Make sure you pasted the App function at the end of the file, directly below the formatNumber function. It should not be inside formatNumber.
- Check that export default App appears at the very bottom of the file. Without it, React cannot find your component.
- If you see a blank white page, open your browser's DevTools console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and look for a red error message pointing to the broken line.
- If your dev server stopped, press Ctrl+C in the terminal, run npm run dev again, and refresh your browser. See the "How to restart your dev server" tip in Step 1 for full instructions.
Help me debug my header and filter pills
Build the four metric cards
The metric cards give users an instant snapshot of the filtered data: total repos, top language, average stars, and total forks. Each card is its own reusable MetricCard component that receives its data as props.
- In App.jsx, find the comment that says C-START: Paste the metric cards below this line.
- Select everything from that comment to the end of the file.
- Replace your selection with this code:
{/* ===== C-START: Paste the metric cards below this line ===== */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard
label="TOTAL REPOS"
value={totalRepos}
sub1={`+${Math.min(totalRepos, 12)} today`}
sub2="↑ 8% vs yesterday"
/>
<MetricCard
label="TOP LANGUAGE"
value={topLanguage[0]}
sub1={`${topLangPercent}% of repos`}
sub2="Trending ↑"
/>
<MetricCard
label="AVG STARS"
value={avgStars}
sub1="↑ 18% vs yesterday"
sub2="All-time high"
/>
<MetricCard
label="TOTAL FORKS"
value={totalForks}
sub1={`+340 this week`}
sub2="Contributors"
/>
</div>
{/* ===== C-END: Paste the metric cards ===== */}
{/* ===== D-START: Paste the charts below this line ===== */}
</div>
)
}
// Renders a single metric card with label, value, and two subtext lines
function MetricCard({ label, value, sub1, sub2 }) {
return (
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-5 hover:border-cyan-500/30 transition-colors shadow-[0_0_15px_rgba(6,182,212,0.05)]">
<div className="text-[10px] text-gray-500 tracking-widest mb-2">{label}</div>
<div className="text-3xl font-bold text-cyan-400 mb-3">{value}</div>
<div className="flex justify-between text-xs text-gray-500">
<span className="text-emerald-400" dangerouslySetInnerHTML={{ __html: sub1 }} />
<span dangerouslySetInnerHTML={{ __html: sub2 }} />
</div>
</div>
)
}
export default App
// ===== D-END: Paste the charts =====
What does this code do?
Each MetricCard uses props destructuring ({ label, value, sub1, sub2 }) to receive its data. The card uses shadow-[0_0_15px_rgba(6,182,212,0.05)] to create the subtle cyan glow effect.
hover:border-cyan-500/30 makes the border brighten when the user hovers. The dangerouslySetInnerHTML prop renders HTML entities like ↑ (an up arrow) correctly instead of displaying them as raw text.
- Save App.jsx and check your browser.
- You should see four dark cards in a row below the filter pills, each with a small label, a large cyan number, and two subtext values.
- Click a language filter pill. The card values should update immediately to reflect the filtered data.
Cards not showing or values not updating?
- If you see "MetricCard is not defined", scroll to the bottom of App.jsx. The MetricCard function should appear below the closing } of the App function, not inside it.
- If the card values do not change when you click a filter pill, check that the metric cards grid is inside the App function's return statement, not outside it.
- Make sure you selected from the C-START comment all the way to the end of the file before pasting. If the old closing tags are still there, you may have duplicate </div> tags.
Help me debug my metric cards
✔️ Awesome, I've got everything!
Double check you have saved App.jsx. Your header, filter pills, and four metric cards should all be visible and interactive.
ⓧ I'd like to double check the full code
Here is the complete App.jsx file at the end of this step. Compare it against yours to spot any differences.
import { useState, useMemo } from 'react'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell
} from 'recharts'
import data from './data.json'
const LANGUAGE_COLORS = {
Python: '#06b6d4',
TypeScript: '#f59e0b',
Rust: '#22c55e',
Go: '#14b8a6',
JavaScript: '#f97316',
'C++': '#ef4444',
Java: '#a855f7',
Unknown: '#64748b',
}
const STATUS_STYLES = {
HOT: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
NEW: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
RISING: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
}
const FILTER_LANGUAGES = ['All', 'Python', 'TypeScript', 'Rust', 'Go', 'JavaScript', 'C++', 'Java']
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) {
const formatted = (num / 1000).toFixed(1) + 'K'
return formatted.replace('.0K', 'K')
}
return String(num)
}
function App() {
const [search, setSearch] = useState('')
const [activeFilter, setActiveFilter] = useState('All')
const filteredData = useMemo(() => {
let result = data
if (activeFilter !== 'All') {
result = result.filter(repo => repo.language === activeFilter)
}
if (search.trim()) {
const q = search.toLowerCase()
result = result.filter(repo =>
repo.name.toLowerCase().includes(q) ||
repo.description.toLowerCase().includes(q) ||
repo.language.toLowerCase().includes(q)
)
}
return result
}, [search, activeFilter])
const totalRepos = filteredData.length
const topLanguage = useMemo(() => {
const counts = {}
filteredData.forEach(r => {
if (r.language) counts[r.language] = (counts[r.language] || 0) + 1
})
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
return sorted.length > 0 ? sorted[0] : ['N/A', 0]
}, [filteredData])
const avgStars = useMemo(() => {
if (filteredData.length === 0) return '0'
const avg = filteredData.reduce((sum, r) => sum + r.stars, 0) / filteredData.length
return formatNumber(Math.round(avg))
}, [filteredData])
const totalForks = useMemo(() => {
return formatNumber(filteredData.reduce((sum, r) => sum + r.forks, 0))
}, [filteredData])
const topLangPercent = totalRepos > 0 ? Math.round((topLanguage[1] / totalRepos) * 100) : 0
const languageChartData = useMemo(() => {
const counts = {}
filteredData.forEach(r => {
if (r.language) counts[r.language] = (counts[r.language] || 0) + 1
})
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([name, value]) => ({ name, value }))
}, [filteredData])
const pieData = useMemo(() => {
const total = filteredData.length
return languageChartData.map(item => ({
...item,
percent: total > 0 ? Math.round((item.value / total) * 100) : 0,
}))
}, [filteredData, languageChartData])
return (
<div className="min-h-screen bg-[#0a0e17] text-white font-mono p-4 md:p-6">
{/* Header */}
<header className="flex flex-wrap items-center justify-between gap-4 mb-6 bg-[#0d1117] border border-[#1e293b] rounded-lg px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-emerald-400 rounded-full animate-pulse" />
<h1 className="text-lg md:text-xl font-bold tracking-wider text-cyan-400">
GITHUB TRENDING DASHBOARD
</h1>
</div>
<div className="flex-1 max-w-md mx-4">
<input
type="text"
placeholder="Search repos, languages..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#1a1f2e] border border-[#2d3748] rounded-lg px-4 py-2 text-sm text-gray-300 placeholder-gray-500 focus:outline-none focus:border-cyan-500 transition-colors"
/>
</div>
<div className="text-right text-xs">
<div className="text-gray-400 tracking-wider">UPDATED: 2 MIN AGO</div>
<div className="text-emerald-400 font-bold tracking-wider">
LIVE • {data.length} REPOS SCRAPED
</div>
</div>
</header>
{/* Filter Pills */}
<div className="flex flex-wrap gap-2 mb-6">
{FILTER_LANGUAGES.map(lang => (
<button
key={lang}
onClick={() => setActiveFilter(lang)}
className={`px-5 py-2 rounded-lg text-sm font-medium tracking-wide transition-all duration-200 ${
activeFilter === lang
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500 shadow-[0_0_10px_rgba(6,182,212,0.3)]'
: 'bg-[#0d1117] text-gray-400 border border-[#1e293b] hover:border-gray-500'
}`}
>
{lang}
</button>
))}
</div>
{/* Metric Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard label="TOTAL REPOS" value={totalRepos} sub1={`+${Math.min(totalRepos, 12)} today`} sub2="↑ 8% vs yesterday" />
<MetricCard label="TOP LANGUAGE" value={topLanguage[0]} sub1={`${topLangPercent}% of repos`} sub2="Trending ↑" />
<MetricCard label="AVG STARS" value={avgStars} sub1="↑ 18% vs yesterday" sub2="All-time high" />
<MetricCard label="TOTAL FORKS" value={totalForks} sub1={`+340 this week`} sub2="Contributors" />
</div>
</div>
)
}
function MetricCard({ label, value, sub1, sub2 }) {
return (
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-5 hover:border-cyan-500/30 transition-colors shadow-[0_0_15px_rgba(6,182,212,0.05)]">
<div className="text-[10px] text-gray-500 tracking-widest mb-2">{label}</div>
<div className="text-3xl font-bold text-cyan-400 mb-3">{value}</div>
<div className="flex justify-between text-xs text-gray-500">
<span className="text-emerald-400" dangerouslySetInnerHTML={{ __html: sub1 }} />
<span dangerouslySetInnerHTML={{ __html: sub2 }} />
</div>
</div>
)
}
export default App
Your header, filters, and metric cards are all wired up and responding to clicks. Next up, you will use those Recharts imports to build a horizontal bar chart and a donut pie chart that visualize the language breakdown.
Build the Charts with Recharts
Your metric cards are pulling live numbers from the filtered dataset. Numbers alone tell part of the story, but charts make patterns jump off the screen.
In this step, you will build two Recharts visualizations inside your dashboard. A horizontal bar chart will rank the top programming languages. A donut pie chart will show the language breakdown with a live total in the center.
In this step, get ready to:
- Build a horizontal bar chart of top languages using Recharts.
- Build a donut pie chart showing the language breakdown.
- Verify that both charts update in real time when you filter or search.
Add the horizontal bar chart and donut pie chart
Both charts live inside a two-column grid that sits directly below the metric cards. The bar chart uses languageChartData and the pie chart uses pieData. Both of these values already exist in your App function from the previous step.
- In App.jsx, find the comment that says D-START: Paste the charts below this line.
- Select everything from the { at the start of that comment down to the } at the end of the D-END comment.
- Replace your selection with this code:
{/* ===== D-START: Paste the charts below this line ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{/* Bar Chart - horizontal layout showing top languages */}
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-sm font-bold tracking-wider">TOP LANGUAGES</h2>
<span className="text-xs text-gray-500 tracking-wider">LAST 7 DAYS</span>
</div>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={languageChartData} layout="vertical" margin={{ left: 0, right: 40 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fill: '#94a3b8', fontSize: 12, fontFamily: 'monospace' }}
axisLine={false}
tickLine={false}
width={90}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1a1f2e',
border: '1px solid #2d3748',
borderRadius: '8px',
color: '#e2e8f0',
fontFamily: 'monospace',
}}
/>
<Bar
dataKey="value"
radius={[0, 4, 4, 0]}
barSize={20}
label={{ position: 'right', fill: '#06b6d4', fontSize: 13, fontFamily: 'monospace' }}
>
{/* Color each bar using the LANGUAGE_COLORS map */}
{languageChartData.map((entry) => (
<Cell
key={entry.name}
fill={LANGUAGE_COLORS[entry.name] || '#64748b'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Pie Chart - donut style with center label */}
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-6">
<h2 className="text-sm font-bold tracking-wider mb-4">LANGUAGE BREAKDOWN</h2>
<div className="relative">
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={95}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{/* Color each slice using the LANGUAGE_COLORS map */}
{pieData.map((entry) => (
<Cell
key={entry.name}
fill={LANGUAGE_COLORS[entry.name] || '#64748b'}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#1a1f2e',
border: '1px solid #2d3748',
borderRadius: '8px',
color: '#e2e8f0',
fontFamily: 'monospace',
}}
/>
</PieChart>
</ResponsiveContainer>
{/* Center label overlaid on the donut hole */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-3xl font-bold">{totalRepos}</div>
<div className="text-[10px] text-gray-500 tracking-widest">REPOS</div>
</div>
</div>
{/* Legend row with colored squares and percentages */}
<div className="flex flex-wrap justify-center gap-x-5 gap-y-2 mt-4">
{pieData.map(item => (
<div key={item.name} className="flex items-center gap-1.5 text-xs text-gray-400">
<div
className="w-2.5 h-2.5 rounded-sm"
style={{ backgroundColor: LANGUAGE_COLORS[item.name] || '#64748b' }}
/>
{item.name} {item.percent}%
</div>
))}
</div>
</div>
</div>
{/* ===== D-END: Paste the charts ===== */}
{/* ===== F-START: Paste the repo cards grid below this line ===== */}
{/* ===== F-END: Paste the repo cards grid ===== */}
</div>
)
}
// Renders a single metric card with label, value, and two subtext lines
function MetricCard({ label, value, sub1, sub2 }) {
return (
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-5 hover:border-cyan-500/30 transition-colors shadow-[0_0_15px_rgba(6,182,212,0.05)]">
<div className="text-[10px] text-gray-500 tracking-widest mb-2">{label}</div>
<div className="text-3xl font-bold text-cyan-400 mb-3">{value}</div>
<div className="flex justify-between text-xs text-gray-500">
<span className="text-emerald-400" dangerouslySetInnerHTML={{ __html: sub1 }} />
<span dangerouslySetInnerHTML={{ __html: sub2 }} />
</div>
</div>
)
}
// ===== E-START: Paste the repo card below this line =====
export default App
// ===== E-END: Paste the repo card =====
What does this code do?
The two charts sit inside a responsive two-column grid (grid-cols-1 lg:grid-cols-2). On small screens they stack vertically. On large screens they sit side by side.
The bar chart:
- ResponsiveContainer stretches the chart to fill its parent's width automatically.
- layout="vertical" on BarChart flips the axes so bars run horizontally, with language names on the left.
- Each Cell inside the Bar colors individual bars using the LANGUAGE_COLORS map. The label prop places the count in cyan at the right end of each bar.
The donut pie chart:
- innerRadius={65} and outerRadius={95} on the Pie component create the hole in the center.
- The center label is an absolutely-positioned div overlaid on top of the chart using absolute inset-0. It shows the live totalRepos count.
- The legend row below maps over pieData and renders colored squares with percentage labels for each language.
- Save App.jsx.
- Check your browser. You should see the horizontal bar chart on the left with colored language bars and cyan count labels, and the donut pie chart on the right with a total count in the center and a legend row below.
Verify chart interactivity
Both charts derive from filteredData, which recomputes whenever search or activeFilter changes. This means every interaction should ripple through the entire dashboard.
- Click the Python filter pill. The bar chart should shrink to show only Python, and the donut chart should become a full ring for one language.
- Click All to reset, then type react in the search box. The charts should update to reflect only the matching repos.
- Hover over individual bars in the bar chart and slices in the pie chart. You should see a dark-themed tooltip with the language name and count.
Charts not updating when you click filters?
- Double check that the languageChartData and pieData useMemo hooks include filteredData in their dependency arrays (the second argument to useMemo). Without it, the values never recalculate.
- Confirm that the BarChart receives data={languageChartData} and the Pie receives data={pieData}. A typo in the prop name will render an empty chart.
- My Recharts charts are not updating when I click filter pills.
Want to double check your full file matches? Use the tabs below to compare.
✔️ Awesome, I've got everything!
Great. Double check you have saved App.jsx.
ⓧ I'd like to double check the full code
Here is the complete App.jsx as it should look at the end of this step. Compare it against yours to spot any differences.
import { useState, useMemo } from 'react'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell
} from 'recharts'
import data from './data.json'
const LANGUAGE_COLORS = {
Python: '#06b6d4',
TypeScript: '#f59e0b',
Rust: '#22c55e',
Go: '#14b8a6',
JavaScript: '#f97316',
'C++': '#ef4444',
Java: '#a855f7',
Unknown: '#64748b',
}
const STATUS_STYLES = {
HOT: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
NEW: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
RISING: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
}
const FILTER_LANGUAGES = ['All', 'Python', 'TypeScript', 'Rust', 'Go', 'JavaScript', 'C++', 'Java']
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) {
const formatted = (num / 1000).toFixed(1) + 'K'
return formatted.replace('.0K', 'K')
}
return String(num)
}
function App() {
const [search, setSearch] = useState('')
const [activeFilter, setActiveFilter] = useState('All')
const filteredData = useMemo(() => {
let result = data
if (activeFilter !== 'All') {
result = result.filter(repo => repo.language === activeFilter)
}
if (search.trim()) {
const q = search.toLowerCase()
result = result.filter(repo =>
repo.name.toLowerCase().includes(q) ||
repo.description.toLowerCase().includes(q) ||
repo.language.toLowerCase().includes(q)
)
}
return result
}, [search, activeFilter])
const totalRepos = filteredData.length
const topLanguage = useMemo(() => {
const counts = {}
filteredData.forEach(r => {
if (r.language) counts[r.language] = (counts[r.language] || 0) + 1
})
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
return sorted.length > 0 ? sorted[0] : ['N/A', 0]
}, [filteredData])
const avgStars = useMemo(() => {
if (filteredData.length === 0) return '0'
const avg = filteredData.reduce((sum, r) => sum + r.stars, 0) / filteredData.length
return formatNumber(Math.round(avg))
}, [filteredData])
const totalForks = useMemo(() => {
return formatNumber(filteredData.reduce((sum, r) => sum + r.forks, 0))
}, [filteredData])
const topLangPercent = totalRepos > 0 ? Math.round((topLanguage[1] / totalRepos) * 100) : 0
const languageChartData = useMemo(() => {
const counts = {}
filteredData.forEach(r => {
if (r.language) counts[r.language] = (counts[r.language] || 0) + 1
})
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([name, value]) => ({ name, value }))
}, [filteredData])
const pieData = useMemo(() => {
const total = filteredData.length
return languageChartData.map(item => ({
...item,
percent: total > 0 ? Math.round((item.value / total) * 100) : 0,
}))
}, [filteredData, languageChartData])
return (
<div className="min-h-screen bg-[#0a0e17] text-white font-mono p-4 md:p-6">
{/* Header */}
<header className="flex flex-wrap items-center justify-between gap-4 mb-6 bg-[#0d1117] border border-[#1e293b] rounded-lg px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-emerald-400 rounded-full animate-pulse" />
<h1 className="text-lg md:text-xl font-bold tracking-wider text-cyan-400">
GITHUB TRENDING DASHBOARD
</h1>
</div>
<div className="flex-1 max-w-md mx-4">
<input
type="text"
placeholder="Search repos, languages..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#1a1f2e] border border-[#2d3748] rounded-lg px-4 py-2 text-sm text-gray-300 placeholder-gray-500 focus:outline-none focus:border-cyan-500 transition-colors"
/>
</div>
<div className="text-right text-xs">
<div className="text-gray-400 tracking-wider">UPDATED: 2 MIN AGO</div>
<div className="text-emerald-400 font-bold tracking-wider">
LIVE • {data.length} REPOS SCRAPED
</div>
</div>
</header>
{/* Filter Pills */}
<div className="flex flex-wrap gap-2 mb-6">
{FILTER_LANGUAGES.map(lang => (
<button
key={lang}
onClick={() => setActiveFilter(lang)}
className={`px-5 py-2 rounded-lg text-sm font-medium tracking-wide transition-all duration-200 ${
activeFilter === lang
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500 shadow-[0_0_10px_rgba(6,182,212,0.3)]'
: 'bg-[#0d1117] text-gray-400 border border-[#1e293b] hover:border-gray-500'
}`}
>
{lang}
</button>
))}
</div>
{/* Metric Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard
label="TOTAL REPOS"
value={totalRepos}
sub1={`+${Math.min(totalRepos, 12)} today`}
sub2="↑ 8% vs yesterday"
/>
<MetricCard
label="TOP LANGUAGE"
value={topLanguage[0]}
sub1={`${topLangPercent}% of repos`}
sub2="Trending ↑"
/>
<MetricCard
label="AVG STARS"
value={avgStars}
sub1="↑ 18% vs yesterday"
sub2="All-time high"
/>
<MetricCard
label="TOTAL FORKS"
value={totalForks}
sub1={`+340 this week`}
sub2="Contributors"
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{/* Bar Chart */}
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-sm font-bold tracking-wider">TOP LANGUAGES</h2>
<span className="text-xs text-gray-500 tracking-wider">LAST 7 DAYS</span>
</div>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={languageChartData} layout="vertical" margin={{ left: 0, right: 40 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fill: '#94a3b8', fontSize: 12, fontFamily: 'monospace' }}
axisLine={false}
tickLine={false}
width={90}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1a1f2e',
border: '1px solid #2d3748',
borderRadius: '8px',
color: '#e2e8f0',
fontFamily: 'monospace',
}}
/>
<Bar
dataKey="value"
radius={[0, 4, 4, 0]}
barSize={20}
label={{ position: 'right', fill: '#06b6d4', fontSize: 13, fontFamily: 'monospace' }}
>
{languageChartData.map((entry) => (
<Cell
key={entry.name}
fill={LANGUAGE_COLORS[entry.name] || '#64748b'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Pie Chart */}
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-6">
<h2 className="text-sm font-bold tracking-wider mb-4">LANGUAGE BREAKDOWN</h2>
<div className="relative">
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={95}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{pieData.map((entry) => (
<Cell
key={entry.name}
fill={LANGUAGE_COLORS[entry.name] || '#64748b'}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#1a1f2e',
border: '1px solid #2d3748',
borderRadius: '8px',
color: '#e2e8f0',
fontFamily: 'monospace',
}}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-3xl font-bold">{totalRepos}</div>
<div className="text-[10px] text-gray-500 tracking-widest">REPOS</div>
</div>
</div>
<div className="flex flex-wrap justify-center gap-x-5 gap-y-2 mt-4">
{pieData.map(item => (
<div key={item.name} className="flex items-center gap-1.5 text-xs text-gray-400">
<div
className="w-2.5 h-2.5 rounded-sm"
style={{ backgroundColor: LANGUAGE_COLORS[item.name] || '#64748b' }}
/>
{item.name} {item.percent}%
</div>
))}
</div>
</div>
</div>
</div>
)
}
function MetricCard({ label, value, sub1, sub2 }) {
return (
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-5 hover:border-cyan-500/30 transition-colors shadow-[0_0_15px_rgba(6,182,212,0.05)]">
<div className="text-[10px] text-gray-500 tracking-widest mb-2">{label}</div>
<div className="text-3xl font-bold text-cyan-400 mb-3">{value}</div>
<div className="flex justify-between text-xs text-gray-500">
<span className="text-emerald-400" dangerouslySetInnerHTML={{ __html: sub1 }} />
<span dangerouslySetInnerHTML={{ __html: sub2 }} />
</div>
</div>
)
}
export default App
Your dashboard now has two polished data visualizations that respond to every filter and search interaction. Next up, you will build the repo card grid that fills the rest of the page.
Build the Repo Card Grid
Your dashboard now has a header, filter pills, metric cards, and two interactive charts. The final visual piece is the most important one: the actual repo cards that let users browse individual repositories.
In this step, you will build a RepoCard component and render it in a responsive grid. Each card shows the repo's name, description, star count, language pill, and a direct link to GitHub.
In this step, get ready to:
- Build a RepoCard component with language-colored accents, status badges, and stats.
- Wire the card grid into the dashboard so it responds to search and filter changes in real time.
Add the RepoCard component and the card grid
The RepoCard component turns each repo object from your JSON data into a styled card with a colored accent bar, stats, and a link to GitHub.
- In App.jsx, find the comment that says E-START: Paste the repo card below this line.
- Select everything from that // line down to the end of the E-END line (including export default App in between).
- Replace your selection with this code:
// ===== E-START: Paste the repo card below this line =====
function RepoCard({ repo }) {
// Look up the language color for this repo
const langColor = LANGUAGE_COLORS[repo.language] || '#64748b'
return (
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg overflow-hidden hover:border-cyan-500/30 transition-colors">
{/* Thin colored accent bar at the top */}
<div className="h-1" style={{ backgroundColor: langColor }} />
<div className="p-4">
{/* Status badge (HOT, NEW, RISING) - only shows if status exists */}
{repo.status && (
<div className="flex justify-end mb-2">
<span className={`px-3 py-0.5 rounded-full text-[10px] font-bold tracking-wider ${STATUS_STYLES[repo.status] || ''}`}>
{repo.status}
</span>
</div>
)}
{/* Repo name and description */}
<h3 className="font-bold text-white mb-2 text-sm">{repo.name}</h3>
<p className="text-xs text-gray-400 mb-4 line-clamp-2 leading-relaxed">
{repo.description || 'No description available'}
</p>
{/* Star count and stats */}
<div className="text-xl font-bold text-cyan-400 mb-1">
☆ {repo.stars_formatted}
</div>
<div className="text-xs text-gray-500 mb-3">
↓ {formatNumber(repo.forks)} forks • ⚡ {formatNumber(repo.issues)} issues
</div>
{/* Language pill and VIEW link */}
<div className="flex items-center gap-2 mb-3">
<span
className="px-3 py-1 rounded-lg text-[10px] font-bold tracking-wider"
style={{
backgroundColor: langColor + '20',
color: langColor,
border: `1px solid ${langColor}40`,
}}
>
{repo.language || 'Unknown'}
</span>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 rounded-lg text-[10px] font-bold tracking-wider bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/30 transition-colors"
>
VIEW →
</a>
</div>
{/* Footer with last updated and license */}
<div className="text-[10px] text-gray-600">
Updated {repo.last_updated}
{repo.license && ` \u2022 ${repo.license}`}
</div>
</div>
</div>
)
}
export default App
// ===== E-END: Paste the repo card =====
What does this code do?
- langColor looks up the repo's language in LANGUAGE_COLORS so the card uses a consistent color throughout.
- The h-1 div creates a thin colored bar at the top of each card using inline backgroundColor.
- The status badge only renders when repo.status is non-empty. It pulls its styling from the STATUS_STYLES object you defined earlier.
- line-clamp-2 truncates long descriptions to two lines so all cards stay the same height.
- The language pill uses inline hex color math. Appending '20' to the hex color creates a 12% opacity background. Appending '40' creates a 25% opacity border.
- The VIEW link opens repo.url in a new tab using target="_blank".
You will not see the cards in the browser yet. The next chunk adds the grid that renders them.
- Still in App.jsx, find the comment that says F-START: Paste the repo cards grid below this line.
- Select everything from the { at the start of that comment down to the } at the end of the F-END comment.
- Replace your selection with this code:
{/* ===== F-START: Paste the repo cards grid below this line ===== */}
{/* Repo Cards - responsive grid: 1/2/3/4 columns by screen size */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredData.map(repo => (
<RepoCard key={repo.name} repo={repo} />
))}
</div>
{/* Empty state when no repos match the search or filter */}
{filteredData.length === 0 && (
<div className="text-center py-12 text-gray-500">
No repositories found matching your search.
</div>
)}
{/* ===== F-END: Paste the repo cards grid ===== */}
What does this code do?
- The grid uses responsive breakpoints: 1 column on mobile, 2 on small screens, 3 on medium, and 4 on large.
- filteredData.map() renders one RepoCard for each repo that passes the current search and filter.
- key={repo.name} gives React a stable identifier for each card so it can efficiently update the list when filters change.
- The empty state message only appears when filteredData.length === 0, showing a centered "No repositories found" message instead of a blank page.
- Save App.jsx.
- Check your browser at http://localhost:5173. You should see a grid of dark repo cards below the charts, each with a colored top bar, the repo name, a description, a cyan star count, and a VIEW button.
- Try typing in the search box or clicking a language filter pill. The cards, metric values, and charts should all update in real time.
Verify the complete dashboard
Your App.jsx now contains every section of the dashboard. Double check that everything is in the right order.
- Confirm the imports and constants are at the top of the file.
- Confirm the App function contains all JSX sections: header, filter pills, metric cards, charts, repo cards, and the empty state message.
- Confirm the MetricCard function is below the App function.
- Confirm the RepoCard function is below the MetricCard function.
- Confirm export default App is at the very bottom of the file.
✔️ Awesome, I've got everything!
Great. Make sure you have saved App.jsx before moving on.
ⓧ I'd like to double check the full code
Here is the complete App.jsx file. Compare it against yours to spot any differences.
import { useState, useMemo } from 'react'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell
} from 'recharts'
import data from './data.json'
const LANGUAGE_COLORS = {
Python: '#06b6d4',
TypeScript: '#f59e0b',
Rust: '#22c55e',
Go: '#14b8a6',
JavaScript: '#f97316',
'C++': '#ef4444',
Java: '#a855f7',
Unknown: '#64748b',
}
const STATUS_STYLES = {
HOT: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
NEW: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
RISING: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
}
const FILTER_LANGUAGES = ['All', 'Python', 'TypeScript', 'Rust', 'Go', 'JavaScript', 'C++', 'Java']
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) {
const formatted = (num / 1000).toFixed(1) + 'K'
return formatted.replace('.0K', 'K')
}
return String(num)
}
function App() {
const [search, setSearch] = useState('')
const [activeFilter, setActiveFilter] = useState('All')
const filteredData = useMemo(() => {
let result = data
if (activeFilter !== 'All') {
result = result.filter(repo => repo.language === activeFilter)
}
if (search.trim()) {
const q = search.toLowerCase()
result = result.filter(repo =>
repo.name.toLowerCase().includes(q) ||
repo.description.toLowerCase().includes(q) ||
repo.language.toLowerCase().includes(q)
)
}
return result
}, [search, activeFilter])
const totalRepos = filteredData.length
const topLanguage = useMemo(() => {
const counts = {}
filteredData.forEach(r => {
if (r.language) counts[r.language] = (counts[r.language] || 0) + 1
})
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
return sorted.length > 0 ? sorted[0] : ['N/A', 0]
}, [filteredData])
const avgStars = useMemo(() => {
if (filteredData.length === 0) return '0'
const avg = filteredData.reduce((sum, r) => sum + r.stars, 0) / filteredData.length
return formatNumber(Math.round(avg))
}, [filteredData])
const totalForks = useMemo(() => {
return formatNumber(filteredData.reduce((sum, r) => sum + r.forks, 0))
}, [filteredData])
const topLangPercent = totalRepos > 0 ? Math.round((topLanguage[1] / totalRepos) * 100) : 0
const languageChartData = useMemo(() => {
const counts = {}
filteredData.forEach(r => {
if (r.language) counts[r.language] = (counts[r.language] || 0) + 1
})
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([name, value]) => ({ name, value }))
}, [filteredData])
const pieData = useMemo(() => {
const total = filteredData.length
return languageChartData.map(item => ({
...item,
percent: total > 0 ? Math.round((item.value / total) * 100) : 0,
}))
}, [filteredData, languageChartData])
return (
<div className="min-h-screen bg-[#0a0e17] text-white font-mono p-4 md:p-6">
{/* Header */}
<header className="flex flex-wrap items-center justify-between gap-4 mb-6 bg-[#0d1117] border border-[#1e293b] rounded-lg px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-emerald-400 rounded-full animate-pulse" />
<h1 className="text-lg md:text-xl font-bold tracking-wider text-cyan-400">
GITHUB TRENDING DASHBOARD
</h1>
</div>
<div className="flex-1 max-w-md mx-4">
<input
type="text"
placeholder="Search repos, languages..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#1a1f2e] border border-[#2d3748] rounded-lg px-4 py-2 text-sm text-gray-300 placeholder-gray-500 focus:outline-none focus:border-cyan-500 transition-colors"
/>
</div>
<div className="text-right text-xs">
<div className="text-gray-400 tracking-wider">UPDATED: 2 MIN AGO</div>
<div className="text-emerald-400 font-bold tracking-wider">
LIVE • {data.length} REPOS SCRAPED
</div>
</div>
</header>
{/* Filter Pills */}
<div className="flex flex-wrap gap-2 mb-6">
{FILTER_LANGUAGES.map(lang => (
<button
key={lang}
onClick={() => setActiveFilter(lang)}
className={`px-5 py-2 rounded-lg text-sm font-medium tracking-wide transition-all duration-200 ${
activeFilter === lang
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500 shadow-[0_0_10px_rgba(6,182,212,0.3)]'
: 'bg-[#0d1117] text-gray-400 border border-[#1e293b] hover:border-gray-500'
}`}
>
{lang}
</button>
))}
</div>
{/* Metric Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard
label="TOTAL REPOS"
value={totalRepos}
sub1={`+${Math.min(totalRepos, 12)} today`}
sub2="↑ 8% vs yesterday"
/>
<MetricCard
label="TOP LANGUAGE"
value={topLanguage[0]}
sub1={`${topLangPercent}% of repos`}
sub2="Trending ↑"
/>
<MetricCard
label="AVG STARS"
value={avgStars}
sub1="↑ 18% vs yesterday"
sub2="All-time high"
/>
<MetricCard
label="TOTAL FORKS"
value={totalForks}
sub1={`+340 this week`}
sub2="Contributors"
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{/* Bar Chart */}
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-sm font-bold tracking-wider">TOP LANGUAGES</h2>
<span className="text-xs text-gray-500 tracking-wider">LAST 7 DAYS</span>
</div>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={languageChartData} layout="vertical" margin={{ left: 0, right: 40 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fill: '#94a3b8', fontSize: 12, fontFamily: 'monospace' }}
axisLine={false}
tickLine={false}
width={90}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1a1f2e',
border: '1px solid #2d3748',
borderRadius: '8px',
color: '#e2e8f0',
fontFamily: 'monospace',
}}
/>
<Bar
dataKey="value"
radius={[0, 4, 4, 0]}
barSize={20}
label={{ position: 'right', fill: '#06b6d4', fontSize: 13, fontFamily: 'monospace' }}
>
{languageChartData.map((entry) => (
<Cell
key={entry.name}
fill={LANGUAGE_COLORS[entry.name] || '#64748b'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Pie Chart */}
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-6">
<h2 className="text-sm font-bold tracking-wider mb-4">LANGUAGE BREAKDOWN</h2>
<div className="relative">
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={95}
paddingAngle={2}
dataKey="value"
stroke="none"
>
{pieData.map((entry) => (
<Cell
key={entry.name}
fill={LANGUAGE_COLORS[entry.name] || '#64748b'}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#1a1f2e',
border: '1px solid #2d3748',
borderRadius: '8px',
color: '#e2e8f0',
fontFamily: 'monospace',
}}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-3xl font-bold">{totalRepos}</div>
<div className="text-[10px] text-gray-500 tracking-widest">REPOS</div>
</div>
</div>
<div className="flex flex-wrap justify-center gap-x-5 gap-y-2 mt-4">
{pieData.map(item => (
<div key={item.name} className="flex items-center gap-1.5 text-xs text-gray-400">
<div
className="w-2.5 h-2.5 rounded-sm"
style={{ backgroundColor: LANGUAGE_COLORS[item.name] || '#64748b' }}
/>
{item.name} {item.percent}%
</div>
))}
</div>
</div>
</div>
{/* Repo Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredData.map(repo => (
<RepoCard key={repo.name} repo={repo} />
))}
</div>
{filteredData.length === 0 && (
<div className="text-center py-12 text-gray-500">
No repositories found matching your search.
</div>
)}
</div>
)
}
function MetricCard({ label, value, sub1, sub2 }) {
return (
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg p-5 hover:border-cyan-500/30 transition-colors shadow-[0_0_15px_rgba(6,182,212,0.05)]">
<div className="text-[10px] text-gray-500 tracking-widest mb-2">{label}</div>
<div className="text-3xl font-bold text-cyan-400 mb-3">{value}</div>
<div className="flex justify-between text-xs text-gray-500">
<span className="text-emerald-400" dangerouslySetInnerHTML={{ __html: sub1 }} />
<span dangerouslySetInnerHTML={{ __html: sub2 }} />
</div>
</div>
)
}
function RepoCard({ repo }) {
const langColor = LANGUAGE_COLORS[repo.language] || '#64748b'
return (
<div className="bg-[#0d1117] border border-[#1e293b] rounded-lg overflow-hidden hover:border-cyan-500/30 transition-colors">
<div className="h-1" style={{ backgroundColor: langColor }} />
<div className="p-4">
{repo.status && (
<div className="flex justify-end mb-2">
<span className={`px-3 py-0.5 rounded-full text-[10px] font-bold tracking-wider ${STATUS_STYLES[repo.status] || ''}`}>
{repo.status}
</span>
</div>
)}
<h3 className="font-bold text-white mb-2 text-sm">{repo.name}</h3>
<p className="text-xs text-gray-400 mb-4 line-clamp-2 leading-relaxed">
{repo.description || 'No description available'}
</p>
<div className="text-xl font-bold text-cyan-400 mb-1">
☆ {repo.stars_formatted}
</div>
<div className="text-xs text-gray-500 mb-3">
↓ {formatNumber(repo.forks)} forks • ⚡ {formatNumber(repo.issues)} issues
</div>
<div className="flex items-center gap-2 mb-3">
<span
className="px-3 py-1 rounded-lg text-[10px] font-bold tracking-wider"
style={{
backgroundColor: langColor + '20',
color: langColor,
border: `1px solid ${langColor}40`,
}}
>
{repo.language || 'Unknown'}
</span>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 rounded-lg text-[10px] font-bold tracking-wider bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/30 transition-colors"
>
VIEW →
</a>
</div>
<div className="text-[10px] text-gray-600">
Updated {repo.last_updated}
{repo.license && ` \u2022 ${repo.license}`}
</div>
</div>
</div>
)
}
export default App
Secret mission
Add Sort and Theme Toggle
Your dashboard filters repos by language and search text. But what about sorting by stars, forks, or name? And what if users prefer a lighter look? Take your dashboard to the next level with a sort dropdown and a theme toggle.
Before you go
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Vite dev server (running process on localhost:5173).
- The frontend/ folder containing your React project, Tailwind CSS configuration, and Recharts visualizations.
- The node_modules/ folder inside frontend/ with all installed npm packages.
- The src/data.json file copied from your Part 1 backend.
Keep everything running
No action needed. Choose this if you are still actively building or want to extend the dashboard with the Secret Mission.
- Leave the Vite dev server running in your terminal.
- Keep the frontend/ folder on your Desktop.
- Your dashboard is available at http://localhost:5173 whenever the dev server is running.
Pause - I'll come back to this later
Shut down the dev server to free up your terminal. Your project files stay on disk so you can pick up where you left off.
- In the terminal where Vite is running, press Ctrl+C to stop the dev server.
- Keep the github-trending-dashboard/ folder on your Desktop.
- To restart later, open your terminal, navigate to the frontend/ folder, and run npm run dev.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your dashboard code and all installed packages.
- In the terminal where Vite is running, press Ctrl+C to stop the dev server.
- Delete the entire github-trending-dashboard/ folder from your Desktop. This removes both the backend/ and frontend/ folders.
- If you only want to remove the frontend, delete the frontend/ folder and keep the backend/ folder for future use.
In Part 1, you built a Python scraper that pulls data from GitHub Trending and saves it as a JSON file. In this project, your React frontend imports that same JSON file and transforms it into the interactive dashboard you see in your browser.
Right now the data is a static snapshot from whenever you last ran the scraper. To keep it fresh, you could deploy your frontend to Vercel and set up a GitHub Actions workflow that re-runs the scraper 4 times a day. Every time it runs, the new data gets committed to your repo and Vercel automatically redeploys your dashboard with the latest trending repos.
Want me to build this next?
If you'd like a follow-up project that wires up Vercel + GitHub Actions so your dashboard updates itself automatically, let me know. I'd love to build it for you.
In the meantime, the complete project code (backend + frontend) is available here: GitHub repo.
Mission accomplished
Nice work! You just built an interactive data dashboard with React, Tailwind CSS, and Recharts. You transformed raw JSON data from your Part 1 Python scraper into a polished, tactical-style UI that runs entirely in your browser.
What you learned:
- Built a React + Vite frontend from scratch that imports a local JSON dataset and renders it as a dark tactical dashboard with Tailwind CSS utility classes and responsive grid layouts.
- Created two Recharts visualizations (a horizontal bar chart and a donut pie chart) that dynamically recompute based on filtered data.
- Wired up real-time search and language filtering using React hooks (useState and useMemo) so every section of the dashboard updates instantly.
- Secret Mission: Added a sort dropdown and a dark/light theme toggle for additional user control.
Ready to quiz yourself?