DEV Community

John Halsey
John Halsey

Posted on

Creating a masonry layout from scratch in VueJs (and CSS Grid)

I wrote my own website in Laravel and VueJS. It’s nothing special, just a page about me and some of my skills, and a blog page. Eventually I intend to have a page for some projects to showcase some of the things I've created in my spare time.

A few days ago, the blog index page was, well… shite, just a paginated list of the posts, nothing exciting and a pretty crap layout. I wanted to spruce it up with a masonry feel, but didn’t want to import any packages, so had a go at building it myself.

This is resulting outcome. Check it out here johnhalsey.co.uk/blog

So how did I achieve this?

Firstly, I make an api call to get the posts from the database. I send back a JSON response in a Laravel resource (not shown here).

export default {
    name: 'Posts',
    props: {
        category: {
            type: String
        }
    },
    data () {
        return {
            posts: []
        }
    },
    mounted () {
       this.getPosts()
    },
    methods: {
        getPosts () {
            let params = {}
            if(this.category != '') {
                params.category = this.category
            }
            window.axios.get('/api/posts', {params})
                .then(response => {
                    this.posts = response.data.data
                    this.calculateImageCount()
                })
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The masonry layout is achieved with CSS Grid and a javascript calculation for each post that works out how many grid rows it needs to take up, but it can't start to work that out, until all the images have loaded and rendered on the browser, and since not all posts have images, I calculate how many of the posts have images.

data () {
    return {   
        imageCounter: 0
    }
},
methods: {
    calculateImageCount () {
        for (let i = 0; i < this.posts.length; i++) {
            if (this.posts[i].media.featured.medium != '') {
                this.imageCounter++
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Then in the template, when looping through the posts, I mark when each image is loaded, and increment a counter.

<template>
    <div class="masonry">
        <div v-for="(post, key) in posts"
             :key="key"
             class="card"
        >
            <div class="card-content">
                <div v-if="post.media.featured.medium != ''">
                    <img :src="post.media.featured.medium" :alt="post.title" class="img-responsive" @load="rendered">
                </div>
                <div class="p-20">
                    <a :href="post.link">
                        <h3>{{ post.title }}</h3>
                    </a>
                    <a v-for="category in post.categories" :key="category.id" :href="'/blog?category=' + category.slug">{{ category.title }} <br></a>
                    <p class="font-12">Posted on {{ post.published_at }}</p>
                </div>
            </div>
        </div>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode
export default {
    data () {
        return {
            imagesCount: 0
        }
    },
    methods: {
        rendered () {
            this.imagesCount++
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I watch the image counter and when it gets to the same number of images I know I’m expecting, I can calculate the height of the post cards.

watch: {
    imagesCount: function () {
        if(this.imagesCount == this.imageCounter){
            this.resizeAllMasonryItems()
        }
    }
},
Enter fullscreen mode Exit fullscreen mode

Then the magic happens. the resizeAllMasonryItems() method loops through all the posts now that all images have loaded, and calls another method to actually resize the item, and it does that by applying a grid-row-end: span [dynamic number] style to each post card.

resizeAllMasonryItems () {
    // Get all item class objects in one list
    let allItems = document.getElementsByClassName('card');

    /*
     * Loop through the above list and execute the spanning function to
     * each list-item (i.e. each masonry item)
     */
    for (let i = 0; i < allItems.length; i++) {
        this.resizeMasonryItem(allItems[i]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Each item gets passed into the resizeMasonryItem() method.

resizeMasonryItem (item) {
    /* Get the grid object, its row-gap, and the size of its implicit rows */
    let grid = document.getElementsByClassName('masonry')[0],
        rowGap = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-row-gap')),
        rowHeight = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'));

    /*
     * Spanning for any brick = S
     * Grid's row-gap = G
     * Size of grid's implicitly create row-track = R
     * Height of item content = H
     * Net height of the item = H1 = H + G
     * Net height of the implicit row-track = T = G + R
     * S = H1 / T
     */

    let rowSpan = Math.ceil((item.querySelector('.card-content').getBoundingClientRect().height + rowGap) / (rowHeight + rowGap));

    /* Set the spanning as calculated above (S) */
    item.style.gridRowEnd = 'span ' + rowSpan;
},
Enter fullscreen mode Exit fullscreen mode

I style the parent element with some basic CSS, using CSS Grid.

<style type="text/css">
    .masonry {
        display: grid;
        grid-gap: 15px;
        grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
        grid-auto-rows: 0;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Finally, when the component is created I put event listeners on load and resize of the page to keep calculating the card heights. Technically I don’t even need the load event listener, but seems nice to have it there.

created () {
    let masonryEvents = ['load', 'resize'];
    let vm = this
    masonryEvents.forEach(function (event) {
        window.addEventListener(event, vm.resizeAllMasonryItems);
    });
}
Enter fullscreen mode Exit fullscreen mode

That’s it, now I have a pretty cool masonry layout for my blog posts. The whole thing looks like this. And it's responsive too.

<template>
    <div class="masonry">
        <div v-for="(post, key) in posts"
             :key="key"
             class="card"
        >
            <div class="card-content">
                <div v-if="post.media.featured.medium != ''">
                    <img :src="post.media.featured.medium" :alt="post.title" class="img-responsive" @load="rendered">
                </div>
                <div class="p-20">
                    <a :href="post.link">
                        <h3>{{ post.title }}</h3>
                    </a>
                    <a v-for="category in post.categories" :key="category.id" :href="'/blog?category=' + category.slug">{{ category.title }} <br></a>
                    <p class="font-12">Posted on {{ post.published_at }}</p>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        name: 'Posts',
        props: {
            category: {
                type: String
            }
        },
        data () {
            return {
                posts: [],
                imageCounter: 0,
                imagesCount: 0
            }
        },
        mounted () {
            this.getPosts()
        },
        created () {
            let masonryEvents = ['load', 'resize'];
            let vm = this
            masonryEvents.forEach(function (event) {
                window.addEventListener(event, vm.resizeAllMasonryItems);
            });
        },
        watch: {
            imagesCount: function () {
                if(this.imagesCount == this.imageCounter){
                    this.resizeAllMasonryItems()
                }
            }
        },
        methods: {
            rendered () {
                this.imagesCount++
            },
            getPosts () {
                let params = {}
                if(this.category != '') {
                    params.category = this.category
                }
                window.axios.get('/api/posts', {params})
                    .then(response => {
                        this.posts = response.data.data
                        this.calculateImageCount()
                    })
            },
            calculateImageCount () {
                for (let i = 0; i < this.posts.length; i++) {
                    if (this.posts[i].media.featured.medium != '') {
                        this.imageCounter++
                    }
                }
            },
            resizeMasonryItem (item) {
                /* Get the grid object, its row-gap, and the size of its implicit rows */
                let grid = document.getElementsByClassName('masonry')[0],
                    rowGap = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-row-gap')),
                    rowHeight = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'));

                /*
                 * Spanning for any brick = S
                 * Grid's row-gap = G
                 * Size of grid's implicitly create row-track = R
                 * Height of item content = H
                 * Net height of the item = H1 = H + G
                 * Net height of the implicit row-track = T = G + R
                 * S = H1 / T
                 */

                let rowSpan = Math.ceil((item.querySelector('.card-content').getBoundingClientRect().height + rowGap) / (rowHeight + rowGap));

                /* Set the spanning as calculated above (S) */
                item.style.gridRowEnd = 'span ' + rowSpan;
            },
            resizeAllMasonryItems () {
                // Get all item class objects in one list
                let allItems = document.getElementsByClassName('card');

                /*
                 * Loop through the above list and execute the spanning function to
                 * each list-item (i.e. each masonry item)
                 */
                for (let i = 0; i < allItems.length; i++) {
                    this.resizeMasonryItem(allItems[i]);
                }
            }
        }
    }
</script>

<style lang="scss" type="text/css">
    .masonry {
        display: grid;
        grid-gap: 15px;
        grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
        grid-auto-rows: 0;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
marcusjohnsson profile image
Marcus Johnsson • Edited

Really enjoyed that. Thanks! If you feel like it, and have time Id love to get a source of the masonry. Its really good and its easier to work with a git repo (especially for a noob like me. Just a humble wish.

Take care and keep being awesome!

Collapse
 
jovialcore profile image
Chidiebere Chukwudi

Thanks for sharing chief...I opened it to read it in vuejs only to realise you did it with laravel too..Thanks for sharing