I built a Vue component that grows chart bars and counts up their values as they enter the viewport. Anime.js controls the bar heights, while CountUp animates the numbers.
The example uses Vue with TypeScript, Tailwind CSS, Anime.js, and vue-countup-v3. The custom color classes and arrow SVG import come from the original project.
Vue component
Each item provides a number, label, and chart_size for the bar height. Optional prefixes and suffixes format the number, while change details appear inside the bar.
<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>How it works
The template renders a grid of chart items. On mount, the component calculates the bar heights and prepares the animations using the initial viewport width.
Bar heights
chartSizeModifier scales each chart_size by the viewport width: 3 at 1024px and above, 2 above 640px, and 1.7 otherwise. The largest value also helps determine the container height.
At widths below 1024px, the bars take their final heights immediately and their inner content becomes visible. This skips the bar animation; CountUp still handles the numbers separately.
Scroll trigger
At widths of 1024px and above, Anime.js prepares a paused animation for each bar, starting at 10px and growing to its calculated height over two seconds.
Intersection Observer watches each bar. When at least 20% is visible, it plays the animation and stops observing that bar, so the animation runs once. The completion callback reveals the change details.
Number animation
CountUp uses its own scroll trigger through enableScrollSpy. scrollSpyOnce runs the count once, and duration sets it to two seconds. Each item can supply a prefix or suffix.
Adjust the effect
Change chartSizeModifier to adjust the bar heights, the Anime.js duration to change their speed, or the observer threshold to change when they start. Set the CountUp duration separately to control the number animation.