Create <Counter /> component

This commit is contained in:
Alfarish Fizikri
2025-07-23 09:16:28 +07:00
parent 53c5b5e208
commit 82f1a6fcbd
5 changed files with 434 additions and 1 deletions

View File

@@ -0,0 +1,219 @@
<template>
<div class="counter-container" :style="containerStyle">
<div
class="counter-counter"
:style="counterStyles"
>
<div
v-for="place in places"
:key="place"
class="counter-digit"
:style="digitStyles"
>
<Motion
v-for="digit in 10"
:key="digit - 1"
tag="span"
class="counter-number"
:animate="{ y: getDigitPosition(place, digit - 1) }"
>
{{ digit - 1 }}
</Motion>
</div>
</div>
<div class="gradient-container">
<div class="top-gradient" :style="topGradientStyle || topGradientStyles" />
<div class="bottom-gradient" :style="bottomGradientStyle || bottomGradientStyles" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Motion } from 'motion-v';
import type { CSSProperties } from 'vue';
interface CounterProps {
value: number;
fontSize?: number;
padding?: number;
places?: number[];
gap?: number;
borderRadius?: number;
horizontalPadding?: number;
textColor?: string;
fontWeight?: string | number;
containerStyle?: CSSProperties;
counterStyle?: CSSProperties;
digitStyle?: CSSProperties;
gradientHeight?: number;
gradientFrom?: string;
gradientTo?: string;
topGradientStyle?: CSSProperties;
bottomGradientStyle?: CSSProperties;
}
const props = withDefaults(defineProps<CounterProps>(), {
fontSize: 100,
padding: 0,
places: () => [100, 10, 1],
gap: 8,
borderRadius: 4,
horizontalPadding: 8,
textColor: 'white',
fontWeight: 'bold',
containerStyle: () => ({}),
counterStyle: () => ({}),
digitStyle: () => ({}),
gradientHeight: 16,
gradientFrom: 'black',
gradientTo: 'transparent',
topGradientStyle: undefined,
bottomGradientStyle: undefined
});
const digitHeight = computed(() => props.fontSize + props.padding);
const counterStyles = computed(() => ({
fontSize: `${props.fontSize}px`,
gap: `${props.gap}px`,
borderRadius: `${props.borderRadius}px`,
paddingLeft: `${props.horizontalPadding}px`,
paddingRight: `${props.horizontalPadding}px`,
color: props.textColor,
fontWeight: props.fontWeight,
...props.counterStyle
}));
const digitStyles = computed(() => ({
height: `${digitHeight.value}px`,
...props.digitStyle
}));
const topGradientStyles = computed((): CSSProperties => ({
height: `${props.gradientHeight}px`,
background: `linear-gradient(to bottom, ${props.gradientFrom}, ${props.gradientTo})`
}));
const bottomGradientStyles = computed((): CSSProperties => ({
height: `${props.gradientHeight}px`,
background: `linear-gradient(to top, ${props.gradientFrom}, ${props.gradientTo})`
}));
const springValues = ref<Record<number, number>>({});
const initializeSpringValues = () => {
props.places.forEach(place => {
springValues.value[place] = Math.floor(props.value / place);
});
};
initializeSpringValues();
watch(
() => props.value,
(newValue, oldValue) => {
if (newValue === oldValue) return;
props.places.forEach(place => {
const newRoundedValue = Math.floor(newValue / place);
const oldRoundedValue = springValues.value[place];
if (newRoundedValue !== oldRoundedValue) {
springValues.value[place] = newRoundedValue;
}
});
},
{ immediate: true }
);
watch(
() => digitHeight.value,
() => {
positionCache.clear();
}
);
const positionCache = new Map<string, number>();
const getDigitPosition = (place: number, digit: number): number => {
const springValue = springValues.value[place] || 0;
const cacheKey = `${place}-${digit}-${springValue}`;
if (positionCache.has(cacheKey)) {
return positionCache.get(cacheKey)!;
}
const placeValue = springValue % 10;
const offset = (10 + digit - placeValue) % 10;
let position = offset * digitHeight.value;
if (offset > 5) {
position -= 10 * digitHeight.value;
}
if (positionCache.size > 200) {
const firstKey = positionCache.keys().next().value;
if (typeof firstKey === 'string') {
positionCache.delete(firstKey);
}
}
positionCache.set(cacheKey, position);
return position;
};
</script>
<style scoped>
.counter-container {
position: relative;
display: inline-block;
}
.counter-counter {
display: flex;
overflow: hidden;
line-height: 1;
}
.counter-digit {
position: relative;
width: 1ch;
font-variant-numeric: tabular-nums;
}
.counter-number {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.gradient-container {
pointer-events: none;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.top-gradient {
position: absolute;
top: 0;
width: 100%;
}
.bottom-gradient {
position: absolute;
bottom: 0;
width: 100%;
}
</style>