Skip to content

Accordion with vue

Submitted by Andrej Galuf on 21.03.2019.

One of the great things about the vue.js are the single file components, simple reusable elements that allow us to add new functionality to our pages. To see just how powerful these components are, we will be building this accordion today:

Accordion

This guide assumes that you have basic experience with Vue and single file components. If you need a short intro, be sure to check the official documentation for a great intro. Ready? Then let's jump right in.

Let's break down how our functionality should work

  1. We have several panels with the title and a hidden body. When the panel title is clicked, the body's visibility is toggled.
  2. When one panel opens, any open panel closes. If the open panel is clicked, that panel is closed and no new panels are opened.
  3. The panels should open with a nice transition.
  4. The developer should be able to simply use components and not have to deal with passing any properties necessary for the mechanism to work.

How would the component architecture look like?

To have only one panel open at a time, all panels need to know at least if they should be open or closed. Since we are building a reusable component, we need a place to store the information on which panel is currently open. The easiest way to do this is to set up a wrapper, which would hold this information:

<accordion-wrapper>
    <accordion-panel>
        <span slot="header">Item-1</span>
        <div>Content for Item-1</div>
    <accordion-panel>
        <span slot="header">Item-2</span>
        <div>Content for Item-2</div>
    </accordion-panel>
    <accordion-panel>
        <span slot="header">Item-3</span>
        <div>Content for Item-3</div>
    </accordion-panel>
</accordion-wrapper>

The Panel component

Let's prepare our panel component:

<template>
    <div class="accordion-panel">
        <div class="panel--header" @click="toggle">
            <slot name="header" />
        </div>
        <div class="panel--body" v-if="visible">
            <slot />
        </div>
    </div>
</template>

<script>
    export default {
        data() {
            return {
                visible: false
            }            
        },
        
        methods: {
            toggle() {
                this.visible = !this.visible;
            }
        }
    }
</script>

<style scoped>
    .accordion-panel {
        box-shadow: 1px 1px 2px #aaa;
        background: white;
    }

    .accordion-panel + .accordion-panel {
        margin-top: 0.5em;
    }

    .panel--header {
        padding: 0.5em;
    }

    .panel--body {
        border-top: 1px solid #ccc;
    }

    .panel--header {
        cursor: pointer;
    }
</style>

Here we have a panel made of two parts, the header and the body, where clicking on the header toggles visibility on the body. Both parts are equipped with slots to be filled with title and content.
Finally, we added a bit of style to make the panels look like actual panels.

The Wrapper component

Now that we have our panels, we need to create the wrapper through which the panels will communicate:

<template>
    <div class="accordion-wrapper">
        <slot></slot>
    </div>
</template>

Not much is happening here yet. We simply have a component with a slot to pass our panels into.

Wrapper - Panel communication

If you try to send information from child to parent, you'll notice that since we are using slots, they're actually communicating with the component where they are used, but not with eachother. This would mean that the user would need to handle opening and closing manually, which isn't exactly what we intended.

If we use console.log(this) in the panel's created() hook, we can see the property $parent and its methods. Although not in the spirit of vue, we could use this property to "register" our panels with the wrapper, then handle the opening and closing on the panel itself.

Let's add a few methods to our wrapper:

</template>
+
+<script>
+    export default {
+        data: () => ({
+            panels: [],
+            open: null
+        }),
+
+        methods: {
+            /**
+             * Register the panel with the wrapper.
+             * This allows the wrapper to track which elements it contains.
+             *
+             * @param panel
+             *   The panel to register.
+             */
+            register(panel) {
+                this.panels.push(panel);
+                // If this is our first panel, set it to open
+                if (this.open === null) {
+                    panel.toggle(true)
+                }
+            },
+
+            /**
+             * Unregister the panel from the wrapper.
+             * When the panel is destroyed, the wrapper should no longer attempt to track it.
+             *
+             * @param panel
+             *   The panel to unregister.
+             */
+            unregister(panel) {
+                if (this.open === panel) {
+                    this.open = null;
+                }
+                const index = this.panels.findIndex(i => i._uid === panel._uid);
+                this.panels.splice(index, 1);
+            },
+
+            /**
+             * Change the open status of the panel.
+             * We have two use cases here: either the panel is already open and needs to be closed,
+             * or a different panel was opened and all other panels need to be closed.
+             *
+             * @param panel
+             *   The panel to change the status for.
+             * @param isOpen
+             *   bool true if the panel should be opened, false if it should be closed.
+             */
+            setOpen(panel, isOpen) {
+                for (let i = 0; i < this.panels.length; i++) {
+                    const item = this.panels[i];
+                    if (isOpen === true && item._uid !== panel._uid) {
+                        item.toggle(false);
+                    }
+                }
+
+                if (isOpen === false) {
+                    this.open = null;
+                } else {
+                    this.open = panel;
+                }
+            }
+        }
+    }
+</script>

We can now use our methods to register a panel with our wrapper:

<template>
    <div class="accordion-panel">
-       <div class="panel--header" @click="toggle">
+       <div class="panel--header" @click="toggle(!visible)">
            <slot name="header" />
        </div>
        <div class="panel--body" v-if="visible">
            <slot />
        </div>
    </div>
</template>

