mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-13 13:48:26 +00:00
Add motion counter to hero
This commit is contained in:
@@ -75,6 +75,10 @@
|
||||
"name": "layout",
|
||||
"title": "Layout"
|
||||
},
|
||||
{
|
||||
"name": "letters-digits",
|
||||
"title": "Letters & digits"
|
||||
},
|
||||
{
|
||||
"name": "mail",
|
||||
"title": "Mail"
|
||||
@@ -97,7 +101,7 @@
|
||||
},
|
||||
{
|
||||
"name": "navigation",
|
||||
"title": "Navigation & Places"
|
||||
"title": "Navigation & places"
|
||||
},
|
||||
{
|
||||
"name": "notifications",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { animate, useInView, useReducedMotion } from 'motion-v';
|
||||
|
||||
/**
|
||||
* Springs a number up from 0 once it scrolls into view.
|
||||
* Ported from motion-counter by Tanja Gomilar (MIT): https://github.com/tgomilar/motion-components
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: number;
|
||||
suffix?: string;
|
||||
/** Spring duration, in seconds. */
|
||||
duration?: number;
|
||||
/** Delay before counting starts, in seconds. */
|
||||
delay?: number;
|
||||
}>(),
|
||||
{
|
||||
suffix: '',
|
||||
duration: 1.5,
|
||||
delay: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
const inView = useInView(el, { once: true, amount: 0.2 });
|
||||
const reducedMotion = useReducedMotion();
|
||||
const current = ref(0);
|
||||
|
||||
const format = (value: number) => `${Math.round(value).toLocaleString('en-US')}${props.suffix}`;
|
||||
const finalText = computed(() => format(props.value));
|
||||
|
||||
let controls: ReturnType<typeof animate> | undefined;
|
||||
|
||||
watch(inView, (isInView) => {
|
||||
if (!isInView) return;
|
||||
|
||||
if (reducedMotion.value) {
|
||||
current.value = props.value;
|
||||
return;
|
||||
}
|
||||
|
||||
controls = animate(0, props.value, {
|
||||
type: 'spring',
|
||||
duration: props.duration,
|
||||
delay: props.delay,
|
||||
bounce: 0.05,
|
||||
onUpdate: (latest) => {
|
||||
current.value = latest;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => controls?.stop());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="el"
|
||||
class="motion-counter"
|
||||
>
|
||||
<span class="visually-hidden">{{ finalText }}</span>
|
||||
<span aria-hidden="true">{{ format(current) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.motion-counter {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@ import SponsorSection from './SponsorSection.vue';
|
||||
<template>
|
||||
<SponsorSection class="cta">
|
||||
<div class="cta-card">
|
||||
<h2 class="title">Built something with Lucide?</h2>
|
||||
<h2 class="title">You like building with Lucide?</h2>
|
||||
<p class="description">
|
||||
Help keep it beautiful and consistent for everyone who comes after you.
|
||||
</p>
|
||||
@@ -55,8 +55,8 @@ import SponsorSection from './SponsorSection.vue';
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
margin-bottom: 24px;
|
||||
max-width: 640px;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
color: var(--vp-c-text-2);
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import MotionCounter from '../base/MotionCounter.vue';
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
import SponsorHeroAnimation from './SponsorHeroAnimation.vue';
|
||||
import { data } from './SponsorStats.data';
|
||||
import { allTimeSponsorCount } from './sponsors';
|
||||
|
||||
/** Rounds down to a short "120M+" / "25k+" figure. */
|
||||
function formatCompact(value: number) {
|
||||
if (value >= 1_000_000) return `${Math.floor(value / 1_000_000)}M+`;
|
||||
if (value >= 1_000) return `${Math.floor(value / 1_000)}k+`;
|
||||
return value.toLocaleString('en-US');
|
||||
/** Rounds down to a short "120M+" / "25k+" figure, split so the counter only counts the number. */
|
||||
function toCompact(value: number) {
|
||||
if (value >= 1_000_000) return { value: Math.floor(value / 1_000_000), suffix: 'M+' };
|
||||
if (value >= 1_000) return { value: Math.floor(value / 1_000), suffix: 'k+' };
|
||||
return { value, suffix: '' };
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ value: data.weeklyDownloads, label: 'Weekly downloads', format: formatCompact },
|
||||
{ value: data.stars, label: 'GitHub stars', format: formatCompact },
|
||||
{ value: data.weeklyDownloads, label: 'Weekly downloads', format: toCompact },
|
||||
{ value: data.stars, label: 'GitHub stars', format: toCompact },
|
||||
{
|
||||
value: data.iconsCount,
|
||||
label: 'Icons',
|
||||
format: (value: number) => `${(Math.floor(value / 100) * 100).toLocaleString('en-US')}+`,
|
||||
format: (value: number) => ({ value: Math.floor(value / 100) * 100, suffix: '+' }),
|
||||
},
|
||||
{
|
||||
value: allTimeSponsorCount,
|
||||
label: 'All-time sponsors',
|
||||
format: (value: number) => value.toLocaleString('en-US'),
|
||||
format: (value: number) => ({ value, suffix: '' }),
|
||||
},
|
||||
]
|
||||
// Stats the build couldn't fetch are left out.
|
||||
.filter((stat): stat is typeof stat & { value: number } => stat.value != null)
|
||||
.map(({ value, label, format }) => ({ value: format(value), label }));
|
||||
.map(({ value, label, format }) => ({ ...format(value), label }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,25 +54,33 @@ const stats = [
|
||||
theme="alt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl class="stats">
|
||||
<div
|
||||
v-for="(stat, index) in stats"
|
||||
:key="stat.label"
|
||||
class="stat"
|
||||
>
|
||||
<dt class="stat-label">{{ stat.label }}</dt>
|
||||
<dd class="stat-value">
|
||||
<MotionCounter
|
||||
:value="stat.value"
|
||||
:suffix="stat.suffix"
|
||||
:delay="0.5 +(index * 0.1)"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<dl class="stats">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="stat"
|
||||
>
|
||||
<dt class="stat-label">{{ stat.label }}</dt>
|
||||
<dd class="stat-value">{{ stat.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<SponsorHeroAnimation class="hero-animation" />
|
||||
</div>
|
||||
</SponsorSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
padding-top: 96px;
|
||||
padding-top: 48px;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
@@ -78,10 +88,11 @@ const stats = [
|
||||
flex-wrap: wrap;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
flex-direction: column-reverse;
|
||||
|
||||
}
|
||||
|
||||
.intro {
|
||||
flex: 999 1 520px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -117,13 +128,10 @@ const stats = [
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex: 1 1 340px;
|
||||
min-width: 0;
|
||||
max-width: 560px;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px 16px;
|
||||
@@ -151,7 +159,24 @@ const stats = [
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-animation {
|
||||
display: block;
|
||||
flex: 1 1 340px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0 -24px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.stats {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hero {
|
||||
padding-top: 96px;
|
||||
}
|
||||
.title {
|
||||
font-size: 56px;
|
||||
line-height: 64px;
|
||||
@@ -162,4 +187,10 @@ const stats = [
|
||||
line-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.hero-content {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,83 +6,32 @@ import { motion, type Options as MotionOptions } from 'motion-v';
|
||||
type Variants = MotionOptions['variants'];
|
||||
|
||||
/**
|
||||
* The logo drawing from the home hero animation, played once and stopped at the coloured logo.
|
||||
* Draws the heart icon the way the SVG editor previews it (handles and arc radii),
|
||||
* played once and stopped at the red heart.
|
||||
*/
|
||||
|
||||
const LOGO_PATHS = [
|
||||
'M14 12C14 9.79086 12.2091 8 10 8C7.79086 8 6 9.79086 6 12C6 16.4183 9.58172 20 14 20C18.4183 20 22 16.4183 22 12C22 8.446 20.455 5.25285 18 3.05557',
|
||||
'M10 12C10 14.2091 11.7909 16 14 16C16.2091 16 18 14.2091 18 12C18 7.58172 14.4183 4 10 4C5.58172 4 2 7.58172 2 12C2 15.5841 3.57127 18.8012 6.06253 21',
|
||||
];
|
||||
const HEART_PATH =
|
||||
'M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5';
|
||||
|
||||
const MARKERS = [
|
||||
[14, 12],
|
||||
[18, 3.05557],
|
||||
[10, 12],
|
||||
[6.06253, 21],
|
||||
];
|
||||
const VERTICES =
|
||||
'M2 9.5h.01M11.591 5.824h.01M12.409 5.824h.01M22 9.5h.01M19 15h.01M13.508 20.313h.01M10.508 20.332h.01M5 15h.01';
|
||||
|
||||
const MARKERS = [[2, 9.5]];
|
||||
|
||||
/** Bezier handles as [x1, y1, x2, y2] segments, grouped by when they appear. */
|
||||
const HANDLE_GROUPS = [
|
||||
{ delay: 0.2, segments: [[14, 12, 14, 9.79086]] },
|
||||
{
|
||||
delay: 0.4,
|
||||
segments: [
|
||||
[10, 8, 12.2091, 8],
|
||||
[10, 8, 7.79086, 8],
|
||||
],
|
||||
},
|
||||
{
|
||||
delay: 0.6,
|
||||
segments: [
|
||||
[6, 12, 6, 9.79086],
|
||||
[6, 12, 6, 16.4183],
|
||||
],
|
||||
},
|
||||
{
|
||||
delay: 0.8,
|
||||
segments: [
|
||||
[14, 20, 9.58172, 20],
|
||||
[14, 20, 18.4183, 20],
|
||||
],
|
||||
},
|
||||
{
|
||||
delay: 1,
|
||||
segments: [
|
||||
[22, 12, 22, 16.4183],
|
||||
[22, 12, 22, 8.446],
|
||||
],
|
||||
},
|
||||
{ delay: 1.2, segments: [[18, 3.05557, 20.455, 5.25285]] },
|
||||
{ delay: 0.2, segments: [[10, 12, 10, 14.2091]] },
|
||||
{
|
||||
delay: 0.4,
|
||||
segments: [
|
||||
[14, 16, 11.7909, 16],
|
||||
[14, 16, 16.2091, 16],
|
||||
],
|
||||
},
|
||||
{
|
||||
delay: 0.6,
|
||||
segments: [
|
||||
[18, 12, 18, 14.2091],
|
||||
[18, 12, 18, 7.58172],
|
||||
],
|
||||
},
|
||||
{
|
||||
delay: 0.8,
|
||||
segments: [
|
||||
[10, 4, 14.4183, 4],
|
||||
[10, 4, 5.58172, 4],
|
||||
],
|
||||
},
|
||||
{
|
||||
delay: 1,
|
||||
segments: [
|
||||
[2, 12, 2, 7.58172],
|
||||
[2, 12, 2, 15.5841],
|
||||
],
|
||||
},
|
||||
{ delay: 1.2, segments: [[6.06253, 21, 3.57127, 18.8012]] },
|
||||
{ delay: 0.2, segments: [[22, 9.5, 22, 11.79]] },
|
||||
{ delay: 0.4, segments: [[19, 15, 20.5, 13.5]] },
|
||||
{ delay: 0.6, segments: [[5, 15, 3.5, 13.5]] },
|
||||
{ delay: 0.8, segments: [[2, 9.5, 2, 11.8]] },
|
||||
];
|
||||
|
||||
/** Arc radius guides: the arc's centre and radius, drawn from one arc end to the centre to the other. */
|
||||
const ARC_GUIDES = [
|
||||
{ cx: 7.5, cy: 9.5, r: 5.5, from: [2, 9.5], to: [11.591, 5.824], delay: 0.3 },
|
||||
{ cx: 12, cy: 5.4415, r: 0.56, from: [11.591, 5.824], to: [12.409, 5.824], delay: 0.5 },
|
||||
{ cx: 16.5101, cy: 9.4738, r: 5.49, from: [12.409, 5.824], to: [22, 9.5], delay: 0.7 },
|
||||
{ cx: 11.9996, cy: 18.9997, r: 2, from: [13.508, 20.313], to: [10.508, 20.332], delay: 0.9 },
|
||||
];
|
||||
|
||||
const draw: Variants = {
|
||||
@@ -95,21 +44,23 @@ const draw: Variants = {
|
||||
opacity: { delay: 2.4, duration: 0.1 },
|
||||
},
|
||||
},
|
||||
exit: (index) => ({
|
||||
stroke: index === 0 ? 'var(--vp-c-text-1)' : 'var(--vp-c-brand)',
|
||||
exit: {
|
||||
stroke: 'var(--vp-c-brand)',
|
||||
pathLength: 1.02,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const drawAnimation = ref('visible');
|
||||
const showHandles = ref(true);
|
||||
|
||||
// motion-v passes the variant label that finished, not the resolved variant
|
||||
const onAnimationComplete = (variant: unknown) => {
|
||||
if (variant === 'visible') drawAnimation.value = 'exit';
|
||||
if (variant === 'exit') showHandles.value = false;
|
||||
};
|
||||
|
||||
// Only rendered on the client, so reduced motion is known before the first frame
|
||||
@@ -118,7 +69,10 @@ const reducedMotion = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
reducedMotion.value = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reducedMotion.value) drawAnimation.value = 'exit';
|
||||
if (reducedMotion.value) {
|
||||
drawAnimation.value = 'exit';
|
||||
showHandles.value = false;
|
||||
}
|
||||
mounted.value = true;
|
||||
});
|
||||
|
||||
@@ -127,6 +81,9 @@ const initial = <T,>(value: T) => (reducedMotion.value ? false : value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="home-hero-animation-container"
|
||||
>
|
||||
<div
|
||||
class="sponsor-hero-animation"
|
||||
aria-hidden="true"
|
||||
@@ -160,34 +117,64 @@ const initial = <T,>(value: T) => (reducedMotion.value ? false : value);
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g stroke-width="2">
|
||||
<motion.path
|
||||
v-for="(d, index) in LOGO_PATHS"
|
||||
:key="d"
|
||||
:d="d"
|
||||
:style="{ stroke: 'var(--vp-c-gray-1)' }"
|
||||
:initial="initial('hidden')"
|
||||
:animate="drawAnimation"
|
||||
:variants="draw"
|
||||
:custom="index"
|
||||
@animation-complete="onAnimationComplete"
|
||||
/>
|
||||
</g>
|
||||
<motion.g
|
||||
stroke-width="0.12"
|
||||
stroke-dasharray="0 0.25 0.25"
|
||||
stroke="#777"
|
||||
stroke-opacity="0.3"
|
||||
:initial="initial({ opacity: 1 })"
|
||||
:animate="{ opacity: showHandles ? 1 : 0 }"
|
||||
:transition="{ duration: 0.6 }"
|
||||
>
|
||||
<motion.g
|
||||
v-for="arc in ARC_GUIDES"
|
||||
:key="`${arc.cx},${arc.cy}`"
|
||||
:initial="initial({ opacity: 0, scale: 0.2 })"
|
||||
:animate="{ opacity: 1, scale: 1 }"
|
||||
:transition="{ delay: arc.delay, duration: 0.3 }"
|
||||
>
|
||||
<path :d="`M${arc.from.join(' ')}L${arc.cx} ${arc.cy}L${arc.to.join(' ')}`" />
|
||||
<circle
|
||||
:cx="arc.cx"
|
||||
:cy="arc.cy"
|
||||
r="0.25"
|
||||
stroke-dasharray="0"
|
||||
stroke="red"
|
||||
/>
|
||||
<circle
|
||||
:cx="arc.cx"
|
||||
:cy="arc.cy"
|
||||
:r="arc.r"
|
||||
stroke="red"
|
||||
/>
|
||||
</motion.g>
|
||||
</motion.g>
|
||||
|
||||
<g
|
||||
<motion.path
|
||||
:d="HEART_PATH"
|
||||
stroke-width="2"
|
||||
:style="{ stroke: 'var(--vp-c-gray-1)' }"
|
||||
:initial="initial('hidden')"
|
||||
:animate="drawAnimation"
|
||||
:variants="draw"
|
||||
@animation-complete="onAnimationComplete"
|
||||
/>
|
||||
|
||||
<motion.g
|
||||
:style="{ stroke: 'var(--vp-c-text-1)' }"
|
||||
stroke-width="0.125"
|
||||
:initial="initial({ opacity: 1 })"
|
||||
:animate="{ opacity: showHandles ? 1 : 0 }"
|
||||
:transition="{ duration: 0.2 }"
|
||||
>
|
||||
<motion.path
|
||||
v-for="d in LOGO_PATHS"
|
||||
:key="d"
|
||||
:d="d"
|
||||
:d="HEART_PATH"
|
||||
:initial="initial({ opacity: 0 })"
|
||||
:animate="{ opacity: 1 }"
|
||||
:transition="{ delay: 1.6, duration: 1.5 }"
|
||||
/>
|
||||
<motion.path
|
||||
d="M14 12h.01M10 8h.01M6 12h.01M14 20h.01M22 12h.01M18 3.05557h.01M10 12h.01M14 16h.01M18 12h.01M10 4h.01M2 12h.01M6.06253 21h.01"
|
||||
:d="VERTICES"
|
||||
:initial="initial({ opacity: 0 })"
|
||||
:animate="{ opacity: 1 }"
|
||||
:transition="{ delay: 0.2, duration: 0.3 }"
|
||||
@@ -202,12 +189,15 @@ const initial = <T,>(value: T) => (reducedMotion.value ? false : value);
|
||||
:animate="{ opacity: 1, scale: 1 }"
|
||||
:transition="{ duration: 0.8 }"
|
||||
/>
|
||||
</g>
|
||||
</motion.g>
|
||||
|
||||
<g
|
||||
<motion.g
|
||||
stroke-width="0.12"
|
||||
stroke="#FFF"
|
||||
stroke-opacity="0.3"
|
||||
:initial="initial({ opacity: 1 })"
|
||||
:animate="{ opacity: showHandles ? 1 : 0 }"
|
||||
:transition="{ duration: 0.6 }"
|
||||
>
|
||||
<motion.g
|
||||
v-for="(group, index) in HANDLE_GROUPS"
|
||||
@@ -228,12 +218,17 @@ const initial = <T,>(value: T) => (reducedMotion.value ? false : value);
|
||||
/>
|
||||
</template>
|
||||
</motion.g>
|
||||
</g>
|
||||
</motion.g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sponsor-hero-animation-container {
|
||||
margin: -48px -24px 0;
|
||||
display: flex;
|
||||
}
|
||||
.sponsor-hero-animation {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -243,6 +238,12 @@ const initial = <T,>(value: T) => (reducedMotion.value ? false : value);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.sponsor-hero-animation {
|
||||
max-width: 860px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-background {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
Reference in New Issue
Block a user