Masonry grid layouts are perfect for displaying cards with uneven heights. They make viewing content of irregular heights aesthetically more pleasing and also help save lot of space. When we add infinite loading into the mix, it follows pagination like logic where we scroll to load more data.
Now infinite-loading masonry grid is not a new concept, but we will implement it using Nuxt Composition API and Nuxt Content module to build Pinterest-like layout in Nuxt project.
The main aim of this article is to experiment with Nuxt Composition API. The API of Nuxt-specific methods presented in this module will likely to change before Nuxt 3 is released and therefore aren't recommended for production use.
Super versatile Nuxt content module will be our Git-based headless CMS to write, query and display data into our Pinterest-like grid layout. I am using some demo data in the Github repo under content/articles
folder.
Traditionally, we use $content("...").fetch()
method either with asyncData()
or fetch()
to get the content.
export default {
data() {
return {
articles: []
}
},
// fetch()
async fetch() {
this.articles = await this.$content("articles").fetch();
}
// asyncData
async asyncData({ $content }) {
const articles = await $content("articles").fetch();
return { articles }
}
}
But we will modify above code a little bit, as we want to use Nuxt composition API to implement above.
Once we install @nuxtjs/composition-api
, register the module in the Nuxt config file under buildModules
:
// nuxt.config.js
export default {
buildModules: ["@nuxtjs/composition-api"],
};
Nuxt composition API offers everything that vue/composition-api has to offer, such as ref
, defineComponent
, computed
, etc plus Nuxt specific helpers like, useMeta
, useContext
, useFetch
etc.
defineComponent
defineComponent
helper along with an empty head
key to activate the meta tags functionality. And then you can have useMeta
helper in setup
function to override meta tag information, such as title, description, etc.export default defineComponent({
head: {},
setup() {
// ...
useMeta({
title: "v1 - Composition API",
meta: [
{
hid: "description",
name: "description",
content: "my website description",
},
],
});
},
});
In Vue 3 setup()
replaces both beforeCreate
and created
hooks and it receives two arguments, props
and context
. Since we are writing root component, we aren't expecting any incoming props
, so we can start by defining reactive variable to store articles array.
const articles = ref([]);
Next, let's fill the articles
array.
Nuxt content module injects $content
globally for us to access data stored in /content
directory using this.$content
. But this
context isn't available anymore in new Vue 3 composition API.
However, we do have access to Nuxt content inside our Vue 3 composable, where we can access Vuex store, router, config as well as any methods or properties added through Nuxt modules! This means, we should be able to de-structure and access $content
using useContext
like this:👇
export default defineComponent({
// ...
setup() {
// ...
const { $content } = useContext();
},
});
Since we have access to $content
, we can now use useFetch
to implement similar function to fetch data. Later in the article, we will modify this query below to fetch data in sets. For now, let's fetch all available articles.
useFetch(async () => {
articles.value = await $content("articles")
.sortBy("createdAt", "desc")
.fetch();
});
And finally, the most important step of all, we will need to return our articles
array, so that it's available for us to use in the <template>
area.
return { articles };
This is the most basic implementation of Nuxt content module, and it gives us all articles upfront. We can simply loop through each article and display its detail in the grid.
At this point, each grid-item will have different heights due to uneven amount of content. In the next step, let's fix this by applying masonry-grid.
The feature that visually strikes the most in Pinterest is the masonry grid! Masonry grid makes is possible to show images with different heights possible. You can either write your own masonry-grid logic using CSS/JS combination or for faster results, use existing plugins like I have as there are many existing options available already.
Bootstrap's Card component does provide masonry-like layout, but the grid is laid out in vertical - top-to-bottom - order. Normally, when the posts are sorted by published-date in reverse order, we want to scan the grid horizontally - from left-to-right. But with Bootstrap 4's masonry column layout, you'd have to scan grid-items from top-to-bottom. The UX feels very unnatural with this approach when the chronological order matters.
Pinterest-like layout has pretty basic masonry requirements. vue-masonry-css
provides support for defining responsive columns (and columns only!), breakpoints and gap-size options. That's pretty much all we need.
<masonry :cols="{ default: 4, 1000: 3, 700: 2, 400: 1 }" :gutter="20">
<!-- ... -->
</masonry>
It's important to note that Tailwind or Bootstrap or any other external grid utilities will become redundant when we use vue-masonry-css
package, since the package has its own implementation of CSS grid.
vue-masonry
comes with a lot more options compare to vue-masonry-css
. The package follows the most popular masonry layout library which gives you fine-grained control over:
This package even covers animation aspects of the grid, which saves you from using one more extra dependency to add grid transitions.
Decision between these two packages totally depends on your use-case! If your project requirement is to achieve more dynamic masonry-grid with custom size rows and columns, then feature-rich vue-masonry
maybe the answer. When I was going through all available masonry options to replicate Pinterest-like layout, vue-masonry-css
provided just enough options I needed to use for my diagrams.
Another standout feature of Pinterest is infinitely loading items as we scroll the page which makes Pinterest sooo addictive that we keep on scrolling and scrolling until the phone battery runs out! We will use vue-infinite-loading
package to implement this feature. vue-infinite-loading
:
$content(...).fetch()
combinationWith that in mind, let's outline our requirement. We need to fetch x
number of records via API on initial page render and from then onwards, on each page-scroll; where x
is the number of records you want to fetch per scroll.
For larger data-set, you can always set higher value of x
, but for this article, we will fetch 3 records at a time to demonstrate the logic. Let's add vue-infinite-loading
into our project.
npm install vue-infinite-loading
vue-infinite-loading
is Vue component, so if you have a single page that uses infinite loading, then you can directly import the component on that page and start using it.
import InfiniteLoading from "vue-infinite-loading";
export default defineComponent({
components: {
InfiniteLoading,
},
});
But if you have multiple Nuxt pages using infinite loading, then you can convert it into a client-side Nuxt plugin.
// plugins/vue-infinite-loading.js
import Vue from "vue";
import InfiniteLoading from "vue-infinite-loading";
Vue.use(InfiniteLoading);
And make it available globally by registering it into Nuxt configuration. This way we avoid importing the component on every single page.
// nuxt.config.js
export default {
plugins: [
// ...
{ src: "~/plugins/vue-infinite-loading", mode: "client" },
],
};
Now that the setup of the plugin is out of the way, let's modify our fetch content function to accomodate the infinite loading.
Currently, we are fetching all the data upfront. In our fetch $content
query, let's introduce a limit
of fetching 3 records. Nuxt content module provides a chain-able function that will help us limit the number of records fetched on initial page load.
useFetch(async () => {
articles.value = await $content("articles")
.limit(3)
.sortBy("createdAt", "desc")
.fetch();
});
From this point onwards, the vue-infinite-loading
will take over the data fetching operation.
To trigger the infinite-loading, first we will add the Vue component itself in our template area and give it an @infinite
handler.
<infinite-loading @infinite="infiniteHandler">
<!-- ... -->
</infinite-loading>
We are telling infinite-loader to call infiniteHandler
every time user scrolls the page.
Now, think of infinite-loading just like pagination, but instead of clicking next page, we want to scroll the page to load more data until there's nothing left to fetch from the API. We will define a page
variable with initial value set to 0
to keep track of each page in pagination.
const page = ref(0);
Now every time infiniteHandler
is called, we will:
page
value by 1
limit()
, Nuxt content module provides skip()
function to skip given number of records while querying the data0
, then push next 3 records into article
arrayfunction infiniteHandler($state) {
setTimeout(async () => {
page.value += 1;
let additionalItems = await $content("articles")
.skip(3)
.limit(3)
.sortBy("createdAt", "desc")
.fetch();
if (additionalItems.length > 0) {
articles.value.push(...additionalItems);
}
}, 500);
}
So far the solution looks good, but on second page-scroll, we want to skip 6 articles instead of 3. But the values for limit
and skip
are hardcoded, which is a problem!
To fix that, we can simply multiply the value of page
with number of records to skip in skip()
param.
.skip(3 * page.value)
Better yet, let's define a constant for limit
. Later in the next section, when we'll encapsulate all infinite-loading related functionality into composable - a reusable utility where we can pass the params to load data.
const limit = ref(3);
// infinite handler receives $state as a special param
function infiniteHandler($state) {
// ...
let additionalItems = await $content("articles")
.skip(limit.value * page.value)
.limit(limit.value)
.sortBy("createdAt", "desc")
.fetch();
}
Now to account for the use-case when there's no data return, we should always check the length of the data, if this data-length is more than 0
then we tell the plugin that we have the data by using $state.loaded()
else we can use $state.completed()
to let the plugin know that we have nothing more to fetch.
if (additionalItems.length > 0) {
articles.value.push(...additionalItems);
$state.loaded();
} else {
$state.complete();
}
This concludes the infinite loading masonry grid that fetches data from our git-based headless CMS. It's quite simple if you look at the page component structure.
We have two client-side Nuxt plugins - <masonry />
and <infinite-loading />
- at the same level, and each grid-item is wrapped around by masonry CSS.
This is the best part of Vue Composition API. 💖 Using composable in this way, your function only needs to return what's required without exposing unnecessary implementation details.
// pages/composition-api-v3.vue
const { items, infiniteHandler } = useInfiniteMasonry({
path: "articles",
limit: 3,
});
Here, useInfiniteMasonry
encapsulates the logic to fetch initial records and handles infinite loading for the rest of the data and returns items
array and infiniteHandler
. As a consumer of this composable, we only need to feed limit
and path
, where:
/content
directory.Previously defined as articles
variable, now can be generalised to call items
, which is also a reactive reference variable that gradually stores articles as they are fetched and pushed by infiniteHandler
.
// use/infiniteMasonry.js
export default function infiniteMasonry(list) {
const { $content } = useContext();
const items = ref([]);
const page = ref(0);
function fetchData() {
return $content(list.path)
.limit(list.limit)
.skip(list.limit * page.value)
.sortBy("createdAt", "desc")
.fetch();
}
useFetch(async () => {
items.value = await fetchData();
});
function infiniteHandler($state) {
let additionalItems = await fetchData();
// ...
items.value.push(...additionalItems);
// ...
}
return { items, infiniteHandler };
}
As you can see in the demo, each card appears with transition. Next section talks briefly about the package I have used to animate elements on scroll.
Vue aos
package watches all elements and their positions based on the settings we provide. It comes with predefine animation sets such as, fade, flip, slide and zoom along with easing functions and anchor-placement. It works great with default settings, but also provides advanced settings to customise default transitions like, offset, duration, delay, etc. There's a detailed article written by the library author, explaining how the plugin works on CSS-Trick.
In the light of Nuxt, we will convert this package into Nuxt plugin, so that we can animate any elements in our project. Unlike infinite-loading plugin, Vue AOS isn't exactly a Vue component, it's written in JavaScript and rather needs to be injected into root Vue instance. You can set global configuration for all animations and enable it from right here in the plugin.
import AOS from "aos";
import "aos/dist/aos.css";
export default ({ app }) => {
app.AOS = new AOS.init({
// offset: 50,
// delay: 50,
// duration: 500,
// mirror: "true",
// anchorPlacement: "top-center",
easing: "ease-in-out"
})
})
Let's make this plugin available globally.
// nuxt.config.js
expirt default {
plugins: [
{ src: "~/plugins/vue-aos", mode: "client" }
]
}
And now, we can start using the library without any further configuration using data-aos
attribute.
<card v-for="(article, index) in articles" data-aos="fade-up"></card>
Similar as fade-up
, you can try applying many defaults you see here in the documentation.
We have covered same example built in slightly different ways, so that we can compare and contrast the different methods to use Composition API and help us better plan and build reusable components in Nuxt.
This example presents perfect use-case for two chainable methods - limit()
and skip()
- provided by Nuxt content module as well. In case you missed it, don't forget to check this printable PDF of Content Module cheatsheet that I published earlier.
I hope you enjoyed reading this article. You can follow me on Twitter @KrutiePatel to get notified of new articles and diagrams about Vue and Nuxt.