How to build Pinterest-like infinite grid in Nuxt
Created

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.

Infinite Loading Masonry Grid with Nuxt
Infinite Loading Masonry Grid with Nuxt

Nuxt Content Module

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.

Configure Nuxt Composition API

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.

Note on defineComponent

If you are using meta tags then you would need to use 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.

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 Grid

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.

Vue Masonry CSS

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

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:

  • column height and width,
  • laying grid from left-to-right or top-to-bottom
  • define column width and gutter size in numeric value or by CSS class... and more.

This package even covers animation aspects of the grid, which saves you from using one more extra dependency to add grid transitions.

vue-masonry vs vue-masonry-css

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.

View the complete code for Nuxt composition API and Masonry CSS where the all the data is fetched upfront and its demo.

Vue Infinite Loading

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 :

  • is mobile friendly
  • fetches data as you scroll
  • plays nicely with masonry grid & grid-items can be animated on scroll
  • and also works with Nuxt Content module + $content(...).fetch() combination

With 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

One-off usage

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,
  },
});

Global usage

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();
});

Infinite loading

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:

  • increase page value by 1
  • skip the records that are already fetched on initial load
    • Similar to limit(), Nuxt content module provides skip() function to skip given number of records while querying the data
  • fetch next 3 records
  • if the records length is more than 0, then push next 3 records into article array
function 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.

Infinite masonry layout (v2)
Infinite masonry layout (v2)

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.

Infinite masonry hierarchy (v2)
Infinite masonry hierarchy (v2)

Reusable utility

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:

  • limit is the number of records we want to fetch per scroll and
  • path is the content path where the data is stored within /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 };
}

See the complete code for Nuxt composition API Composable where infinite-loading handler is tucked away inside a reusable utility and its demo.

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.

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.

Summary

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.

  1. Regular Masonry with Nuxt where all data fetched upfront
  2. Infinite-loading masonry with Nuxt composition API
  3. Infinite-loading masonry with reusable Composable utility
  4. Infinite-loading masonry with Options API

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.