Weather UI
Fetch live weather data from a public API, display current conditions and a 5-day forecast, handle loading and error states.
Project Overview
This project brings together everything from the Async JavaScript module: fetch(), async/await, error handling, and loading states — all applied to a real public API. The Open-Meteo API is free and requires no API key, making it ideal for learning.
What you will build
- City search with geocoding (convert city name to latitude/longitude)
- Current conditions: temperature, feels-like, humidity, wind speed, weather description
- Large weather icon (Unicode emoji mapped from WMO weather codes)
- 5-day forecast strip
- Loading skeleton / spinner while fetching
- Error state for invalid cities or network failures
- °C / °F toggle
Concepts used
async/awaitwithtry/catch- Chained
fetch()calls (geocode first, then weather) URLSearchParamsfor query strings- Rendering loading, error, and success states
- WMO weather code lookup table
API Details
This project uses two free APIs — no signup or API key required:
- Open-Meteo Geocoding API — converts a city name to lat/lng
- Open-Meteo Weather API — returns current and forecast data given lat/lng
// Step 1: Geocode the city name
const GEO_URL = 'https://geocoding-api.open-meteo.com/v1/search';
const geoRes = await fetch(`${GEO_URL}?name=${encodeURIComponent(city)}&count=1&language=en&format=json`);
const geoData = await geoRes.json();
const { latitude, longitude, name, country } = geoData.results[0];
// Step 2: Fetch weather for those coordinates
const WEATHER_URL = 'https://api.open-meteo.com/v1/forecast';
const params = new URLSearchParams({
latitude, longitude,
current: 'temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weathercode',
daily: 'temperature_2m_max,temperature_2m_min,weathercode',
timezone: 'auto',
forecast_days: 5
});
const wRes = await fetch(`${WEATHER_URL}?${params}`);
const wData = await wRes.json();
Key Logic
WMO weather code to emoji
const WMO = {
0: { label: 'Clear sky', icon: '☀️' },
1: { label: 'Mainly clear', icon: '🌤️' },
2: { label: 'Partly cloudy', icon: '⛅' },
3: { label: 'Overcast', icon: '☁️' },
45: { label: 'Fog', icon: '🌫️' },
51: { label: 'Light drizzle', icon: '🌦️' },
61: { label: 'Slight rain', icon: '🌧️' },
71: { label: 'Slight snow', icon: '🌨️' },
80: { label: 'Slight rain showers', icon: '🌦️' },
95: { label: 'Thunderstorm', icon: '⛈️' },
};
function getWeather(code) {
// Find exact match, or nearest lower code
return WMO[code] ?? WMO[Object.keys(WMO).reverse().find(k => k <= code)]
?? { label: 'Unknown', icon: '🌡️' };
}
Temperature unit toggle
let useFahrenheit = false;
function displayTemp(celsius) {
if (!useFahrenheit) return `${Math.round(celsius)}°C`;
return `${Math.round(celsius * 9/5 + 32)}°F`;
}
Loading / error states
function setState(state) {
loadingEl.hidden = state !== 'loading';
errorEl.hidden = state !== 'error';
weatherEl.hidden = state !== 'data';
}
async function search(city) {
setState('loading');
try {
const data = await fetchWeather(city);
renderWeather(data);
setState('data');
} catch (err) {
errorEl.textContent = err.message;
setState('error');
}
}
Complete Working Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weather App</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(160deg, #0f172a, #1e3a5f);
min-height: 100vh;
display: flex; align-items: flex-start; justify-content: center;
padding: 2rem 1rem;
color: #e2e8f0;
}
.app { width: 100%; max-width: 480px; }
h1 { font-size: 1.6rem; margin-bottom: 1.25rem; color: #f1f5f9; text-align: center; }
.search-row { display: flex; gap: .6rem; margin-bottom: 1.5rem; }
.search-row input {
flex: 1; padding: .75rem 1rem;
background: #1e293b; border: 2px solid #334155;
border-radius: .6rem; font-size: 1rem; color: #e2e8f0; outline: none;
}
.search-row input:focus { border-color: #38bdf8; }
.search-row input::placeholder { color: #64748b; }
.search-row button {
padding: .75rem 1.25rem; background: #0ea5e9; color: #fff;
border: none; border-radius: .6rem; font-size: .95rem; cursor: pointer;
}
.search-row button:hover { background: #0284c7; }
/* States */
.spinner {
text-align: center; padding: 3rem; font-size: 2rem;
animation: spin 1s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-wrap { text-align: center; padding: 2rem; }
.error-box {
background: #450a0a; border: 1px solid #ef4444;
border-radius: .6rem; padding: 1rem 1.25rem;
color: #fca5a5; font-size: .9rem;
}
/* Weather card */
.weather-card {
background: #1e293b; border-radius: 1rem; padding: 1.75rem;
box-shadow: 0 8px 32px #0005;
}
.city-name { font-size: 1.3rem; font-weight: 700; color: #f1f5f9; }
.date { font-size: .85rem; color: #64748b; margin-bottom: 1rem; }
.main-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.weather-icon { font-size: 4rem; line-height: 1; }
.temp-big { font-size: 3.5rem; font-weight: 800; color: #38bdf8; }
.weather-desc { font-size: 1rem; color: #94a3b8; margin-top: .25rem; }
.details-grid {
display: grid; grid-template-columns: 1fr 1fr 1fr;
gap: .75rem; margin-bottom: 1.25rem;
}
.detail-box {
background: #0f172a; border-radius: .5rem;
padding: .6rem .75rem; text-align: center;
}
.detail-label { font-size: .7rem; color: #64748b; text-transform: uppercase; letter-spacing: .05em; }
.detail-val { font-size: 1rem; font-weight: 700; color: #e2e8f0; margin-top: .2rem; }
h3 { font-size: .8rem; color: #64748b; text-transform: uppercase;
letter-spacing: .06em; margin-bottom: .6rem; }
.forecast { display: flex; gap: .5rem; }
.forecast-day {
flex: 1; background: #0f172a; border-radius: .5rem;
padding: .6rem .25rem; text-align: center;
}
.f-day { font-size: .7rem; color: #64748b; margin-bottom: .3rem; }
.f-icon { font-size: 1.4rem; margin-bottom: .3rem; }
.f-hi { font-size: .85rem; font-weight: 700; color: #e2e8f0; }
.f-lo { font-size: .75rem; color: #64748b; }
.unit-toggle {
background: none; border: 1px solid #334155; color: #94a3b8;
border-radius: .4rem; padding: .3rem .8rem; font-size: .8rem;
cursor: pointer; float: right; margin-top: -.25rem;
}
.unit-toggle:hover { border-color: #38bdf8; color: #38bdf8; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<div class="app">
<h1>Weather</h1>
<div class="search-row">
<input id="city-input" placeholder="Enter city name…" autocomplete="off">
<button id="search-btn">Search</button>
</div>
<div class="loading-wrap" id="loading" hidden>
<span class="spinner">🌀</span>
<p style="margin-top:.75rem;color:#64748b">Fetching weather…</p>
</div>
<div class="error-box" id="error" hidden></div>
<div class="weather-card" id="weather" hidden>
<button class="unit-toggle" id="unit-toggle">°F</button>
<div class="city-name" id="city-name"></div>
<div class="date" id="date"></div>
<div class="main-row">
<div class="weather-icon" id="w-icon"></div>
<div>
<div class="temp-big" id="temp-big"></div>
<div class="weather-desc" id="w-desc"></div>
</div>
</div>
<div class="details-grid">
<div class="detail-box">
<div class="detail-label">Feels like</div>
<div class="detail-val" id="feels"></div>
</div>
<div class="detail-box">
<div class="detail-label">Humidity</div>
<div class="detail-val" id="humidity"></div>
</div>
<div class="detail-box">
<div class="detail-label">Wind</div>
<div class="detail-val" id="wind"></div>
</div>
</div>
<h3>5-Day Forecast</h3>
<div class="forecast" id="forecast"></div>
</div>
</div>
<script>
const WMO = {
0: { label: 'Clear sky', icon: '☀️' },
1: { label: 'Mainly clear', icon: '🌤️' },
2: { label: 'Partly cloudy', icon: '⛅' },
3: { label: 'Overcast', icon: '☁️' },
45:{ label: 'Fog', icon: '🌫️' },
48:{ label: 'Icy fog', icon: '🌫️' },
51:{ label: 'Light drizzle', icon: '🌦️' },
61:{ label: 'Slight rain', icon: '🌧️' },
63:{ label: 'Moderate rain', icon: '🌧️' },
65:{ label: 'Heavy rain', icon: '🌧️' },
71:{ label: 'Slight snow', icon: '🌨️' },
75:{ label: 'Heavy snow', icon: '❄️' },
77:{ label: 'Snow grains', icon: '🌨️' },
80:{ label: 'Slight showers', icon: '🌦️' },
85:{ label: 'Snow showers', icon: '🌨️' },
95:{ label: 'Thunderstorm', icon: '⛈️' },
99:{ label: 'Thunderstorm+hail', icon: '⛈️' },
};
function getWMO(code) {
if (WMO[code]) return WMO[code];
const nearest = Object.keys(WMO).map(Number).reverse().find(k => k <= code);
return WMO[nearest] ?? { label: 'Unknown', icon: '🌡️' };
}
let useFahrenheit = false;
let lastData = null;
function toF(c) { return Math.round(c * 9 / 5 + 32); }
function fmt(c) { return useFahrenheit ? `${toF(c)}°F` : `${Math.round(c)}°C`; }
const loadingEl = document.querySelector('#loading');
const errorEl = document.querySelector('#error');
const weatherEl = document.querySelector('#weather');
function setState(s) {
loadingEl.hidden = s !== 'loading';
errorEl.hidden = s !== 'error';
weatherEl.hidden = s !== 'data';
}
async function fetchWeather(city) {
const geoRes = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`
);
if (!geoRes.ok) throw new Error('Geocoding API error');
const geoData = await geoRes.json();
if (!geoData.results?.length) throw new Error(`City not found: "${city}"`);
const { latitude, longitude, name, country } = geoData.results[0];
const params = new URLSearchParams({
latitude, longitude,
current: 'temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weathercode',
daily: 'temperature_2m_max,temperature_2m_min,weathercode',
timezone: 'auto',
forecast_days: 5
});
const wRes = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
if (!wRes.ok) throw new Error('Weather API error');
const wData = await wRes.json();
return { name, country, current: wData.current, daily: wData.daily };
}
function renderWeather(data) {
lastData = data;
const c = data.current;
const d = data.daily;
const w = getWMO(c.weathercode);
document.querySelector('#city-name').textContent = `${data.name}, ${data.country}`;
document.querySelector('#date').textContent = new Date().toLocaleDateString('en-GB', {
weekday: 'long', day: 'numeric', month: 'long'
});
document.querySelector('#w-icon').textContent = w.icon;
document.querySelector('#temp-big').textContent = fmt(c.temperature_2m);
document.querySelector('#w-desc').textContent = w.label;
document.querySelector('#feels').textContent = fmt(c.apparent_temperature);
document.querySelector('#humidity').textContent = `${c.relative_humidity_2m}%`;
document.querySelector('#wind').textContent = `${Math.round(c.wind_speed_10m)} km/h`;
const forecastEl = document.querySelector('#forecast');
forecastEl.innerHTML = '';
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
d.time.forEach((dateStr, i) => {
const day = document.createElement('div');
day.className = 'forecast-day';
const dow = days[new Date(dateStr).getDay()];
const fw = getWMO(d.weathercode[i]);
day.innerHTML = `
<div class="f-day">${i === 0 ? 'Today' : dow}</div>
<div class="f-icon">${fw.icon}</div>
<div class="f-hi">${fmt(d.temperature_2m_max[i])}</div>
<div class="f-lo">${fmt(d.temperature_2m_min[i])}</div>
`;
forecastEl.appendChild(day);
});
}
async function doSearch() {
const city = document.querySelector('#city-input').value.trim();
if (!city) return;
setState('loading');
try {
const data = await fetchWeather(city);
renderWeather(data);
setState('data');
} catch (err) {
errorEl.textContent = err.message;
setState('error');
}
}
document.querySelector('#search-btn').addEventListener('click', doSearch);
document.querySelector('#city-input').addEventListener('keydown', e => {
if (e.key === 'Enter') doSearch();
});
document.querySelector('#unit-toggle').addEventListener('click', () => {
useFahrenheit = !useFahrenheit;
document.querySelector('#unit-toggle').textContent = useFahrenheit ? '°C' : '°F';
if (lastData) renderWeather(lastData);
});
// Default city on load
document.querySelector('#city-input').value = 'London';
doSearch();
</script>
</body>
</html>
Code Explained
Two chained fetch calls
The app makes two sequential API calls inside one async function. The geocoding API converts the city name to coordinates; those coordinates are then passed to the weather API. Because both are await-ed, errors from either are caught by the single try/catch in the calling code.
Three UI states
The app always has exactly one visible state: loading, error, or data. The setState() helper sets hidden on two elements and clears it on one. This is the same pattern used in production React apps with conditional rendering.
URLSearchParams for clean query strings
new URLSearchParams({ latitude, longitude, ... }) handles encoding of spaces and special characters, and produces a clean key=value&key2=value2 string automatically. Always prefer this over manually concatenating query strings.
WMO code lookup with fallback
The WMO standard defines dozens of weather codes. Instead of listing every single one, we store the most common codes and find the nearest lower match using Object.keys(WMO).map(Number).reverse().find(k => k <= code) as a fallback. This avoids returning "Unknown" for codes that are close variants of a known condition.
Challenges
- Add a "Use my location" button using
navigator.geolocation.getCurrentPosition()to skip the city search entirely. - Cache the last successful fetch result in localStorage and display it instantly on page load while a background refresh runs.
- Add an hourly forecast for the next 12 hours using the
hourlyparameter in the Open-Meteo API. - Animate the weather icon: use CSS keyframes to make the sun pulse, rain fall, or clouds drift.
- Add a "Saved cities" list so users can bookmark multiple locations and switch between them.