Search code examples
javascriptscrollvue.jsvue-component

How to scroll to bottom of div when new elements are added


I am trying to make a chat application using Vue and Express.

At the moment, I want to make the container with the messages automatically scroll to the bottom when a new message is sent. I tried to do this by using a scrollToEnd function that selects the div container and assigns its scrollHeight to the scrollTop:

scrollToEnd: function () {
    var messages = this.$el.querySelector('#messages')
    messages.scrollTop = messages.scrollHeight
}

This gives the following error:

TypeError: Cannot read property 'scrollHeight' of null

For some reason, using the querySelector always returns null, also when I am testing it on other elements.

Below the full code for the component can be found.

<template>
    <div id="messages">
        <ul>
            <li v-for="msg in messages.slice().reverse()">{{ msg.message }}</li>
        </ul>
    </div>
</template>

<script>
import MessageService from '@/services/MessageService'

export default {
    name: 'messages',
    data () {
        return {
            messages: []
        }
    },
    mounted () {
        this.getMessages()

        this.$root.$on('newMessage', (msg) => {
            this.message = msg
            this.getMessages()
            this.scrollToEnd()
        })
    },
    methods: {
        async getMessages () {
            const response = await MessageService.fetchMessages()
            this.messages = response.data.messages
        },
        scrollToEnd: function () {
            var messages = this.$el.querySelector('#messages')
            messages.scrollTop = messages.scrollHeight
        }
    }
}
</script>

Solution

  • this.$el

    The root DOM element that the Vue instance is managing.

    this.$el is the #messages div, there's no need to fetch it from the DOM.

    Then, you could use this.$el.lastElementChild.offsetTop to get the last message and scroll to its top, so if it's long, you're not scrolling past its starting point.

    Here, I simplified the template a little to make it straight to the point.

    <template>
        <ul id="messages">
            <li v-for="msg in messages.slice().reverse()">{{ msg.message }}</li>
        </ul>
    </template>
    
    <script>
    export default {
        name: 'messages',
        data() {
            return { messages: [] };
        },
        mounted() {
            this.getMessages();
        },
        updated() {
            // whenever data changes and the component re-renders, this is called.
            this.$nextTick(() => this.scrollToEnd());
        },
        methods: {
            async getMessages () {
                // ...snip...
            },
            scrollToEnd: function () {
                // scroll to the start of the last message
                this.$el.scrollTop = this.$el.lastElementChild.offsetTop;
            }
        }
    }
    </script>
    

    If you really want to keep the <div> container, you could use a ref.

    <template>
        <div id="messages">
            <ul ref="list">
                <li v-for="msg in messages.slice().reverse()">{{ msg.message }}</li>
            </ul>
        </div>
    </template>
    

    Then in the component, you can refer to it with this.$refs.list.

    ref is used to register a reference to an element or a child component. The reference will be registered under the parent component’s $refs object. If used on a plain DOM element, the reference will be that element; if used on a child component, the reference will be component instance.

    While Vue examples often use the native DOM API to get around, using ref in this instance is way easier.