This website uses essential cookies for functionality and collects anonymous data using analytics tools. By clicking 'Accept Analytics Tracking,' you consent to the collection and processing of this anonymous data. If you prefer not to participate in analytics, click 'Decline Analytics Tracking.'
You can manage your preferences or withdraw consent at any time via the Privacy Policy, under 'Cookies and Tracking Technologies.'
Sorry for the huge gap between part 1 and this part 2 - I was pretty busy over the last few months! But I'm excited to finally bring you part 2, and I promise that part 3 will be posted very soon!
In this part two, we are going to build the user interface for the app. This part will cover topics like using Tailwind CSS or creating Nuxt components, composables and more.
Prepare your favorite code editor, code from the previous part and let's proceed with the part 2!
Before we start building our weather app, let's make one more addition for a cleaner codebase. We'll install the Prettier plugin for TailwindCSS, which will format our code properly.
To do this, run:
npm install -D prettier prettier-plugin-tailwindcss
Next, add the plugin to your .prettierrc
configuration file with the following code:
{
"plugins": ["prettier-plugin-tailwindcss"]
}
With Prettier now integrated, our code should be looking cleaner.
To make our transactions visually appear, we need to add styles to our assets/css/main.css
file. Here's what you should add:
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Fade Transition */
.fade-enter-active,
.fade-leave-active {
@apply transition-opacity duration-300;
}
.fade-enter-from,
.fade-leave-to {
@apply opacity-0;
}
Now we can move on to the next step.
As a next step, let's prepare our default layout and remove default Nuxt template.
Create file layouts/default.vue
file:
<template>
<div>
<slot />
</div>
</template>
You can find more information on layouts here.
After that, modify the app.vue
file:
<template>
<NuxtLayout>
<h1>Hello World!</h1>
</NuxtLayout>
</template>
Next, let's clean up the project by deleting files in the public
directory. We'll also drop an SVG icon that will serve as a favicon for our app: public/favicon.svg
.
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<path
d="M21.0672 11.8568L20.4253 11.469L21.0672 11.8568ZM15.5 14.25C15.0858 14.25 14.75 14.5858 14.75 15C14.75 15.4142 15.0858 15.75 15.5 15.75V14.25ZM8.25 8.5C8.25 8.91421 8.58579 9.25 9 9.25C9.41421 9.25 9.75 8.91421 9.75 8.5H8.25ZM12.1432 2.93276L11.7553 2.29085V2.29085L12.1432 2.93276ZM1.74227 15.2247C1.86638 15.6199 2.28736 15.8397 2.68254 15.7155C3.07772 15.5914 3.29746 15.1704 3.17334 14.7753L1.74227 15.2247ZM16.6245 20.013C16.2659 20.2204 16.1434 20.6792 16.3508 21.0377C16.5582 21.3963 17.017 21.5188 17.3755 21.3114L16.6245 20.013ZM2.75 12C2.75 6.89137 6.89137 2.75 12 2.75V1.25C6.06294 1.25 1.25 6.06294 1.25 12H2.75ZM20.4253 11.469C19.4172 13.1373 17.5882 14.25 15.5 14.25V15.75C18.1349 15.75 20.4407 14.3439 21.7092 12.2447L20.4253 11.469ZM9.75 8.5C9.75 6.41182 10.8627 4.5828 12.531 3.57467L11.7553 2.29085C9.65609 3.5593 8.25 5.86509 8.25 8.5H9.75ZM3.17334 14.7753C2.89847 13.9001 2.75 12.9681 2.75 12H1.25C1.25 13.1223 1.42224 14.2058 1.74227 15.2247L3.17334 14.7753ZM21.25 12C21.25 15.4229 19.3912 18.4125 16.6245 20.013L17.3755 21.3114C20.5868 19.4538 22.75 15.98 22.75 12H21.25ZM12 2.75C11.9115 2.75 11.8077 2.71008 11.7324 2.63168C11.6686 2.56527 11.6538 2.50244 11.6503 2.47703C11.6461 2.44587 11.6482 2.35557 11.7553 2.29085L12.531 3.57467C13.0342 3.27065 13.196 2.71398 13.1368 2.27627C13.0754 1.82126 12.7166 1.25 12 1.25V2.75ZM21.7092 12.2447C21.6444 12.3518 21.5541 12.3539 21.523 12.3497C21.4976 12.3462 21.4347 12.3314 21.3683 12.2676C21.2899 12.1923 21.25 12.0885 21.25 12H22.75C22.75 11.2834 22.1787 10.9246 21.7237 10.8632C21.286 10.804 20.7293 10.9658 20.4253 11.469L21.7092 12.2447Z"
fill="#9458db"></path>
<path
d="M10.0476 15.142C10.4349 15.0119 10.8516 14.9412 11.2857 14.9412C11.7113 14.9412 12.1201 15.0092 12.5008 15.1344M5.3255 16.7555C5.15087 16.723 4.97039 16.7059 4.78571 16.7059C3.24721 16.7059 2 17.891 2 19.3529C2 20.8149 3.24721 22 4.78571 22H11.2857C13.3371 22 15 20.4198 15 18.4706C15 16.9257 13.9554 15.6126 12.5008 15.1344M5.3255 16.7555C5.17659 16.3736 5.09524 15.9605 5.09524 15.5294C5.09524 13.5802 6.75818 12 8.80952 12C10.7203 12 12.2941 13.3711 12.5008 15.1344M5.3255 16.7555C5.69238 16.824 6.03343 16.9609 6.33333 17.1516"
stroke="#9458db" stroke-width="1.5" stroke-linecap="round"></path>
</g>
</svg>
Finally, we'll update the nuxt.config.ts
file to include our new icon and set the app title:
export default defineNuxtConfig({
app: {
head: {
link: [{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }],
title: "Simple Weather App",
},
},
css: ["~/assets/css/main.css"],
compatibilityDate: "2024-04-03",
devtools: { enabled: true },
googleFonts: {
download: true,
families: {
Nunito: "400..700",
},
fontsDir: "",
overwriting: true,
outputDir: "assets/fonts",
stylePath: "nunito.css",
},
modules: ["@nuxtjs/tailwindcss", "@nuxtjs/google-fonts"],
});
Let's try it out, fire up npx nuxi dev
and open up the URL provided in terminal. In my case it's http://localhost:3000
.
If you see a blank page with text "Hello World!", we can proceed with the UI.
Leave npx nuxi dev
running throughout this tutorial - as you make changes to your code, the page will automatically refresh.
While not the most advanced icon system, I'm pleased with its flexibility, allowing me to add custom icons and use them anywhere.
First, create an icons
folder within your assets
directory.
Let's add our first icon - assets/icons/sun.svg
:
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<circle cx="12" cy="12" r="5" stroke="currentColor" stroke-width="1.5"></circle>
<path d="M12 2V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
<path d="M12 20V22" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
<path d="M4 12L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
<path d="M22 12L20 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
<path d="M19.7778 4.22266L17.5558 6.25424" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round"></path>
<path d="M4.22217 4.22266L6.44418 6.25424" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round"></path>
<path d="M6.44434 17.5557L4.22211 19.7779" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round"></path>
<path d="M19.7778 19.7773L17.5558 17.5551" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round"></path>
</g>
</svg>
Note:
You can also find and grab some icons on SVG Repo. I've used stroke="currentColor"
to enable changing the icon color by adjusting the text color, which is useful for hover effects. (You can do the same with fill
as well.)
Add your icons in assets/icons/index.ts
:
import * as sun from "./sun.svg?raw";
export default {
sun,
};
Next, create a reusable template file for our icon component. Add the following code to components/Icon.vue
:
<script setup lang="ts">
import Icons from "~/assets/icons";
const icons: any = Icons;
defineProps({
iconName: {
type: String,
required: true,
},
});
/**
* Get icon raw SVG.
*
* @param iconName string
*
* @returns string
*/
const getIcon = (iconName: string): string => {
if (icons[iconName]?.default) {
return icons[iconName].default;
} else {
return "";
}
};
</script>
<template>
<svg v-html="getIcon(iconName)" v-inline-svg />
</template>
Finally, add custom directive by creating a Nuxt plugin - plugins/svg-directive.ts
:
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive("inline-svg", {
mounted(element) {
if (element.children.length === 0) {
return;
}
const svg = element.children[0];
if (svg.tagName.toLowerCase() !== "svg") {
return;
}
for (let i = 0; i < svg.attributes.length; i++) {
const attr = svg.attributes.item(i);
element.setAttribute(attr.nodeName, attr.nodeValue);
}
svg.replaceWith(...svg.children);
},
getSSRProps() {
return {};
},
});
});
And that's it! You can now add as many custom icons as you need. Don't forget to update your index.ts
file accordingly.
We'll be using this icon component in a future section of this tutorial, so keep this example handy for reference.
<template>
...
<Icon class="h-16 w-16" icon-name="sun" />
...
</template>
By the way, here's my current list of icons I'll be using:
assets/
|-- icons/
| |-- cloud-bolt.svg
| |-- cloud-moon.svg
| |-- cloud-rain.svg
| |-- cloud-snow.svg
| |-- cloud-storm.svg
| |-- cloud-sun.svg
| |-- clouds.svg
| |-- index.ts
| |-- magnifier.svg
| |-- map-point.svg
| |-- menu.svg
| |-- moon.svg
| └── sun.svg
Prepare your icons - we'll need them for building the UI!
Next, select the colors you'd like to use and add them to your tailwind.config.js
file.
import defaultTheme from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
content: [],
theme: {
extend: {
fontFamily: {
sans: ["Nunito", ...defaultTheme.fontFamily.sans],
},
colors: {
background: {
from: "#516395",
to: "#614385",
},
},
},
},
plugins: [],
};
The default layout (layouts/default.vue
) will be straightforward, featuring a basic structure with a header component and a slot for our app content.
<template>
<div
class="from-background-from to-background-to flex min-h-screen flex-col items-center justify-center bg-gradient-to-br p-4 text-white text-opacity-80"
>
<div class="container mx-auto flex flex-col gap-8">
<AppHeader />
<slot />
</div>
</div>
</template>
As you'll notice, there's an unusual <AppHeader>
tag, which is a result of Nuxt's automatic imports allowing us to use components directly from our components
directory without importing them. Let's create our app header component in components/app/Header.vue
.
<template>
<UiGlassContainer class="relative flex justify-between">
<!-- Logo -->
<AppLogo />
<div class="flex flex-1 items-center justify-end gap-4">
<!-- Date and Time -->
<ClientOnly>
<AppDateTime />
</ClientOnly>
<!-- Menu -->
<AppMenu />
</div>
</UiGlassContainer>
</template>
You'll notice another unique tag, <ClientOnly>
, which renders HTML only on the client-side. Alternatively, you could simply disable server-side rendering altogether if it's not required, rather than using <ClientOnly>
.
To avoid duplicating code, let's create a reusable component called <UiGlassContainer>
, which will render a container with a glass-like effect. We'll define this component in components/ui/GlassContainer.vue
.
<template>
<div class="bg-white bg-opacity-5 p-4 shadow-xl">
<slot />
</div>
</template>
Now, let's create our app logo component in components/app/Logo.vue
. For this example, I'll use a simple text-based approach, but feel free to customize it with your preferred icon instead.
<template>
<div>
<h1 class="text-xl font-bold md:text-2xl">Simple Weather App</h1>
<span class="block text-sm uppercase tracking-wide opacity-75 md:text-base">
vnkmax Tutorials
</span>
</div>
</template>
The <AppDateTime>
component will display the current date and time, utilizing JavaScript's built-in Date
object for this information. To prevent a potential hydration mismatch, we've wrapped it in a <ClientOnly>
tag. Here's what the code looks like: components/app/DateTime.vue
.
<script setup lang="ts">
const dateTime: Ref<string> = ref("");
/**
* Update date and time values.
*/
const updateDateAndTime = (): void => {
const now = new Date();
dateTime.value = now.toLocaleString();
};
// Immediately update the date and time.
updateDateAndTime();
// Set an interval to update date and time every second.
setInterval(updateDateAndTime, 1000);
</script>
<template>
<div class="flex flex-col text-right text-sm md:text-base">
<span>{{ dateTime }}</span>
</div>
</template>
To complete our app's header, let's create two additional components. First, we'll create components/app/Menu.vue
, with the following code:
<script setup lang="ts">
const showMenu: Ref<boolean> = ref(false);
</script>
<template>
<div class="select-none">
<!-- Menu Toggle -->
<Icon
class="transition-colors duration-300 hover:cursor-pointer hover:text-white"
:class="showMenu ? 'text-white' : ''"
icon-name="menu"
@click="showMenu = !showMenu"
/>
<!-- Menu Content -->
<transition name="fade" appear>
<UiGlassContainer
v-if="showMenu"
class="absolute right-0 top-full mt-4 flex w-full max-w-md flex-col gap-8 backdrop-blur-2xl"
>
<!-- Current Location -->
<div class="flex flex-col gap-2 tracking-wide">
<span class="text-lg font-bold">Current Location</span>
<div class="flex items-center gap-2">
<Icon class="w-6" icon-name="mapPoint" />
<span>Jēkabpils, Jēkabpils novads, Latvia</span>
</div>
<div class="ml-8 text-xs font-medium opacity-75">
<span>Lat: 56.4958638</span>
<span>Lon: 25.8687747</span>
</div>
</div>
<!-- Location Search Box -->
<LocationSearch />
</UiGlassContainer>
</transition>
</div>
</template>
Next, we'll build components/location/Search.vue
, which will allow us to search and select a location for weather data.
<script setup lang="ts">
const showResults: Ref<boolean> = ref(false);
const keyUpTimeout: Ref<NodeJS.Timeout | null> = ref(null);
/**
* Handle input change.
*/
const handleInputChange = (event: Event): void => {
const input: HTMLInputElement = event.target as HTMLInputElement;
// Clear the timeout if it exists.
if (keyUpTimeout.value) clearTimeout(keyUpTimeout.value);
// Hide results if input has no value.
if (!input.value) {
keyUpTimeout.value = setTimeout(() => {
showResults.value = false;
}, 500);
return;
}
// Show results after a delay.
keyUpTimeout.value = setTimeout(() => {
showResults.value = true;
}, 500);
};
</script>
<template>
<div
class="flex flex-col gap-2 overflow-hidden tracking-wide transition-all duration-300"
:class="showResults ? 'h-72' : 'h-24'"
>
<!-- Location Search Input -->
<label for="search" class="text-lg font-bold">Search Location</label>
<div class="relative w-full">
<input
class="w-full bg-white bg-opacity-5 p-4 shadow transition-colors duration-300 placeholder:text-white placeholder:text-opacity-75 focus:bg-opacity-5 focus:outline-none focus:ring-0"
id="search"
name="search"
placeholder="Enter location name or address"
type="text"
autocomplete="off"
@keyup.prevent="handleInputChange"
/>
<Icon
class="absolute bottom-0 right-4 top-0 my-auto w-8 opacity-75"
icon-name="magnifier"
/>
</div>
<!-- Results Container -->
<transition name="fade" appear>
<div v-show="showResults" class="flex flex-col gap-2">
<span
class="truncate bg-white bg-opacity-10 p-4 transition-colors duration-300 hover:cursor-pointer hover:bg-opacity-15"
>
Jēkabpils, Jēkabpils novads, Latvia
</span>
<span
class="truncate bg-white bg-opacity-10 p-4 transition-colors duration-300 hover:cursor-pointer hover:bg-opacity-15"
>
Jēkabpils, Jēkabpils novads, Latvia
</span>
<span
class="truncate bg-white bg-opacity-10 p-4 transition-colors duration-300 hover:cursor-pointer hover:bg-opacity-15"
>
Jēkabpils, Jēkabpils novads, Latvia
</span>
</div>
</transition>
</div>
</template>
We'll have four components to display our weather data: current conditions, hourly forecasts, daily forecasts, and a reusable card component. Let's start by creating components/weather/Now.vue
, with the following code:
<template>
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-bold">Now</h2>
<UiGlassContainer
class="flex flex-1 items-center justify-evenly gap-4 !py-12"
>
<div class="flex flex-col gap-8">
<span class="text-3xl font-medium">Jēkabpils</span>
<div class="flex items-end gap-2">
<span class="text-5xl font-medium">21°C</span>
<span class="text-xl font-medium">Sunny</span>
</div>
</div>
<Icon class="h-auto w-32" icon-name="sun" />
</UiGlassContainer>
</div>
</template>
Next, we'll build our reusable card component in components/weather/Card.vue
:
<script setup lang="ts">
defineProps({
dateTimeString: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
weather: {
type: String,
required: true,
},
temperature: {
type: Number,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col items-center justify-center gap-4 p-4 font-semibold"
>
<span class="text-lg">{{ dateTimeString }}</span>
<Icon class="h-16" :icon-name="icon" />
<div class="flex flex-col text-center">
<span class="font-normal">{{ weather }}</span>
<span class="text-xl">{{ temperature }}°C</span>
</div>
</div>
</template>
Then, let's create components/weather/Hourly.vue
:
<template>
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-bold">Hourly</h2>
<div class="flex flex-col overflow-x-auto shadow-xl">
<UiGlassContainer class="grid min-w-max flex-1 grid-cols-6">
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="12 AM"
icon="moon"
weather="Clear"
:temperature="18"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="1 AM"
icon="moon"
weather="Clear"
:temperature="17"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="2 AM"
icon="moon"
weather="Clear"
:temperature="16"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="3 AM"
icon="cloudMoon"
weather="Mostly cloudy"
:temperature="15"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="4 AM"
icon="clouds"
weather="Cloudy"
:temperature="14"
/>
<WeatherCard
class="border-white border-opacity-5 last:!border-r-0 even:border-x"
date-time-string="5 AM"
icon="cloudRain"
weather="Rain"
:temperature="14"
/>
</UiGlassContainer>
</div>
</div>
</template>
Finally, we'll develop our daily component in components/weather/Daily.vue
:
<template>
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-bold">Daily</h2>
<div class="flex flex-col overflow-x-auto shadow-xl">
<UiGlassContainer class="grid min-w-max flex-1 grid-cols-7">
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="25 Oct 2024"
icon="sun"
weather="Sunny"
:temperature="21"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="26 Oct 2024"
icon="cloudSun"
weather="Mostly sunny"
:temperature="20"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="27 Oct 2024"
icon="clouds"
weather="Cloudy"
:temperature="18"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="28 Oct 2024"
icon="cloudRain"
weather="Rain"
:temperature="15"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="29 Oct 2024"
icon="cloudBolt"
weather="Thunder"
:temperature="25"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="30 Oct 2024"
icon="cloudStorm"
weather="Thunderstorm"
:temperature="24"
/>
<WeatherCard
class="border-white border-opacity-5 even:border-x"
date-time-string="31 Oct 2024"
icon="cloudSnow"
weather="Snow"
:temperature="-21"
/>
</UiGlassContainer>
</div>
</div>
</template>
Having all the components ready, we just need to update our app.vue
:
<template>
<NuxtLayout>
<div class="grid grid-cols-1 items-stretch gap-8 lg:grid-cols-6">
<WeatherNow class="lg:col-span-3 xl:col-span-2" />
<WeatherHourly class="lg:col-span-3 xl:col-span-4" />
</div>
<WeatherDaily />
</NuxtLayout>
</template>
If you've stopped the app, restart it with npx nuxi dev
. You should now see your weather application up and running. Note that it won't be fully functional yet and we'll address that in part 3 of this tutorial. Stay tuned for more updates on this series!
Building a Simple Weather App with Nuxt 3 and TailwindCSS - Part 1: Setup & Configuration
Building a Simple Weather App with Nuxt 3 and TailwindCSS - Part 3: API Integration