
Recently, I worked on a Vue component that brings charts to life with smooth animations. In this post, I'll walk through my implementation, which uses AnimeJS for height animations, CountUp for animated numbers, and the Intersection Observer API to trigger the animations when the elements become visible on screen.
Overview
The idea is simple: we display a set of chart items—each with a numerical value that counts up as it comes into view—and a colored bar whose height expands via animation. When viewed on smaller screens, the animations are disabled for performance and simplicity, and everything appears instantly. For larger screens, AnimeJS smoothly transitions the chart bars from a minimal height to their full size.
The Vue Component
Below is the full Vue component implementation:
<template>
<div class="container flex flex-col xl:flex-row items-center justify-center">
<div class="sm:w-full bg-gray-150 rounded-xl pt-6 lg:pt-12 xl:basis-[60%]">
<h2
class="mb-2 text-center text-3xl lg:mx-8 lg:mb-4 lg:basis-[40%] lg:text-left lg:text-4xl"
v-html="props.title"
></h2>
<div
ref="chartContainer"
class="grid grid-cols-2 sm:grid-cols-4 gap-2 rounded-3xl px-4 items-end justify-center lg:basis-[60%] lg:gap-4 xl:mx-6 xl:px-6 2xl:gap-6"
>
<div v-for="item in props.items" class="group px-4 pt-2 text-left lg:px-6">
<div class="text-2xl sm:text-3xl font-bold">
<CountUp
:end-val="item['number']"
:options="{
enableScrollSpy: true,
scrollSpyOnce: true,
duration: 2,
prefix: item['prefix'] ?? '',
suffix: item['suffix'] ?? '',
}"
></CountUp>
</div>
<p class="text-xs lg:text-sm mb-2 lg:mb-4" v-html="item.text"></p>
<div
:id="item.id"
class="chart-item w-full h-[10px] group-odd:bg-primary-dark-gray group-even:bg-transparent rounded-t-2xl group-even:border-t-2 group-even:border-x-2 group-even:border-black"
>
<div
v-if="item.change?.length > 0"
class="inner-content p-4 lg:p-6 xl:px-2 xl:py-4 flex flex-col items-center justify-start transition duration-300 opacity-0"
>
<div
class="px-4 xl:px-2 2xl:px-4 py-2 xl:py-1 2xl:py-2 mb-1 text-xs font-bold lg:text-sm rounded-full group-odd:bg-primary-yellow group-even:bg-[#00F0B5] inline-flex items-center"
>
{{ item.change }}
<span v-html="Arrow" class="h-4 w-4 ml-2"></span>
</div>
<div class="group-odd:text-white text-xs" v-html="item.change_text"></div>
</div>
</div>
</div>
</div>
</div>
<div class="h-full w-full xl:basis-[40%] bg-gray-150 rounded-xl"></div>
</div>
</template>
<script setup lang="ts">
import CountUp from 'vue-countup-v3'
import Arrow from '@assets/svg/arrow-up.svg?raw'
import { ref, onMounted, defineProps } from 'vue'
import anime from 'animejs'
const props = defineProps<{
items: {
id: string
number: number
text: string
prefix?: string
suffix?: string
chart_size: number
change: string
change_text: string
}[]
title: String
}>()
const chartContainer = ref(null)
onMounted(() => {
const innerWidth = window.innerWidth
const chartSizeModifier = innerWidth >= 1024 ? 3 : innerWidth > 640 ? 2 : 1.7
const chartItems = chartContainer.value.querySelectorAll('.chart-item')
// Determine the maximum chart size from the items.
const maxChartSize = Math.max(...props.items.map((item) => item.chart_size))
// Adjust the height of the chart container based on screen width.
chartContainer.value.style.height =
(maxChartSize * chartSizeModifier + chartContainer.value.offsetHeight) *
(innerWidth < 640 ? 1.5 : 1) +
'px'
// Create an object to hold our animations for each chart element.
const animationsObject = {}
chartItems.forEach((i) => {
const height = props.items.find((item) => item.id === i.id)?.chart_size
if (innerWidth < 1024) {
// For smaller screens, set height immediately without animation.
i.style.height = height * chartSizeModifier + 'px'
const innerContent = i.querySelector('.inner-content')
if (innerContent) {
innerContent.style.opacity = 1
}
return
}
// For larger screens, prepare the animation using AnimeJS.
animationsObject[i.id] = anime({
targets: i,
height: ['10px', height * chartSizeModifier + 'px'],
easing: 'easeInOutQuad',
autoplay: false,
duration: 2000,
complete: () => {
i.querySelector('.inner-content').style.opacity = 1
},
})
})
// Setup Intersection Observer to play animations when items become visible.
const observerCallback = (entries, observer) => {
entries.forEach((entry) => {
if (innerWidth >= 1024 && entry.isIntersecting) {
animationsObject[entry.target.id].play()
observer.unobserve(entry.target)
}
})
}
const options = {
threshold: 0.2, // trigger when 20% of the element is visible
}
const observer = new IntersectionObserver(observerCallback, options)
chartItems.forEach((element) => {
observer.observe(element)
})
})
</script>
<style scoped>
/* Add your component-specific styles here */
</style>
Breaking Down the Implementation
Component Structure
The template sets up a responsive grid of chart items. Each item consists of:
A number that counts up using the CountUp component.
A text label with additional details.
A chart bar that increases in height with an animation.
The component uses utility classes (likely from Tailwind CSS) to achieve a responsive layout, spacing, and overall styling.
Animations with AnimeJS
Even though AnimeJS is imported (it’s commented out in this snippet), its role is central in animating the height of each chart bar. Here are the key points:
Dynamic Animation Setup: Each chart item is animated from a starting height of 10px to a calculated final height based on the item's chart_size and a chartSizeModifier, which adapts to the screen size.
Intersection Observer: To avoid triggering all animations at once, an Intersection Observer monitors when each chart item becomes visible (at 20% visibility). Once triggered, the corresponding AnimeJS animation plays.
Immediate Rendering on Small Screens: For smaller screens, where performance might be a concern, the animated transition is bypassed. Instead, the chart bars are immediately set to their full height and the inner content fades in.
Using CountUp for Numerical Animation
The CountUp component animates numerical values as they come into view. With options like enableScrollSpy and duration, the numbers smoothly transition to their final state, adding another dynamic layer to the user experience.
Final Thoughts
This implementation effectively combines dynamic animations with responsive design. By detecting screen sizes and using the Intersection Observer, it ensures that the animations are both visually appealing on larger displays and performance-friendly on smaller devices.
If you’re looking to add a subtle, animated chart to your Vue application, combining AnimeJS with CountUp offers flexibility, performance, and a delightful user experience without complicating your codebase. Feel free to adjust parameters such as animation durations, easing functions, or observer thresholds to best align with your design goals.
Happy coding!