Introduction
Have you ever had client requirements where you had to build highly interactive, animation-heavy websites with good performance? What if this interactive experience needed to act like a full-blown website and also provide benefits such as individual URLs for each page, sitemap, accessibility, multiple-language and SEO support, to name a few?
This article will walk you through a case-study of a project that’s built with Nuxt.js, Tailwind CSS, and the GreenSock Animation Platform. Hopefully the article will help inspire you to kick-start your next idea to achieve all of the above and more!
The project discussed here is loosely base on this beautiful site, built with React, called Build Your Best Day. I have tried to build most of the key interactions that I thought would be good for learning and fun to implement with the combination of Nuxt and Vue. Before we dive in, take a look at the demo and here’s the Github repo.
First let’s understand why we’re using Nuxt.js, and how we can leverage the goodness of Vue.js along the way. We’ll then briefly cover Lighthouse Audit results and the transitions & animations created using CSS and GreenSock to take the website to the next level.
*Nature illustration used as a backdrop is designed by Adrianne Walujo for MixKit Art. I have erased the sky, the sun and the clouds from the original artwork to add my own HTML-based sky and sun for animation purposes.*
Why Nuxt?
So why are we using Nuxt for this project? Let’s take a close look at layouts, pages and lifecycle hooks.
Layouts
Layout is the centrepiece of this project, which is responsible for keeping all common parts of the navigation in one place. I have sketched up the following diagram to divide the layout into smaller Vue components.
Visual presentation of Vue components on default layout
We can see how the navigation takes up an entire viewport! And it’s supercharged with six key elements:
- Gradients — Includes natural scene background & sun ☀️
- SVG Arc — Custom loading indicator
- Mega-menu — Groups custom elements such as animated title, subtopics menu, auto-playing tips components
- Page-navigation — back and next buttons
- Navbar — Groups navigation items such as language switcher, page indicators and close button
- Nuxt-shape — SVG shape filler animation
Keeping these components on layout help us get that continuous feel of animation and helps us show, hide, slide, and fade different elements depending on the application state.
Let’s see how they actually fit onto default
layout component.
📃layouts/default.vue
<template>
<div class="default">
<!-- 1. GRADIENT BRACKGROUND WITH SUN AND CIRCLES -->
<gradient-background />
<!-- 2. SVG ARC FOR CIRCLES TO TRAVEL -->
<arc />
<!-- 3. MEGA-MENU, i.e. SUB-NAVIGATION FOR POINTS -->
<mega-menu />
<!-- TOPIC CONTENT - DISPLAYED THROUGH pages/_id.vue -->
<main>
<nuxt />
</main>
<nav>
<!-- 4. PAGE NAVIGATION, i.e. BACK AND NEXT NAVIGATION -->
<page-navigation />
<!-- 5. NAVBAR -->
<nav-bar />
</nav>
<!-- 6. NUXT SHAPE -->
<footer>
<nuxt-shape />
</footer>
</div>
</template>
All of these components are grouped together at /components/layout/ and then placed on default layout component at /layouts/default.vue.
Dynamic Nested Routes
Unlike a typical Vue application, we don’t have to manually configure routes in Nuxt. Rather, we simply create the directory structure under /pages
. The page structure is divided between base and dynamic routes.
We have three base routes:
- index
- introduction
- summary
And the dynamic routes are generated by set of data that is an array of objects. See pages directory of the final project here to gain better understanding for this section.
Below is the basic structure of one object, with only absolutely necessary keys. As we progressively create the application, we can always add more key-values to this data structure.
📃static/content/pages-EN.json
{
"slug": "nuxt-server-init",
"title": "Nuxt Server Init",
"points": [
{
"id": "point-1-1",
"title": "1.1"
},
{
"id": "point-1-2",
"title": "1.2"
},
{
"id": "point-1-3",
"title": "1.3"
}
]
}
As seen in the object above, our dynamic parent routes will have dynamic children to display the content for each sub-topic/point.
Meaning, first dynamic level route, localhost:3000/_slug
will make the second level routes work. Like this, localhost:3000/_slug/_id
We have used the dynamic nested route option to generate such routes. The Pages directory results in the following structure.
pages/
--|_slug/
----|_id.vue
----|index.vue
--|index.vue
--|introduction.vue
--|summary.vue
See full version of the JSON data in /static
directory that helps us creating dynamic nested routes as shown below.
Using Lifecycle Hooks
Now, how do we set this JSON
data in the store?
Pre-fill store with nuxtServerInit
Nuxt provides couple of ways to do this. We can either use asyncData
or fetch
to set the data, or use nuxtServerInit
to do the same.
We need this data to be called in just once, and pre-fill the store. So, asyncData
or fetch
may not be the right fit for this task, as they’re called every time the page loads on both, client and server-side.
On the other hand, nuxtServerInit
is called on server-side, only once! Since it’s one of the first hooks to be called, it’s perfect for setting initial data into store.
nuxtServerInit (vuexCtx, nuxtCtx) {
vuexCtx.commit("SET_PAGES", nuxtCtx.app.i18n.t("pages"));
}
nuxtServerInit
receives Vuex context as the first argument, and Nuxt context as a second. I have used Vuex context to commit
that pages
data into state, so that they’re available even before the application is ready.
pages
are set in Vuex store because they’ve application wide usage. But if you think about the (markdown) content of sub-topics. They don’t need to be set globally, instead we should be able to import the markdown file, on topic by topic basis, when the page is navigated to.
This is when asyncData
comes in handy!
Set data with asyncData
asyncData
is called every time page loads, on server-side when the first HTTP request is made. And then every time the page is navigated to on client-side.
async asyncData({ params, app }) {
let content = await importMd(params.id);
return { content };
}
Taking advantage of that behaviour, asyncData()
method is used to load the markdown files for second-level dynamic routes that are powered through _id
param.
Since we’re dealing with not one, but two dynamic parameters in our routes, we need to cover the scenario of what should happen when one of these params is incorrect.
Nuxt provides a special lifecycle hook just for this scenario, validate()
.
Error Handling with validate()
Validate hook validates parameter/s of the dynamic route and returns true
or false
. validate()
method is used on both of our dynamic routes to validate _slug
and _id
.
📃pages/_slug/index.vue
validate({ params, store }) {
return store.state.pages.find(page => page.slug === params.slug);
}
📃pages/_slug/_id.vue
validate({ params, store, app }) {
return store.getters.activePage.points.find(
point => point.slug === params.id
);
}
validate()
must return true
in order to proceed further. But if it returns false
, user is redirected to the error page.That’s why we’ve made sure to create /layouts/error.vue
for proper error handling.
You can review both dynamic pages to see the validate()
in action.
Take a look at the component tree below to learn how pages
and layouts
use building block components
.
Visualization of three key directories: pages, components and layouts
Directory Structure
Creating directory structure is no-brainer when you build with Nuxt.
Other than /pages
, you can remove the directories that’s not required in your project and add your own directories if required, such as /config
, /lang
and /utils
in this case.
/utils
stores collection of JS utility functions, and/lang
exports language objects depending on number of languages defined ini18n
configuration. Let’s take a look at/config
directory wherei18n
configuration is setup.
Application Configuration
/config
directory in fact holds all the configuration items that never change. They’re the collection of variables that are used to configure the application.
Remember, all of the config items are eventually required in nuxt.config.js
.
For example,
-
i18n
configuration - We’re using nuxt-i18n module to enable second language, Hindi, for this project. So, we configure this module in/config/i18n.js
that exportsI18N
object. -
Tailwind config - We’ve pretty heavy tailwind configuration file, specially in terms of inset (
top
andleft
values) and spacing of elements withabsolute
position! -
pointsMap - This is a collection of
yPercent
values of circles position on the SVG arc. These values are then used in custom Vue transition titled RotateInOut. -
icons - An array of material icons used for page indicators. In case, different icons are requested in future, we simply change this configuration array.
Vue Meta
Nuxt provides powerful head()
method on page components to take care of creating unique title tag for each pages of the site. This feature is super important for SEO reasons.
/pages/index.vue
can use the default meta defined at nuxt.config.js
But the dynamic pages presents real use-case.
📃pages/_slug/_id.vue & 📃pages/_slug/index.vue
head() {
return {
title: this.title,
meta: [
{
hid: "description",
name: "description",
content: this.title
}
]
};
},
In above code, this.title
is a computed property that’s responsible to create dynamic title for each pages depending on where they are in the hierarchy.
Different titles created using Vue meta
Make sure to checkout both of these page components on Github to see the complete example with computed properties.
Custom Loader Indicator
This intimidating custom loading bar seems difficult to implement at first. But Nuxt makes it pretty easy to customise the default loader bar to make it truly yours. All we need to do is create custom component at components/loading.vue
.
I have turned this component into a functional component that updates global state variable loading
of Boolean type. After this, we can use v-if directive or CSS style binding to apply desired transition.
And finally, we link this component into nuxt.config.js
under loading
key.
Custom loader indicator
In this example, we’ve used CSS style (with keyframe animation) binding to path
element as shown below.
📃 components/layout/Arc.default.vue
<path :class="{ 'loading-bar': loading }">...</path>
loading-bar
class animates stroke-dasharray
attribute of a path to give the drawing effect.
Nuxt Generate
To create static site with Nuxt, we use nuxt generate
command to pre-render all static pages upfront before deploying.
Base routes are automatically generated for all the locales that are defined in i18n
configuration. It’s the dynamic nested routes that we need to write a custom script for. In this case, this custom script is required to generate same set of routes for Hindi language as well.
📃 utils/routeHelper.js
let dynamicRoute = (lang = "") => {
return new Promise(resolve => {
const prepend = lang === "" ? "" : `/${lang}`;
const firstLevelRoutes = data.map(el => `${prepend}/${el.slug}`);
const secondLevelRoutes = data.map(el =>
el.points.map(t => `${prepend}/${el.slug}/${t.slug}`)
);
resolve(firstLevelRoutes.concat(_.flattenDeep(secondLevelRoutes)));
});
};
It’s important to note that this same script will help us setup the sitemap
as well. You can refer to the Sitemap generated with this script.
Special thanks to Shirish Nigam for helping out with the script above and thanks to Sarah Drasner for her write-up on Creating Dynamic Routes in Nuxt Applications.
Animations, transitions and CSS
There are two ways to navigate the site. 1) via the Back and Next buttons seen on left and right side of the view-port, and 2) via the indicators located in the header section.
Sub-topic menu for each of the four dynamic routes
Now, each page has its own sub-menu that is visible in the sets of three, four or five circles in the centre of the page (example above).
You may wonder how are the circles positioned right on top of the arc! Well, this effect is achieved using custom Vue transition, RotateInOut, which uses yPercent
values for defined in pointsMap configuration file.
So, if you think about it, this is very static in nature. Meaning, if you want to display six or seven circles on this arc instead, then you’ll have to find respective yPercent
values and feed into the custom transitions. Of course, the circle size would be lot smaller if we’re to fit more of them!
Site has many other little components that slides and fades as a result of user interactions. Such as,
- SVG Shape filler on Introduction page
Data driven SVG Shape filler animation
- Slideshow that animates lines and letters on Introduction page
Title characters animation and lines animation using custom Vue Js Transitions
- Moving sun ☀️ and its halo in the layout that’s reactive to user interaction and act like a progress indicators of the user-journey within the site.
Sun moves from morning to night
These beautifully moving elements and their behaviour is controlled by CSS keyframe animations combined with,
- v-if directive & CSS Transitions and
- style-binding.
We can use Tailwind CSS here because the layout of this interactive design only partially fit into traditional grid-system. We’ve put together all the CSS keyframe animations together at assets/css/keyframe.animation.css
We fallback on GreenSock animation library when CSS3 is inadequate to get results. For example, adjusting sub-menu circles on the SVG arc or breaking up text into individual characters and animating them.
Custom function written to break title letters, is unable to animate characters of Hindi language (Devanagari Script) at the moment. You may choose to use TextPlugin by GreenSock as an alternative. With it, you’ll gain an added benefit to animate special characters, as well as easily animate lines, words or characters with only a few lines of code.
The combination of custom Vue transitions and GSAP is very powerful. All custom Vue transitions are grouped together at components/transitions.
Modules
Nuxt is very light-weight by default. But we can selectively give it more power to do extra stuff for our application by using modules, like the ones we have used below in this project.
- nuxt-i18n — …enabled adding content in two languages with very minimal configurations in Nuxt environment
- PWA — …helped support key PWA features, such as, — Registering Service Worker Js — Prompt to install the web app on mobile devices — Customising Workbox-build to cache assets
- markdownit — …enabled writing content in simple markdown files and render them in Vue component using
v-html
directive - Sitemap — … enabled
sitemap.xml
generation on the fly duringnpm generate
- Google Analytics — …enabled adding Google analytics code for tracking
After all of these dependencies and HTML content, I was able to get the following results on Google Lighthouse Auditing tool.
Lighthouse Score
The Performance score has been fluctuating throughout the development journey, and it has fallen in the range between 86 and 99 depending on the various conditions. When I turned off Clear storage
checkbox in Audit panel, it had even hit 100!
That’s why, I’d suggest following tips & tricks below to get as best results as possible.
Test after,
- turning off unused Chrome extensions
- closing all open tabs except for your Nuxt project
- deploying the app online — preferably on Heroku or Netlify
Know that the result may vary depending on different versions of Chrome. Lighthouse isn’t the ultimate auditing tool, but in my experience, the more your website comply to Lighthouse, the better it performs.
Conclusion
Most of us know Nuxt as an SSR framework, built on top of Vue that helps rendering webpages on server-side to provide SEO benefits. But Nuxt can be equally powerful with modules, plugins and page-based routing system to create this kind of static experience that uses only a handful of options.
This type of website can be used as a landing page, microsite or even as an eLearning shell that’s highly engaging and fun to browse with some amounts of surprising factors. And it could very well result into visitors spending more time on the site and even help them retain the information delivered using unique layout!
Without a doubt, there’s lot of room for improvements here. I already have a few on my list.
- Streamline all GSAP animations and convert them into Nuxt plugin for re-using them in more than one project.
- The site works fine on tablet and desktop. It’s not optimised for mobile devices at the time of writing this. It may require a complete re-design for the mobile layout, since the vertical orientation could be an issue, for example, on Route Middleware page where I have five sub-topics!
- I’m planning to add moving clouds and stars to the natural scene in the background, it’d be interesting to see the performance after that.
So… follow me on Twitter for future updates and versions of this project. Thank you so much for reading about my work. ❤ I hope you learned something from my Nuxt experiment. And last but not least, Are you Nuxt?