<script>
    export default {
        data() {
            return {
                visible: false
            }            
        },
        
+       beforeMount() {
+           this.$parent.register(this);
+       },
+
+       beforeDestroy() {
+           this.$parent.unregister(this);
+       },
        
        methods: {
-           toggle() {
-               this.visible = !this.visible;
+           toggle (value) {
+               if (value !== this.visible) {
+                   this.visible = value;
+                   this.$parent.setOpen(this, this.visible);
+               }
            }
        }
    }
</script>

What's happening here?

Remember that at the end of the day, Vue's single file components are simple javascript objects. When a new component is mounted, we register the panel object with the wrapper (method: register).

The wrapper can now react to panel's opened status by updating all registered panels and closing them, opening only our selected panel (method: setOpen). Nice, it works.

But this isn't in the spirit of Vue. Communicating with the $parent property directly is discouraged and rightly so. Let's throw a wrench into our accordion. Add another component with a slot between the wrapper and the panels:

<accordion-wrapper>
+    <other-component>
        <accordion-panel>
            <span slot="header">Item-1</span>
            <div>Content for Item-1</div>
        <accordion-panel>
            <span slot="header">Item-2</span>
            <div>Content for Item-2</div>
        </accordion-panel>
        <accordion-panel>
            <span slot="header">Item-3</span>
            <div>Content for Item-3</div>
        </accordion-panel>
+    </other-component>
</accordion-wrapper>

Our accordion no longer works. Why not? this.$parent references the closest parent with a slot - in our case, that is now other-component, where register, unregister and setOpen don't exist. Surely Vue is smarter than that? Turns out it is indeed.

The wrapper can provide the methods to all its children. The children can then inject these methods and use them directly, as if they were their own.

Update the wrapper component:

            open: null
        }),

+        provide() {
+            return {
+                register: this.register,
+                unregister: this.unregister,
+                setOpen: this.setOpen,
+            }
+        },     
+
        methods: {
            /**

Update the panel component:

<script>
        inject: ['register', 'unregister', 'setOpen'],
        
        beforeMount() {
-            this.$parent.register(this);
+            this.register(this);
        },

        beforeDestroy() {
-            this.$parent.unregister(this);
+            this.unregister(this);
        },
        
        methods: {
            toggle (value) {
                if (value !== this.visible) {
                    this.visible = value;
-                    this.$parent.setOpen(this, this.visible);
+                    this.setOpen(this, this.visible);
                }
            }
        }
    }
</script>

Nice, our accordion works again! And if we create an accordion within an accordion, they will still work, because the child's methods overwrite the parent.

Last, but not least - the animations

Of course, our accordion would not be complete if we didn't animate it. Explaining how Vue animations work is out of scope of this article, so if you don't know transitions yet, simply copy this component:

<template>
    <transition name="expand" @enter="enter" @afterEnter="afterEnter" @leave="leave">
        <slot></slot>
    </transition>
</template>

<script>
    export default {
        methods: {
            afterEnter(element) {
                element.style.height = 'auto';
            },

            enter(element) {
                const { height } = getComputedStyle(element);

                element.style.height = 0;

                setTimeout(() => {
                    element.style.height = height;
                });
            },

            leave(element) {
                const { height } = getComputedStyle(element);

                element.style.height = height;

                setTimeout(() => {
                    element.style.height = 0;
                });
            }
        }
    }
</script>

<style scoped>
    .expand-enter-active,
    .expand-leave-active {
        transition-duration: 0.3s;
        transition-property: height, opacity;
        transition-timing-function: ease;
        overflow: hidden;
    }

    .expand-enter,
    .expand-leave-active {
        opacity: 0
    }
</style>

Register the component in the panel and wrap the body with the transition:

<template>
    <div class="accordion-panel">
        <div class="panel--header" @click="toggle(!visible)">
            <slot name="header" />
        </div>
+        <transition-expand>
            <div class="panel--body" v-if="visible">
                <slot />
            </div>
+        </transition-expand>
    </div>
</template>

<script>
+    import TransitionExpand from '~/components/transitions/TransitionExpand';

    export default {
+        components: {
+            TransitionExpand
+        },
        
        inject: ['register', 'unregister', 'setOpen'],

One last thing is missing: we should use an icon to show that the panel can be opened. You can use anything your heart desires, from svg to material icons, but for the purposes of this guide we will simply use the character "<":

Add the following lines in the panel:

<template>
    <div class="accordion-panel">
        <div class="panel--header" @click="toggle(!visible)">
            <slot name="header" />
+            <div :class="indicatorClasses"><</div>
        </div>
        <transition-expand>
            <div class="panel--body" v-if="visible">
                <slot />
            </div>
        </transition-expand>
    </div>
</template>

<script>
        data() {
            return {
                visible: false
            }
        },

+        computed: {
+            indicatorClasses() {
+                const classes = ['indicator'];
+                if (this.visible) {
+                    classes.push('is-open');
+                }
+
+                return classes;
+            }
+        },
+
        beforeMount() {
            this.register(this);
        },


    .panel--header {
        cursor: pointer;
    }

+    .indicator {
+        transform: rotate(0deg);
+        transition: transform .2s linear;
+        float: right;
+        color: #777;
+        font-size: 1.1em;
+    }
+
+    .indicator.is-open {
+         transform: rotate(-90deg);
+         transition: transform .2s linear;
+    }
</style>

There we go, we've now added the icon that rotates when the panel opens/closes and our accordion is done.

Don't forget to write unit tests.