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

@@ -80,7 +80,8 @@ export const CATEGORIES = [
'Flowing Menu',
'Elastic Slider',
'Stack',
'Chroma Grid'
'Chroma Grid',
'Counter'
]
},
{

View File

@@ -69,6 +69,7 @@ const components = {
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
'stack': () => import('../demo/Components/StackDemo.vue'),
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
'counter': () => import('../demo/Components/CounterDemo.vue'),
};
const backgrounds = {

View File

@@ -0,0 +1,17 @@
import code from '@content/Components/Counter/Counter.vue?raw'
import { createCodeObject } from '../../../types/code'
export const counter = createCodeObject(code, 'Components/Counter', {
installation: `npm i motion-v`,
usage: `import Counter from './Counter.vue'
<Counter
:value="1"
:places="[100, 10, 1]"
:fontSize="80"
:padding="5"
:gap="10"
textColor="white"
:fontWeight="900"
/>`,
})

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>

View File

@@ -0,0 +1,195 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container h-[400px] overflow-hidden relative">
<Counter
:value="value"
:places="[100, 10, 1]"
gradientFrom="#060010"
:fontSize="fontSize"
:padding="5"
:gap="gap"
:borderRadius="10"
:horizontalPadding="15"
textColor="white"
:fontWeight="900"
/>
<div class="flex gap-4 bottom-4 justify-center mt-4 absolute left-1/2 transform -translate-x-1/2">
<button
class="bg-[#170D27] rounded-[10px] border border-[#271E37] hover:bg-[#271E37] text-white h-10 w-10 transition-colors"
@click="value > 0 && value--"
>
-
</button>
<button
class="bg-[#170D27] rounded-[10px] border border-[#271E37] hover:bg-[#271E37] text-white h-10 w-10 transition-colors"
@click="value < 999 && value++"
>
+
</button>
</div>
</div>
<Customize>
<PreviewSlider
title="Value"
v-model="value"
:min="0"
:max="999"
:step="1"
/>
<PreviewSlider
title="Gap"
v-model="gap"
:min="0"
:max="50"
:step="5"
/>
<PreviewSlider
title="Font Size"
v-model="fontSize"
:min="40"
:max="200"
:step="10"
/>
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['motion-v']" />
</template>
<template #code>
<CodeExample :code-object="counter" />
</template>
<template #cli>
<CliInstallation :command="counter.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import PropTable from '../../components/common/PropTable.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import Counter from '../../content/Components/Counter/Counter.vue'
import { counter } from '../../constants/code/Components/counterCode'
const value = ref(123)
const fontSize = ref(80)
const gap = ref(10)
const propData = [
{
name: "value",
type: "number",
default: "N/A (required)",
description: "The numeric value to display in the counter."
},
{
name: "fontSize",
type: "number",
default: "100",
description: "The base font size used for the counter digits."
},
{
name: "padding",
type: "number",
default: "0",
description: "Additional padding added to the digit height."
},
{
name: "places",
type: "number[]",
default: "[100, 10, 1]",
description: "An array of place values to determine which digits to display."
},
{
name: "gap",
type: "number",
default: "8",
description: "The gap (in pixels) between each digit."
},
{
name: "borderRadius",
type: "number",
default: "4",
description: "The border radius (in pixels) for the counter container."
},
{
name: "horizontalPadding",
type: "number",
default: "8",
description: "The horizontal padding (in pixels) for the counter container."
},
{
name: "textColor",
type: "string",
default: "'white'",
description: "The text color for the counter digits."
},
{
name: "fontWeight",
type: "string | number",
default: "'bold'",
description: "The font weight of the counter digits."
},
{
name: "containerStyle",
type: "CSSProperties",
default: "{}",
description: "Custom inline styles for the outer container."
},
{
name: "counterStyle",
type: "CSSProperties",
default: "{}",
description: "Custom inline styles for the counter element."
},
{
name: "digitStyle",
type: "CSSProperties",
default: "{}",
description: "Custom inline styles for each digit container."
},
{
name: "gradientHeight",
type: "number",
default: "16",
description: "The height (in pixels) of the gradient overlays."
},
{
name: "gradientFrom",
type: "string",
default: "'black'",
description: "The starting color for the gradient overlays."
},
{
name: "gradientTo",
type: "string",
default: "'transparent'",
description: "The ending color for the gradient overlays."
},
{
name: "topGradientStyle",
type: "CSSProperties",
default: "undefined",
description: "Custom inline styles for the top gradient overlay."
},
{
name: "bottomGradientStyle",
type: "CSSProperties",
default: "undefined",
description: "Custom inline styles for the bottom gradient overlay."
},
]
</script>