chore(site): Add support for reduced motion in HomeHeroAnimation (#5159)

* Refactor code

* Respect reduce motion

* Add icon

* Revert Icon switch
This commit is contained in:
Eric Fennis
2026-11-06 09:09:23 +01:00
committed by GitHub
parent b826f0c527
commit 1a49fd88d8
3 changed files with 175 additions and 299 deletions
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import { onMounted, ref, shallowRef, type DefineComponent } from 'vue';
import { data } from './HomeHeroIconsCard.data';
import { useRouter } from 'vitepress';
import { motion, useScroll, useTransform, type Options as MotionOptions } from 'motion-v';
import { useMediaQuery } from '@vueuse/core';
import LucideIcon from '../base/LucideIcon.vue';
// motion-v re-exports framer-motion's `Variants`, which doesn't match the `variants` prop of its components
@@ -10,7 +11,8 @@ type Variants = MotionOptions['variants'];
const emit = defineEmits(['animation-complete']);
const MotionLucideIcon = motion.create(LucideIcon);
// motion.create only accepts a props-less DefineComponent, so the SFC's typed props need widening
const MotionLucideIcon = motion.create(LucideIcon as unknown as DefineComponent);
const preventFocusProps = {
tabindex: '-1',
@@ -21,6 +23,89 @@ const COLUMNS = 8;
const SIZE = 2;
const GAP = 1;
/** The two spirals of the Lucide logo, drawn the way the SVG editor previews them. */
const LOGO_PATH_OUTER =
'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';
const LOGO_PATH_INNER =
'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 VERTICES =
'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';
const MARKERS = [
[14, 12],
[18, 3.05557],
[10, 12],
[6.06253, 21],
];
/** Bezier handles as [x1, y1, x2, y2] segments, grouped by when they appear. */
const HANDLE_GROUPS = [
// Outer spiral
{ 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]] },
// Inner spiral
{ 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]] },
];
const { scrollYProgress } = useScroll();
const opacity = useTransform(() => 1 - scrollYProgress.get() * 8);
@@ -101,6 +186,9 @@ const draw: Variants = {
// motion-v passes the variant label that finished, not the resolved variant
const onAnimationComplete = (variant: unknown) => {
// Reduced motion stops at the icon grid, so don't shrink the icons or loop
if (reducedMotion.value) return;
if (variant === 'visible') {
drawAnimation.value = 'exit';
return;
@@ -155,6 +243,24 @@ const shrinkIconVariants: Variants = {
transition: { delay: 1.8, duration: 1.5, ease: 'easeInOut' },
}),
};
// Only rendered on the client, so reduced motion is known before the first frame
const mounted = ref(false);
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
onMounted(() => {
if (reducedMotion.value) {
drawAnimation.value = 'exit';
showHandles.value = false;
scaleDownAnimation.value = 'small';
iconGridAnimation.value = 'showIcons';
}
mounted.value = true;
});
/** Skips straight to the end state when the user prefers reduced motion. */
const initial = <T,>(value: T) => (reducedMotion.value ? false : value);
</script>
<template>
@@ -167,6 +273,7 @@ const shrinkIconVariants: Variants = {
v-bind="preventFocusProps"
>
<motion.svg
v-if="mounted"
xmlns="http://www.w3.org/2000/svg"
viewBox="-12 -12 48 48"
fill="none"
@@ -209,7 +316,7 @@ const shrinkIconVariants: Variants = {
<MotionLucideIcon
v-for="(icon, index) in icons"
size="2"
initial="initial"
:initial="initial('initial')"
:key="icon.name"
:variants="iconAnimationVariants"
:animate="iconGridAnimation"
@@ -223,15 +330,15 @@ const shrinkIconVariants: Variants = {
<motion.g
:variants="scaleDownVariants"
:animate="scaleDownAnimation"
initial="hidden"
:initial="initial('hidden')"
@animation-complete="onAnimationComplete"
v-bind="preventFocusProps"
>
<motion.path
d="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"
:style="{ stroke: 'var(--vp-c-gray-1)' }"
:d="LOGO_PATH_OUTER"
:style="{ stroke: reducedMotion ? 'var(--vp-c-text-1)' : 'var(--vp-c-gray-1)' }"
:animate="drawAnimation"
initial="hidden"
:initial="initial('hidden')"
:variants="draw"
:custom="1"
@animation-complete="onAnimationComplete"
@@ -239,10 +346,10 @@ const shrinkIconVariants: Variants = {
/>
<motion.path
d="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"
:style="{ stroke: 'var(--vp-c-gray-1)' }"
:d="LOGO_PATH_INNER"
:style="{ stroke: reducedMotion ? 'var(--vp-c-brand)' : 'var(--vp-c-gray-1)' }"
:animate="drawAnimation"
initial="hidden"
:initial="initial('hidden')"
:variants="draw"
:custom="0"
v-bind="preventFocusProps"
@@ -253,80 +360,36 @@ const shrinkIconVariants: Variants = {
class="svg-preview-control-path-marker-group"
:style="{ stroke: 'var(--vp-c-text-1)' }"
stroke-width="0.125"
:initial="{ opacity: 1 }"
:animate="showHandles ? { opacity: 1 } : { opacity: 0 }"
:transition="{ delay: 0, duration: 0.2 }"
:initial="initial({ opacity: 1 })"
:animate="{ opacity: showHandles ? 1 : 0 }"
:transition="{ duration: 0.2 }"
v-bind="preventFocusProps"
>
<motion.path
d="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"
:initial="{ opacity: 0 }"
v-for="d in [LOGO_PATH_OUTER, LOGO_PATH_INNER]"
:key="d"
:d="d"
:initial="initial({ opacity: 0 })"
:animate="{ opacity: 1 }"
:transition="{ delay: 1.6, duration: 1.5 }"
v-bind="preventFocusProps"
/>
<motion.path
d="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"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:transition="{ delay: 1.6, duration: 1.5 }"
v-bind="preventFocusProps"
/>
<motion.g
:initial="{ opacity: 0 }"
:d="VERTICES"
:initial="initial({ opacity: 0 })"
:animate="{ opacity: 1 }"
:transition="{ delay: 0.2, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path
d="M14 12h.01M10 8h.01M10 8h.01M6 12h.01M6 12h.01M14 20h.01M14 20h.01M22 12h.01M22 12h.01M18 3.05557h.01M10 12h.01M14 16h.01M14 16h.01M18 12h.01M18 12h.01M10 4h.01M10 4h.01M2 12h.01M2 12h.01M6.06253 21h.01"
>
</path>
</motion.g>
<motion.circle
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0, duration: 0.8 }"
cx="14"
cy="12"
r="0.5"
v-bind="preventFocusProps"
/>
<motion.circle
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0, duration: 0.8 }"
cx="14"
cy="12"
v-for="[cx, cy] in MARKERS"
:key="`${cx},${cy}`"
:cx="cx"
:cy="cy"
r="0.5"
v-bind="preventFocusProps"
/>
<motion.circle
:initial="{ opacity: 0, scale: 0.2 }"
:initial="initial({ opacity: 0, scale: 0.2 })"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0, duration: 0.8 }"
cx="18"
cy="3.05557"
r="0.5"
v-bind="preventFocusProps"
/>
<motion.circle
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0, duration: 0.8 }"
cx="10"
cy="12"
r="0.5"
v-bind="preventFocusProps"
/>
<motion.circle
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0, duration: 0.8 }"
cx="6.06253"
cy="21"
r="0.5"
:transition="{ duration: 0.8 }"
v-bind="preventFocusProps"
/>
</motion.g>
@@ -335,221 +398,30 @@ const shrinkIconVariants: Variants = {
stroke-width="0.12"
stroke="#FFF"
stroke-opacity="0.3"
:initial="{ opacity: 1 }"
:animate="showHandles ? { opacity: 1 } : { opacity: 0 }"
:transition="{ delay: 0, duration: 0.6 }"
:initial="initial({ opacity: 1 })"
:animate="{ opacity: showHandles ? 1 : 0 }"
:transition="{ duration: 0.6 }"
v-bind="preventFocusProps"
>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
v-for="(group, index) in HANDLE_GROUPS"
:key="index"
:initial="initial({ opacity: 0, scale: 0.2 })"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0.2, duration: 0.3 }"
:transition="{ delay: group.delay, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M14 12 14 9.79086"></path>
<circle
cy="9.79086"
cx="14"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0.4, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M10 8 12.2091 8"></path>
<circle
cy="8"
cx="12.2091"
r="0.25"
></circle>
<path d="M10 8 7.79086 8"></path>
<circle
cy="8"
cx="7.79086"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0.6, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M6 12 6 9.79086"></path>
<circle
cy="9.79086"
cx="6"
r="0.25"
></circle>
<path d="M6 12 6 16.4183"></path>
<circle
cy="16.4183"
cx="6"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0.8, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M14 20 9.58172 20"></path>
<circle
cy="20"
cx="9.58172"
r="0.25"
></circle>
<path d="M14 20 18.4183 20"></path>
<circle
cy="20"
cx="18.4183"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 1, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M22 12 22 16.4183"></path>
<circle
cy="16.4183"
cx="22"
r="0.25"
></circle>
<path d="M22 12 22 8.446"></path>
<circle
cy="8.446"
cx="22"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 1.2, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M18 3.05557 20.455 5.25285"></path>
<circle
cy="5.25285"
cx="20.455"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0.2, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M10 12 10 14.2091"></path>
<circle
cy="14.2091"
cx="10"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0.4, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M14 16 11.7909 16"></path>
<circle
cy="16"
cx="11.7909"
r="0.25"
></circle>
<path d="M14 16 16.2091 16"></path>
<circle
cy="16"
cx="16.2091"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0.6, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M18 12 18 14.2091"></path>
<circle
cy="14.2091"
cx="18"
r="0.25"
></circle>
<path d="M18 12 18 7.58172"></path>
<circle
cy="7.58172"
cx="18"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 0.8, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M10 4 14.4183 4"></path>
<circle
cy="4"
cx="14.4183"
r="0.25"
></circle>
<path d="M10 4 5.58172 4"></path>
<circle
cy="4"
cx="5.58172"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 1, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M2 12 2 7.58172"></path>
<circle
cy="7.58172"
cx="2"
r="0.25"
></circle>
<path d="M2 12 2 15.5841"></path>
<circle
cy="15.5841"
cx="2"
r="0.25"
></circle>
</motion.g>
<motion.g
:initial="{ opacity: 0, scale: 0.2 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ delay: 1.2, duration: 0.3 }"
v-bind="preventFocusProps"
>
<path d="M6.06253 21 3.57127 18.8012"></path>
<circle
cy="18.8012"
cx="3.57127"
r="0.25"
></circle>
<template
v-for="[x1, y1, x2, y2] in group.segments"
:key="`${x2},${y2}`"
>
<path :d="`M${x1} ${y1} ${x2} ${y2}`" />
<circle
:cx="x2"
:cy="y2"
r="0.25"
/>
</template>
</motion.g>
</motion.g>
</motion.svg>
+1 -1
View File
@@ -73,7 +73,7 @@
"lodash-es": "^4.18.1",
"lucide": "workspace:*",
"lucide-react": "workspace:*",
"motion-v": "2.4.0",
"motion-v": "2.11.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"sandpack-vue3": "3.1.12",
+24 -20
View File
@@ -198,8 +198,8 @@ importers:
specifier: workspace:*
version: link:../packages/lucide-react
motion-v:
specifier: 2.4.0
version: 2.4.0(@vueuse/core@14.4.0(vue@3.5.44(typescript@6.0.3)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vue@3.5.44(typescript@6.0.3))
specifier: 2.11.0
version: 2.11.0(@vueuse/core@14.4.0(vue@3.5.44(typescript@6.0.3)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vue@3.5.44(typescript@6.0.3))
react:
specifier: 19.3.1
version: 19.3.1
@@ -8906,16 +8906,19 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@13.2.0:
resolution: {integrity: sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA==}
framer-motion@14.9.0:
resolution: {integrity: sha512-g1hZu8WoTwgsoVAaqEEcefhvR03cKGtkmrKaRrWpXhnM3LGR8jl1u2qyJ1PMVlO7LKQHE8TTRQYUlMmnrY4L+g==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
remix: ^3.1.0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
remix:
optional: true
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
@@ -10297,14 +10300,14 @@ packages:
resolution: {integrity: sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==}
engines: {node: '>= 0.8.0'}
motion-dom@13.2.0:
resolution: {integrity: sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ==}
motion-dom@14.9.0:
resolution: {integrity: sha512-wGjKbNDMHd75aLStkxLCKZ+T8/wusZHklmNLK1Ak68UrdBt2X8I4yweYR/0WonxGnBsw6oloDTIV8pN+UTvzzA==}
motion-utils@13.0.0:
resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==}
motion-utils@14.7.0:
resolution: {integrity: sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw==}
motion-v@2.4.0:
resolution: {integrity: sha512-kRDGMAZk3nvdjEO36Wo6pezSEIStGXGhVFiwo1QkUDsUg8mB5igjYPXyece8wtu2DrHhmFfA6Y1nOz07+5QH4A==}
motion-v@2.11.0:
resolution: {integrity: sha512-zOq6ccT5IW0G7sLVYZfUD60NBoD2doTRQfd2OA9k3WLuQ3f/z38YkrBAts3OtpA5N3kir+iVUIPKw98ZQnwv2w==}
peerDependencies:
'@vueuse/core': '>=10.0.0'
vue: '>=3.0.0'
@@ -21630,10 +21633,10 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@13.2.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
framer-motion@14.9.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
dependencies:
motion-dom: 13.2.0
motion-utils: 13.0.0
motion-dom: 14.9.0
motion-utils: 14.7.0
tslib: 2.8.1
optionalDependencies:
react: 19.3.1
@@ -23328,23 +23331,24 @@ snapshots:
transitivePeerDependencies:
- supports-color
motion-dom@13.2.0:
motion-dom@14.9.0:
dependencies:
motion-utils: 13.0.0
motion-utils: 14.7.0
motion-utils@13.0.0: {}
motion-utils@14.7.0: {}
motion-v@2.4.0(@vueuse/core@14.4.0(vue@3.5.44(typescript@6.0.3)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vue@3.5.44(typescript@6.0.3)):
motion-v@2.11.0(@vueuse/core@14.4.0(vue@3.5.44(typescript@6.0.3)))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vue@3.5.44(typescript@6.0.3)):
dependencies:
'@vueuse/core': 14.4.0(vue@3.5.44(typescript@6.0.3))
framer-motion: 13.2.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
framer-motion: 14.9.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
hey-listen: 1.0.8
motion-dom: 13.2.0
motion-utils: 13.0.0
motion-dom: 14.9.0
motion-utils: 14.7.0
vue: 3.5.44(typescript@6.0.3)
transitivePeerDependencies:
- react
- react-dom
- remix
mri@1.2.0: {}