kmcom-nuxt-layers 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/layers/content/app/components/Blog/List.vue +5 -1
  2. package/layers/content/app/components/Gallery/AmbientImage.vue +5 -12
  3. package/layers/content/app/components/Gallery/Detail.vue +8 -6
  4. package/layers/content/app/components/Gallery/Grid.vue +11 -3
  5. package/layers/content/app/components/Portfolio/ColorPalette.vue +1 -4
  6. package/layers/content/app/components/Portfolio/Detail.vue +6 -1
  7. package/layers/content/app/components/Portfolio/List.vue +5 -1
  8. package/layers/content/app/components/content/Figure.vue +1 -7
  9. package/layers/content/package.json +5 -5
  10. package/layers/core/app/assets/css/main.css +5 -0
  11. package/layers/core/app/composables/useCache.ts +8 -4
  12. package/layers/core/app/composables/useErrorLog.ts +9 -5
  13. package/layers/core/app/composables/useScrollGuard.ts +4 -2
  14. package/layers/core/app/plugins/feature-detection.client.ts +1 -1
  15. package/layers/core/app/plugins/init.ts +2 -1
  16. package/layers/core/app/plugins/scroll-guard.client.ts +4 -1
  17. package/layers/core/app.config.ts +0 -9
  18. package/layers/forms/app/components/Form/Contact.vue +16 -7
  19. package/layers/forms/nuxt.config.ts +18 -0
  20. package/layers/forms/package.json +2 -0
  21. package/layers/layout/app/components/Layout/Container.vue +1 -4
  22. package/layers/layout/app/components/Layout/Grid/Debug.vue +0 -1
  23. package/layers/layout/app/components/Layout/Grid/Item.vue +12 -6
  24. package/layers/layout/app/components/Layout/Main.vue +1 -4
  25. package/layers/layout/app/components/Layout/Page/Container.vue +3 -1
  26. package/layers/layout/app/components/Layout/Page/Header.vue +16 -5
  27. package/layers/layout/app/components/Layout/Section/Grid.vue +1 -4
  28. package/layers/layout/app/components/Layout/Section/Sidebar.vue +6 -1
  29. package/layers/layout/app/components/Layout/Section/Stack.vue +1 -1
  30. package/layers/layout/app/components/Layout/Section/Title.vue +33 -0
  31. package/layers/layout/app/composables/useGridConfig.ts +6 -1
  32. package/layers/motion/app/components/Motion/HorizontalScroll.vue +61 -0
  33. package/layers/motion/app/components/Motion/PinnedSection.vue +77 -0
  34. package/layers/motion/app/components/Motion/ScrollProgress.vue +8 -56
  35. package/layers/motion/app/components/Motion/ScrollScene.vue +121 -0
  36. package/layers/motion/app/components/Motion/ScrollStep.vue +45 -0
  37. package/layers/motion/app/components/Motion/TextReveal.vue +28 -63
  38. package/layers/motion/app/composables/useScrollSteps.ts +41 -0
  39. package/layers/motion/app/composables/useSectionProgress.ts +58 -0
  40. package/layers/motion/app/composables/useSmoothScroll.ts +3 -2
  41. package/layers/motion/app/plugins/locomotive-scroll.client.ts +6 -6
  42. package/layers/motion/nuxt.config.ts +6 -0
  43. package/layers/motion/package.json +2 -1
  44. package/layers/shader/app/components/Preset/ThemeAurora.client.vue +86 -0
  45. package/layers/shader/app/components/Preset/ThemeFlow.client.vue +86 -0
  46. package/layers/shader/app/components/Preset/ThemeGradient.client.vue +87 -0
  47. package/layers/shader/app/components/Shader/Background.client.vue +6 -0
  48. package/layers/shader/app/composables/useAmbientMaterials.ts +150 -0
  49. package/layers/shader/app/composables/useThemeColors.ts +43 -0
  50. package/layers/shader/app/utils/tsl/oklch.ts +12 -6
  51. package/layers/theme/app/assets/css/theme.css +19 -14
  52. package/layers/theme/app/components/ThemePicker/AccentButton.vue +2 -2
  53. package/layers/theme/app/components/ThemePicker/Colors.vue +2 -4
  54. package/layers/theme/app/components/ThemePicker/Menu.vue +4 -13
  55. package/layers/theme/app/components/ThemePicker/MenuButton.vue +1 -7
  56. package/layers/theme/app/composables/useAccentColor.ts +38 -0
  57. package/layers/theme/app/composables/useTheme.ts +14 -0
  58. package/layers/theme/app/composables/useThemeContrast.ts +34 -0
  59. package/layers/theme/app/composables/useThemeMotion.ts +34 -0
  60. package/layers/theme/app/composables/useThemePreferences.ts +3 -156
  61. package/layers/theme/app/composables/useThemeTransparency.ts +41 -0
  62. package/layers/theme/app/plugins/theme.client.ts +3 -3
  63. package/layers/theme/app/types/theme.ts +4 -0
  64. package/layers/theme/nuxt.config.ts +7 -0
  65. package/layers/ui/app/app.config.ts +44 -0
  66. package/layers/ui/app/assets/css/main.css +14 -0
  67. package/layers/ui/app/components/Accent/Blob.vue +29 -0
  68. package/layers/ui/app/components/Accent/Scene.vue +38 -0
  69. package/layers/ui/app/components/Gradient/Background.vue +22 -0
  70. package/layers/ui/app/components/Gradient/Text.vue +22 -0
  71. package/layers/ui/app/components/Progress/Bar.vue +25 -0
  72. package/layers/ui/app/components/Progress/Circular.vue +69 -0
  73. package/layers/ui/app/components/Tint/Overlay.vue +25 -0
  74. package/layers/ui/app/components/Typography/CodeBlock.vue +2 -1
  75. package/layers/ui/app/components/Typography/Headline.vue +2 -1
  76. package/layers/ui/app/components/Typography/QuoteBlock.vue +2 -1
  77. package/layers/ui/app/components/Typography/TextStroke.vue +18 -16
  78. package/layers/ui/app/composables/accent.ts +51 -0
  79. package/layers/ui/app/composables/gradient.ts +79 -0
  80. package/layers/ui/app/composables/tint.ts +20 -0
  81. package/layers/ui/app/types/accent.ts +17 -0
  82. package/layers/ui/app/types/gradient.ts +27 -0
  83. package/layers/ui/app/types/tint.ts +25 -0
  84. package/package.json +32 -30
  85. package/layers/motion/app/utils/gsapAnimations.ts +0 -122
  86. package/layers/ui/app.config.ts +0 -12
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ const { scrub = 1 } = defineProps<{
3
+ /**
4
+ * Scrub smoothness (1 = default, higher = smoother/slower)
5
+ */
6
+ scrub?: number
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ 'panel-change': [index: number]
11
+ }>()
12
+
13
+ const { gsap } = useGsap()
14
+
15
+ const sectionRef = ref<HTMLElement | null>(null)
16
+ const trackRef = ref<HTMLElement | null>(null)
17
+
18
+ let cleanup: (() => void) | null = null
19
+
20
+ onMounted(() => {
21
+ if (!sectionRef.value || !trackRef.value) return
22
+
23
+ const track = trackRef.value
24
+ const trackWidth = track.scrollWidth - window.innerWidth
25
+
26
+ const tween = gsap.to(track, {
27
+ scrollTrigger: {
28
+ trigger: sectionRef.value,
29
+ start: 'top top',
30
+ end: `+=${trackWidth}`,
31
+ pin: true,
32
+ scrub,
33
+ anticipatePin: 1,
34
+ onUpdate: (self) => {
35
+ const panels = track.querySelectorAll(':scope > *')
36
+ if (panels.length > 0) {
37
+ const index = Math.round(self.progress * (panels.length - 1))
38
+ emit('panel-change', index)
39
+ }
40
+ },
41
+ },
42
+ x: -trackWidth,
43
+ ease: 'none',
44
+ })
45
+
46
+ cleanup = () => tween.kill()
47
+ })
48
+
49
+ onUnmounted(() => {
50
+ cleanup?.()
51
+ cleanup = null
52
+ })
53
+ </script>
54
+
55
+ <template>
56
+ <section ref="sectionRef" class="motion-horizontal-scroll h-screen overflow-hidden">
57
+ <div ref="trackRef" class="h-full flex items-center will-change-transform">
58
+ <slot />
59
+ </div>
60
+ </section>
61
+ </template>
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ const { duration = 200, scrub = 0.5 } = defineProps<{
3
+ /**
4
+ * Pin duration as a percentage of viewport height (200 = 2× viewport height)
5
+ */
6
+ duration?: number
7
+ /**
8
+ * Scrub smoothness for card reveals
9
+ */
10
+ scrub?: number
11
+ }>()
12
+
13
+ const { gsap, ScrollTrigger } = useGsap()
14
+
15
+ const sectionRef = ref<HTMLElement | null>(null)
16
+ const contentRef = ref<HTMLElement | null>(null)
17
+
18
+ const cleanups: Array<() => void> = []
19
+
20
+ onMounted(() => {
21
+ if (!sectionRef.value || !contentRef.value) return
22
+
23
+ const endOffset = (duration * window.innerHeight) / 100
24
+ const endValue = `+=${endOffset}`
25
+
26
+ // Pin the section
27
+ const pinSt = ScrollTrigger.create({
28
+ trigger: sectionRef.value,
29
+ start: 'top top',
30
+ end: endValue,
31
+ pin: true,
32
+ pinSpacing: true,
33
+ })
34
+ cleanups.push(() => pinSt.kill())
35
+
36
+ // Animate each direct child
37
+ const items = Array.from(contentRef.value.querySelectorAll(':scope > *'))
38
+ const total = items.length
39
+ if (total === 0) return
40
+
41
+ items.forEach((item, i) => {
42
+ gsap.set(item, { opacity: 0, y: 60, scale: 0.9 })
43
+
44
+ const startProgress = i / total
45
+ const endProgress = (i + 0.5) / total
46
+
47
+ const tween = gsap.to(item, {
48
+ scrollTrigger: {
49
+ trigger: sectionRef.value,
50
+ start: 'top top',
51
+ end: endValue,
52
+ scrub,
53
+ },
54
+ keyframes: [
55
+ { opacity: 0, y: 60, scale: 0.9, duration: startProgress },
56
+ { opacity: 1, y: 0, scale: 1, duration: endProgress - startProgress },
57
+ { opacity: 1, y: 0, scale: 1, duration: 1 - endProgress },
58
+ ],
59
+ ease: 'none',
60
+ })
61
+ cleanups.push(() => tween.kill())
62
+ })
63
+ })
64
+
65
+ onUnmounted(() => {
66
+ cleanups.forEach((fn) => fn())
67
+ cleanups.length = 0
68
+ })
69
+ </script>
70
+
71
+ <template>
72
+ <section ref="sectionRef" class="motion-pinned-section h-screen relative">
73
+ <div ref="contentRef" class="h-full flex items-center justify-center">
74
+ <slot />
75
+ </div>
76
+ </section>
77
+ </template>
@@ -44,16 +44,6 @@ const props = withDefaults(
44
44
  const { progress } = useSmoothScroll()
45
45
 
46
46
  const percentage = computed(() => Math.round(progress.value * 100))
47
-
48
- // For circular progress
49
- const radius = computed(() => (props.size - props.strokeWidth) / 2)
50
- const circumference = computed(() => 2 * Math.PI * radius.value)
51
- const strokeDasharray = computed(
52
- () => `${progress.value * circumference.value} ${circumference.value}`
53
- )
54
-
55
- // Generate gradient ID
56
- const gradientId = `scroll-progress-gradient-${Math.random().toString(36).slice(2, 9)}`
57
47
  </script>
58
48
 
59
49
  <template>
@@ -77,51 +67,13 @@ const gradientId = `scroll-progress-gradient-${Math.random().toString(36).slice(
77
67
  </div>
78
68
 
79
69
  <!-- Circular Progress -->
80
- <div
70
+ <ProgressCircular
81
71
  v-else
82
- class="motion-scroll-progress-circular relative inline-flex items-center justify-center"
83
- >
84
- <svg :width="size" :height="size" class="-rotate-90" :viewBox="`0 0 ${size} ${size}`">
85
- <!-- Background circle -->
86
- <circle
87
- :cx="size / 2"
88
- :cy="size / 2"
89
- :r="radius"
90
- fill="none"
91
- :stroke="bgColor"
92
- :stroke-width
93
- />
94
- <!-- Progress circle -->
95
- <circle
96
- :cx="size / 2"
97
- :cy="size / 2"
98
- :r="radius"
99
- fill="none"
100
- :stroke="`url(#${gradientId})`"
101
- :stroke-width
102
- stroke-linecap="round"
103
- :stroke-dasharray
104
- class="transition-[stroke-dasharray] duration-100"
105
- />
106
- <defs>
107
- <linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="0%">
108
- <stop offset="0%" :style="`stop-color: ${colors[0]}`" />
109
- <stop offset="50%" :style="`stop-color: ${colors[1]}`" />
110
- <stop offset="100%" :style="`stop-color: ${colors[2]}`" />
111
- </linearGradient>
112
- </defs>
113
- </svg>
114
- <span
115
- v-if="showPercentage"
116
- class="absolute inset-0 flex items-center justify-center text-2xl font-bold tabular-nums"
117
- >
118
- {{ percentage }}%
119
- </span>
120
- </div>
72
+ :progress="progress"
73
+ :size
74
+ :stroke-width="strokeWidth"
75
+ :show-percentage="showPercentage"
76
+ :colors
77
+ :bg-color="bgColor"
78
+ />
121
79
  </template>
122
-
123
- <style scoped>
124
- .tabular-nums {
125
- font-variant-numeric: tabular-nums;
126
- }
127
- </style>
@@ -0,0 +1,121 @@
1
+ <script setup lang="ts">
2
+ import type { ScrollSceneContext } from '../../composables/useScrollSteps'
3
+ import { SCROLL_SCENE_KEY } from '../../composables/useScrollSteps'
4
+
5
+ const {
6
+ name,
7
+ start = 'top top',
8
+ end = '+=200%',
9
+ pin = true,
10
+ scrub = 1,
11
+ markers = false,
12
+ } = defineProps<{
13
+ /**
14
+ * Unique name for this scene (for debugging)
15
+ */
16
+ name: string
17
+ /**
18
+ * ScrollTrigger start position
19
+ */
20
+ start?: string
21
+ /**
22
+ * ScrollTrigger end position (e.g. '+=200%' = 2 viewport heights past start)
23
+ */
24
+ end?: string
25
+ /**
26
+ * Pin the section while scroll advances
27
+ */
28
+ pin?: boolean
29
+ /**
30
+ * Scrub smoothness: true | number | false
31
+ */
32
+ scrub?: boolean | number
33
+ /**
34
+ * Show GSAP ScrollTrigger markers (debug)
35
+ */
36
+ markers?: boolean
37
+ }>()
38
+
39
+ const emit = defineEmits<{
40
+ enter: []
41
+ leave: []
42
+ progress: [value: number]
43
+ }>()
44
+
45
+ const { ScrollTrigger } = useGsap()
46
+
47
+ const containerRef = ref<HTMLElement | null>(null)
48
+ const progress = ref(0)
49
+ const active = ref(false)
50
+
51
+ // Step registration
52
+ const registeredSteps = ref(new Set<number>())
53
+ const stepCount = computed(() =>
54
+ registeredSteps.value.size > 0 ? Math.max(...registeredSteps.value) + 1 : 0
55
+ )
56
+
57
+ function registerStep(index: number) {
58
+ registeredSteps.value = new Set([...registeredSteps.value, index])
59
+ }
60
+
61
+ function unregisterStep(index: number) {
62
+ const next = new Set(registeredSteps.value)
63
+ next.delete(index)
64
+ registeredSteps.value = next
65
+ }
66
+
67
+ provide<ScrollSceneContext>(SCROLL_SCENE_KEY, {
68
+ progress,
69
+ active,
70
+ name,
71
+ stepCount,
72
+ registerStep,
73
+ unregisterStep,
74
+ })
75
+
76
+ let st: { kill: () => void } | null = null
77
+
78
+ onMounted(() => {
79
+ if (!containerRef.value) return
80
+
81
+ st = ScrollTrigger.create({
82
+ trigger: containerRef.value,
83
+ start,
84
+ end,
85
+ pin,
86
+ scrub,
87
+ markers,
88
+ onUpdate: (self) => {
89
+ progress.value = self.progress
90
+ emit('progress', self.progress)
91
+ },
92
+ onEnter: () => {
93
+ active.value = true
94
+ emit('enter')
95
+ },
96
+ onLeave: () => {
97
+ active.value = false
98
+ emit('leave')
99
+ },
100
+ onEnterBack: () => {
101
+ active.value = true
102
+ emit('enter')
103
+ },
104
+ onLeaveBack: () => {
105
+ active.value = false
106
+ emit('leave')
107
+ },
108
+ })
109
+ })
110
+
111
+ onUnmounted(() => {
112
+ st?.kill()
113
+ st = null
114
+ })
115
+ </script>
116
+
117
+ <template>
118
+ <div ref="containerRef" class="motion-scroll-scene">
119
+ <slot :progress="progress" :active="active" />
120
+ </div>
121
+ </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import type { ScrollSceneContext } from '../../composables/useScrollSteps'
3
+ import { SCROLL_SCENE_KEY } from '../../composables/useScrollSteps'
4
+
5
+ const { index } = defineProps<{
6
+ /**
7
+ * Zero-based step index within the parent MotionScrollScene
8
+ */
9
+ index: number
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ enter: []
14
+ leave: []
15
+ }>()
16
+
17
+ const scene = inject<ScrollSceneContext>(SCROLL_SCENE_KEY)
18
+
19
+ onMounted(() => scene?.registerStep(index))
20
+ onUnmounted(() => scene?.unregisterStep(index))
21
+
22
+ const isActive = computed(() => {
23
+ if (!scene) return index === 0
24
+ const total = scene.stepCount.value
25
+ if (total === 0) return false
26
+ const stepSize = 1 / total
27
+ const start = index * stepSize
28
+ const end = (index + 1) * stepSize
29
+ // Last step stays active at progress === 1
30
+ return scene.progress.value >= start && (index === total - 1 || scene.progress.value < end)
31
+ })
32
+
33
+ const wasActive = ref(false)
34
+ watch(isActive, (active) => {
35
+ if (active && !wasActive.value) emit('enter')
36
+ else if (!active && wasActive.value) emit('leave')
37
+ wasActive.value = active
38
+ })
39
+ </script>
40
+
41
+ <template>
42
+ <div class="motion-scroll-step" :class="{ 'is-active': isActive }" :data-step="index">
43
+ <slot :is-active="isActive" />
44
+ </div>
45
+ </template>
@@ -17,73 +17,44 @@ const props = withDefaults(
17
17
  * Animation duration
18
18
  */
19
19
  duration?: number
20
- /**
21
- * Trigger on scroll
22
- */
23
- scrollTrigger?: boolean
24
20
  /**
25
21
  * Start position for scroll trigger
26
22
  */
27
23
  start?: string
28
24
  }>(),
29
- {
30
- type: 'chars',
31
- stagger: 0.03,
32
- duration: 0.8,
33
- scrollTrigger: true,
34
- start: 'top 80%',
35
- }
25
+ { type: 'chars', stagger: 0.03, duration: 0.8, start: 'top 80%' }
36
26
  )
37
27
 
38
- const { gsap } = useGsap()
39
- const containerRef = ref<HTMLElement | null>(null)
40
-
41
- const splitContent = computed(() => {
42
- if (props.type === 'chars') {
43
- return props.text.split('').map((char) => (char === ' ' ? '\u00A0' : char))
44
- } else if (props.type === 'words') {
45
- return props.text.split(' ')
46
- }
47
- return [props.text]
48
- })
49
-
50
- onMounted(() => {
51
- if (!containerRef.value) return
52
-
53
- const elements = containerRef.value.querySelectorAll('.reveal-item')
54
-
55
- const animationConfig: gsap.TweenVars = {
56
- y: 40,
57
- opacity: 0,
58
- rotateX: -90,
59
- duration: props.duration,
60
- stagger: props.stagger,
61
- ease: 'power3.out',
62
- }
63
-
64
- if (props.scrollTrigger) {
65
- animationConfig.scrollTrigger = {
66
- trigger: containerRef.value,
67
- start: props.start,
68
- toggleActions: 'play none none none',
69
- }
70
- }
71
-
72
- gsap.from(elements, animationConfig)
73
- })
28
+ const animBinding = computed(() => ({
29
+ y: 40,
30
+ opacity: 0,
31
+ rotateX: -90,
32
+ stagger: props.stagger,
33
+ duration: props.duration,
34
+ ease: 'power3.out',
35
+ start: props.start,
36
+ }))
74
37
  </script>
75
38
 
76
39
  <template>
77
- <span ref="containerRef" class="motion-text-reveal">
78
- <span
79
- v-for="(item, index) in splitContent"
80
- :key="index"
81
- class="reveal-item"
82
- :class="{ 'mr-[0.25em]': type === 'words' && index < splitContent.length - 1 }"
83
- >
84
- {{ item }}
85
- </span>
86
- </span>
40
+ <span
41
+ v-if="type === 'chars'"
42
+ v-gsap.whenVisible.once.splitText.chars.from="animBinding"
43
+ class="motion-text-reveal"
44
+ >{{ text }}</span
45
+ >
46
+ <span
47
+ v-else-if="type === 'words'"
48
+ v-gsap.whenVisible.once.splitText.words.from="animBinding"
49
+ class="motion-text-reveal"
50
+ >{{ text }}</span
51
+ >
52
+ <span
53
+ v-else
54
+ v-gsap.whenVisible.once.splitText.lines.from="animBinding"
55
+ class="motion-text-reveal"
56
+ >{{ text }}</span
57
+ >
87
58
  </template>
88
59
 
89
60
  <style scoped>
@@ -92,10 +63,4 @@ onMounted(() => {
92
63
  flex-wrap: wrap;
93
64
  perspective: 1000px;
94
65
  }
95
-
96
- .reveal-item {
97
- display: inline-block;
98
- transform-origin: center bottom;
99
- will-change: transform, opacity;
100
- }
101
66
  </style>
@@ -0,0 +1,41 @@
1
+ import type { ComputedRef, Ref } from 'vue'
2
+
3
+ export const SCROLL_SCENE_KEY = Symbol('motionScrollScene')
4
+
5
+ export interface ScrollSceneContext {
6
+ progress: Ref<number>
7
+ active: Ref<boolean>
8
+ name: string
9
+ stepCount: ComputedRef<number>
10
+ registerStep: (index: number) => void
11
+ unregisterStep: (index: number) => void
12
+ }
13
+
14
+ /**
15
+ * Step/waypoint state tracking — must be used inside a MotionScrollScene.
16
+ *
17
+ * @example
18
+ * const { currentStep, isActive, stepProgress } = useScrollSteps(3)
19
+ * // currentStep: 0 | 1 | 2 based on scene progress
20
+ */
21
+ export function useScrollSteps(total: number) {
22
+ const scene = inject<ScrollSceneContext>(SCROLL_SCENE_KEY)
23
+
24
+ const sceneProgress = computed(() => scene?.progress.value ?? 0)
25
+
26
+ const currentStep = computed(() =>
27
+ Math.min(Math.floor(sceneProgress.value * total), total - 1)
28
+ )
29
+
30
+ function isActive(index: number): boolean {
31
+ return currentStep.value === index
32
+ }
33
+
34
+ function stepProgress(index: number): number {
35
+ const stepSize = 1 / total
36
+ const start = index * stepSize
37
+ return Math.max(0, Math.min(1, (sceneProgress.value - start) / stepSize))
38
+ }
39
+
40
+ return { currentStep, isActive, stepProgress }
41
+ }
@@ -0,0 +1,58 @@
1
+ import type { MaybeRef } from 'vue'
2
+
3
+ interface SectionProgressOptions {
4
+ start?: string
5
+ end?: string
6
+ markers?: boolean
7
+ }
8
+
9
+ /**
10
+ * Section-local scroll progress (0–1) scoped to a given element.
11
+ *
12
+ * @example
13
+ * const sectionRef = ref<HTMLElement | null>(null)
14
+ * const { progress, active, entering, leaving } = useSectionProgress(sectionRef, {
15
+ * start: 'top top',
16
+ * end: '+=200%',
17
+ * })
18
+ */
19
+ export function useSectionProgress(
20
+ trigger: MaybeRef<HTMLElement | null>,
21
+ options: SectionProgressOptions = {}
22
+ ) {
23
+ const { ScrollTrigger } = useGsap()
24
+ const { direction } = useSmoothScroll()
25
+
26
+ const progress = ref(0)
27
+ const active = ref(false)
28
+ const entering = computed(() => active.value && direction.value > 0)
29
+ const leaving = computed(() => active.value && direction.value < 0)
30
+
31
+ let st: { kill: () => void } | null = null
32
+
33
+ onMounted(() => {
34
+ const el = unref(trigger)
35
+ if (!el) return
36
+
37
+ st = ScrollTrigger.create({
38
+ trigger: el,
39
+ start: options.start ?? 'top top',
40
+ end: options.end ?? '+=100%',
41
+ markers: options.markers ?? false,
42
+ onUpdate: (self) => {
43
+ progress.value = self.progress
44
+ },
45
+ onEnter: () => { active.value = true },
46
+ onLeave: () => { active.value = false },
47
+ onEnterBack: () => { active.value = true },
48
+ onLeaveBack: () => { active.value = false },
49
+ })
50
+ })
51
+
52
+ onUnmounted(() => {
53
+ st?.kill()
54
+ st = null
55
+ })
56
+
57
+ return { progress, active, entering, leaving }
58
+ }
@@ -1,3 +1,4 @@
1
+ import type { Ref } from 'vue'
1
2
  import type LocomotiveScroll from 'locomotive-scroll'
2
3
 
3
4
  interface ScrollToOptions {
@@ -23,9 +24,9 @@ interface ScrollToOptions {
23
24
  export function useSmoothScroll() {
24
25
  const nuxtApp = useNuxtApp()
25
26
 
26
- // Get the Locomotive Scroll instance and reactive state from the plugin
27
+ // Get the Locomotive Scroll instance from the reactive ref provided by the plugin
27
28
  const locomotiveScroll = computed<LocomotiveScroll | undefined>(
28
- () => nuxtApp.$locomotiveScroll as LocomotiveScroll | undefined
29
+ () => (nuxtApp.$locomotiveScroll as Ref<LocomotiveScroll | null>)?.value ?? undefined
29
30
  )
30
31
 
31
32
  // Reactive scroll state from the plugin
@@ -18,11 +18,11 @@ export default defineNuxtPlugin(() => {
18
18
  progress: 0,
19
19
  })
20
20
 
21
- let instance: LocomotiveScroll | null = null
21
+ const instance = shallowRef<LocomotiveScroll | null>(null)
22
22
 
23
23
  function init() {
24
- if (instance) return
25
- instance = new LocomotiveScroll({
24
+ if (instance.value) return
25
+ instance.value = new LocomotiveScroll({
26
26
  lenisOptions: {
27
27
  lerp: 0.1,
28
28
  smoothWheel: true,
@@ -40,8 +40,8 @@ export default defineNuxtPlugin(() => {
40
40
  }
41
41
 
42
42
  function destroy() {
43
- instance?.destroy()
44
- instance = null
43
+ instance.value?.destroy()
44
+ instance.value = null
45
45
  }
46
46
 
47
47
  const router = useRouter()
@@ -62,7 +62,7 @@ export default defineNuxtPlugin(() => {
62
62
 
63
63
  return {
64
64
  provide: {
65
- locomotiveScroll: readonly(instance),
65
+ locomotiveScroll: instance,
66
66
  scrollState,
67
67
  },
68
68
  }
@@ -8,6 +8,12 @@ export default defineNuxtConfig({
8
8
  '#layers/motion': import.meta.dirname,
9
9
  },
10
10
 
11
+ modules: ['v-gsap-nuxt'],
12
+
13
+ vgsap: {
14
+ composable: true,
15
+ },
16
+
11
17
  css: ['#layers/motion/app/assets/css/main.css'],
12
18
 
13
19
  compatibilityDate: '2026-01-30',
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "gsap": "^3.12.7",
14
- "locomotive-scroll": "^5.0.0"
14
+ "locomotive-scroll": "^5.0.0",
15
+ "v-gsap-nuxt": "latest"
15
16
  }
16
17
  }