srcdev-nuxt-components 9.1.35 → 9.1.36

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.
@@ -18,36 +18,12 @@
18
18
  "Bash(git show:*)",
19
19
  "Edit(/.claude/skills/components/**)",
20
20
  "Bash(npx nuxi:*)",
21
- "Bash(node -e \"const t = require\\('/Users/simoncornforth/websites/nuxt-components/node_modules/pinia-plugin-persistedstate'\\); console.log\\(Object.keys\\(t\\)\\)\")",
22
21
  "Bash(npx vue-tsc:*)",
23
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline -5)",
24
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 16ac4ef --stat)",
25
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline -8)",
26
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 5597a15 --stat)",
27
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 5597a15 -- \"*.vue\" \"*.css\")",
28
22
  "Bash(npm install:*)",
29
23
  "Bash(npm run:*)",
30
24
  "Bash(git add:*)",
31
- "Bash(ls /Users/simoncornforth/websites/nuxt-components/*.md)",
32
25
  "Bash(gh release:*)",
33
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components status)",
34
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components add .claude/skills/components/services-section.md .claude/skills/components/services-section-grid.md .claude/skills/index.md app/components/03.organisms/services/services-grids/ServicesSectionGrid.vue app/components/03.organisms/services/services-section/ServicesSection.vue app/components/03.organisms/services/services-card/stories/ app/components/03.organisms/services/services-card/tests/ app/components/03.organisms/services/services-grids/stories/ app/components/03.organisms/services/services-grids/tests/ app/components/03.organisms/services/services-section/stories/ app/components/03.organisms/services/services-section/tests/)",
35
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components commit -m ':*)",
36
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline v9.1.22..HEAD)",
37
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline c401cea..HEAD)",
38
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 3058d63 --stat)",
39
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 0f99d7a --stat)",
40
26
  "Bash(node -e ':*)"
41
- ],
42
- "additionalDirectories": [
43
- "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
44
- "/Users/simoncornforth/websites/nuxt-components/.claude/skills",
45
- "/Users/simoncornforth/websites/nuxt-components/app/components/02.molecules/navigation/site-navigation/tests",
46
- "/Users/simoncornforth/websites/luxury-locs-by-natasha-nuxt3/app/pages",
47
- "/Users/simoncornforth/websites/nuxt-components/app/assets/styles/extends-layer",
48
- "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms",
49
- "/private/tmp",
50
- "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/banner-video"
51
27
  ]
52
28
  }
53
29
  }
@@ -7,7 +7,35 @@
7
7
  "Bash(grep '\"\"typescript\"\"' package.json)",
8
8
  "Bash(grep '\"\"@vue/language-server\\\\|@volar\\\\|vue-tsc\"\"' package.json)",
9
9
  "Bash(npx nuxi prepare)",
10
- "Bash(ls -la /Users/simoncornforth/websites/luxury-locs-by-natasha-nuxt3/.env*)"
10
+ "Bash(ls -la /Users/simoncornforth/websites/luxury-locs-by-natasha-nuxt3/.env*)",
11
+ "Bash(node -e \"const t = require\\('/Users/simoncornforth/websites/nuxt-components/node_modules/pinia-plugin-persistedstate'\\); console.log\\(Object.keys\\(t\\)\\)\")",
12
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline -5)",
13
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 16ac4ef --stat)",
14
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline -8)",
15
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 5597a15 --stat)",
16
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 5597a15 -- \"*.vue\" \"*.css\")",
17
+ "Bash(ls /Users/simoncornforth/websites/nuxt-components/*.md)",
18
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components status)",
19
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components add .claude/skills/components/services-section.md .claude/skills/components/services-section-grid.md .claude/skills/index.md app/components/03.organisms/services/services-grids/ServicesSectionGrid.vue app/components/03.organisms/services/services-section/ServicesSection.vue app/components/03.organisms/services/services-card/stories/ app/components/03.organisms/services/services-card/tests/ app/components/03.organisms/services/services-grids/stories/ app/components/03.organisms/services/services-grids/tests/ app/components/03.organisms/services/services-section/stories/ app/components/03.organisms/services/services-section/tests/)",
20
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components commit -m ':*)",
21
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline v9.1.22..HEAD)",
22
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components log --oneline c401cea..HEAD)",
23
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 3058d63 --stat)",
24
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 0f99d7a --stat)",
25
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff app/components/parallax/SectionParallax.vue)",
26
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff HEAD app/components/parallax/SectionParallax.vue)",
27
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff --cached)"
28
+ ],
29
+ "additionalDirectories": [
30
+ "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
31
+ "/Users/simoncornforth/websites/nuxt-components/.claude/skills",
32
+ "/Users/simoncornforth/websites/nuxt-components/app/components/02.molecules/navigation/site-navigation/tests",
33
+ "/Users/simoncornforth/websites/luxury-locs-by-natasha-nuxt3/app/pages",
34
+ "/Users/simoncornforth/websites/nuxt-components/app/assets/styles/extends-layer",
35
+ "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms",
36
+ "/private/tmp",
37
+ "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/banner-video",
38
+ "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/scroll-parallax-section"
11
39
  ]
12
40
  }
13
41
  }
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: ScrollParallaxSection
3
+ description: ScrollParallaxSection JS-driven parallax background section — props, CSS height token, slot usage, parallaxStrength guide, reduced-motion, vs ScrollRevealFrame
4
+ type: reference
5
+ ---
6
+
7
+ # ScrollParallaxSection
8
+
9
+ ## Overview
10
+
11
+ `ScrollParallaxSection` is a full-width section with a parallax background image driven by `requestAnimationFrame` and `IntersectionObserver`. The background image bleeds beyond the container bounds and is translated vertically as the component scrolls through the viewport.
12
+
13
+ Use this component for **full-width decorative background sections** — hero banners, dividers, and atmospheric breaks between content. For arbitrary slot content that itself needs to pan (grids of images, video), use `ScrollRevealFrame` instead.
14
+
15
+ ### How it works
16
+
17
+ - The root element is a fixed-height container with `overflow: hidden`.
18
+ - `.scroll-parallax-section__bg` is positioned absolutely with a negative inset (derived from `parallaxStrength`) so the image bleeds beyond the frame top and bottom, ensuring full coverage at all scroll positions.
19
+ - On scroll/resize, a `requestAnimationFrame` callback reads `getBoundingClientRect()` and sets `translateY` on the background element.
20
+ - An `IntersectionObserver` pauses the RAF loop when the component is offscreen, reducing CPU usage.
21
+ - The background layer uses `will-change: transform` for GPU compositing.
22
+
23
+ ### Difference from ScrollRevealFrame / ScrollRevealImage
24
+
25
+ | | `ScrollParallaxSection` | `ScrollRevealFrame` / `ScrollRevealImage` |
26
+ |---|---|---|
27
+ | Mechanism | JS (RAF + IntersectionObserver) | CSS Scroll-driven Animations |
28
+ | Browser support | All modern + older browsers | Chrome 115+, Firefox 114+, Safari 17.2+ |
29
+ | Content | Background image only (slot above) | Slot content pans as a unit |
30
+ | Use case | Full-width decorative background sections | Clipping frames with panning image/content |
31
+
32
+ ## Props
33
+
34
+ | Prop | Type | Default | Description |
35
+ |------|------|---------|-------------|
36
+ | `tag` | `"div" \| "section" \| "article" \| "aside"` | `"div"` | HTML element rendered as the container. Use `"section"` for landmark regions. |
37
+ | `backgroundImage` | `string` | — | **Required.** Path or URL of the background image. Passed via CSS `background-image`. |
38
+ | `parallaxStrength` | `number` | `1` | Multiplier for the parallax movement and image bleed. `0` = no movement, `1` = standard, `2` = very dramatic. See the guide below. |
39
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra classes applied to the root element. |
40
+
41
+ ## Slots
42
+
43
+ | Slot | Description |
44
+ |------|-------------|
45
+ | `default` | Content placed above the parallax background at `z-index: 1`. |
46
+
47
+ ## CSS custom properties
48
+
49
+ Height is controlled entirely via CSS — there is no `height` prop.
50
+
51
+ | Property | Default | Description |
52
+ |----------|---------|-------------|
53
+ | `--scroll-parallax-section-height` | `25svh` | Height of the visible section. Set on the component or a parent wrapper. |
54
+
55
+ ## Basic usage
56
+
57
+ ```vue
58
+ <ScrollParallaxSection background-image="/images/banners/banner-mid-brown.webp" />
59
+ ```
60
+
61
+ ## With slot content
62
+
63
+ Slot content is layered at `z-index: 1` above the parallax background. Use flexbox or grid on the root element (via `styleClassPassthrough` or an inline style) to position it.
64
+
65
+ ```vue
66
+ <ScrollParallaxSection
67
+ background-image="/images/banners/banner-ginger.webp"
68
+ :parallax-strength="0.8"
69
+ tag="section"
70
+ style="
71
+ --scroll-parallax-section-height: 40svh;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ "
76
+ >
77
+ <h2 style="color: white; text-shadow: 0 2px 8px rgba(0,0,0,0.5);">Section heading</h2>
78
+ </ScrollParallaxSection>
79
+ ```
80
+
81
+ ## Custom height via CSS override
82
+
83
+ Override the height in a scoped stylesheet — useful for responsive breakpoints.
84
+
85
+ ```css
86
+ .my-page {
87
+ .scroll-parallax-section {
88
+ --scroll-parallax-section-height: 20svh;
89
+
90
+ @media (width >= 768px) {
91
+ --scroll-parallax-section-height: 35svh;
92
+ }
93
+
94
+ @media (width >= 1200px) {
95
+ --scroll-parallax-section-height: 25svh;
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ ## Choosing parallaxStrength
102
+
103
+ `parallaxStrength` controls two things simultaneously:
104
+
105
+ 1. **Movement** — how far the background travels per pixel of scroll.
106
+ 2. **Image bleed** — the negative inset applied to `.scroll-parallax-section__bg`. The inset is calculated as `ceil(parallaxStrength × 100)%`, ensuring the image always fills the frame even at extremes of the scroll position.
107
+
108
+ | `parallaxStrength` | Character | Inset |
109
+ |---|---|---|
110
+ | `0` | No movement (static background) | `0%` |
111
+ | `0.3–0.5` | Subtle — good for text-heavy sections | `30–50%` |
112
+ | `1` (default) | Standard parallax feel | `100%` |
113
+ | `1.5–2` | Dramatic — large image travel | `150–200%` |
114
+
115
+ Values above `2` are rarely useful and increase layout memory cost.
116
+
117
+ ## Reduced motion
118
+
119
+ The component does not yet implement a `prefers-reduced-motion` media query. If your consuming app needs to respect user motion preferences, disable the parallax effect by setting `parallaxStrength` to `0` and watching the CSS media feature:
120
+
121
+ ```vue
122
+ <script setup lang="ts">
123
+ const prefersReducedMotion = ref(false);
124
+
125
+ onMounted(() => {
126
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
127
+ prefersReducedMotion.value = mq.matches;
128
+ mq.addEventListener("change", (e) => {
129
+ prefersReducedMotion.value = e.matches;
130
+ });
131
+ });
132
+ </script>
133
+
134
+ <template>
135
+ <ScrollParallaxSection
136
+ background-image="/images/banner.webp"
137
+ :parallax-strength="prefersReducedMotion ? 0 : 1"
138
+ />
139
+ </template>
140
+ ```
141
+
142
+ ## Notes
143
+
144
+ - `backgroundImage` is passed through `v-bind()` as a CSS `background-image` value. The image is not processed by `@nuxt/image` — use a path under `public/` or a full URL.
145
+ - `overflow: hidden` is on the root element. Content that needs to escape (dropdowns, tooltips) must be portalled outside.
146
+ - The background layer uses `background-position: center` and `background-size: cover`. The focal point cannot be changed per-prop — override `background-position` in CSS if needed.
147
+ - Multiple `ScrollParallaxSection` instances on the same page each run independent observers and RAF loops. They pause individually when offscreen.
148
+ - Do not nest `ScrollParallaxSection` inside a container with CSS `transform` — this creates a new stacking context and breaks the `getBoundingClientRect` viewport calculation.
@@ -68,6 +68,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
68
68
  ├── input-copy-core.md — InputCopyCore: readonly copy-to-clipboard input; props, emits, slots, CSS classes, usage
69
69
  ├── banner-video.md — BannerVideo: full-width hero video banner, depth tier system, objectFit/objectPosition, reduced-motion fallback, CSS tokens
70
70
  ├── grid-stack.md — GridStack: CSS Grid z-axis stacking, slot API, z-order rules, sizing, video+overlay and image+text patterns
71
+ ├── scroll-parallax-section.md — ScrollParallaxSection: JS-driven parallax background section, parallaxStrength guide, height token, reduced-motion pattern
71
72
  ├── scroll-reveal-frame.md — ScrollRevealFrame: generic parallax clipping frame, slot API, image grid pattern, CSS tokens, browser support
72
73
  ├── scroll-reveal-image.md — ScrollRevealImage: single-image parallax reveal, focalX, imgWidth/imgHeight, responsive frame height
73
74
  ├── site-navigation.md — SiteNavigation: responsive nav with auto-collapse, burger menu, decorator indicators, CSS token API
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
- import { nextTick } from "vue";
4
3
  import BannerVideo from "../BannerVideo.vue";
5
4
 
6
5
  const defaultProps = {
@@ -41,9 +40,9 @@ describe("BannerVideo", () => {
41
40
 
42
41
  // ─── Root element ────────────────────────────────────────────────────────
43
42
 
44
- it("renders a <section> as the root element by default", async () => {
43
+ it("renders a <div> as the root element by default", async () => {
45
44
  const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
46
- expect(wrapper.element.tagName).toBe("SECTION");
45
+ expect(wrapper.element.tagName).toBe("DIV");
47
46
  });
48
47
 
49
48
  it("renders the specified tag as root", async () => {
@@ -194,15 +193,12 @@ describe("BannerVideo", () => {
194
193
  expect(wrapper.attributes("data-depth")).toBe("md");
195
194
  });
196
195
 
197
- it.each(["xs", "sm", "md", "lg", "xl"] as const)(
198
- "sets data-depth to %s when depth prop is %s",
199
- async (depth) => {
200
- const wrapper = await mountSuspended(BannerVideo, {
201
- props: { ...defaultProps, depth },
202
- });
203
- expect(wrapper.attributes("data-depth")).toBe(depth);
204
- }
205
- );
196
+ it.each(["xs", "sm", "md", "lg", "xl"] as const)("sets data-depth to %s when depth prop is %s", async (depth) => {
197
+ const wrapper = await mountSuspended(BannerVideo, {
198
+ props: { ...defaultProps, depth },
199
+ });
200
+ expect(wrapper.attributes("data-depth")).toBe(depth);
201
+ });
206
202
 
207
203
  // ─── CSS custom properties ───────────────────────────────────────────────
208
204
 
@@ -261,7 +257,10 @@ describe("BannerVideo", () => {
261
257
  });
262
258
 
263
259
  it("reflects verticalPosition prop in --_align-self", async () => {
264
- for (const [prop, expected] of [["start", "start"], ["end", "end"]] as const) {
260
+ for (const [prop, expected] of [
261
+ ["start", "start"],
262
+ ["end", "end"],
263
+ ] as const) {
265
264
  const wrapper = await mountSuspended(BannerVideo, {
266
265
  props: { ...defaultProps, verticalPosition: prop },
267
266
  });
@@ -276,7 +275,10 @@ describe("BannerVideo", () => {
276
275
  });
277
276
 
278
277
  it("reflects horizontalPosition prop in --_justify-self", async () => {
279
- for (const [prop, expected] of [["start", "start"], ["end", "end"]] as const) {
278
+ for (const [prop, expected] of [
279
+ ["start", "start"],
280
+ ["end", "end"],
281
+ ] as const) {
280
282
  const wrapper = await mountSuspended(BannerVideo, {
281
283
  props: { ...defaultProps, horizontalPosition: prop },
282
284
  });
@@ -1,13 +1,13 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`BannerVideo > renders correct HTML structure (all props set) 1`] = `
4
- "<header class="banner-video full-bleed" data-depth="xl" style="--_aspect-ratio: 16/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
4
+ "<header class="banner-video full-bleed" data-depth="xl" style="--_aspect-ratio: 16/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" fetchpriority="high" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
5
5
  <source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
6
- </video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"></header>"
6
+ </video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"></header>"
7
7
  `;
8
8
 
9
9
  exports[`BannerVideo > renders correct HTML structure (default props) 1`] = `
10
- "<section class="banner-video" data-depth="md" style="--_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
10
+ "<div class="banner-video" data-depth="md" style="--_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" fetchpriority="high" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
11
11
  <source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
12
- </video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"></section>"
12
+ </video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"></div>"
13
13
  `;
@@ -0,0 +1,108 @@
1
+ <template>
2
+ <component :is="tag" ref="containerRef" class="scroll-parallax-section" :class="[elementClasses]">
3
+ <div ref="bgRef" class="scroll-parallax-section__bg"></div>
4
+ <div class="scroll-parallax-section__content">
5
+ <slot></slot>
6
+ </div>
7
+ </component>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ interface Props {
12
+ tag?: "div" | "section" | "article" | "aside";
13
+ backgroundImage: string;
14
+ parallaxStrength?: number;
15
+ styleClassPassthrough?: string | string[];
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ tag: "div",
20
+ parallaxStrength: 1,
21
+ styleClassPassthrough: () => [],
22
+ });
23
+
24
+ const containerRef = useTemplateRef<HTMLElement>("containerRef");
25
+ const bgRef = useTemplateRef<HTMLElement>("bgRef");
26
+
27
+ const bgImage = computed(() => `url("${props.backgroundImage}")`);
28
+ const bgInset = computed(() => `-${Math.ceil(props.parallaxStrength * 100)}% 0`);
29
+
30
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
31
+
32
+ watch(
33
+ () => props.styleClassPassthrough,
34
+ () => resetElementClasses(props.styleClassPassthrough)
35
+ );
36
+
37
+ let rafId: number | null = null;
38
+ let isVisible = false;
39
+ let observer: IntersectionObserver | null = null;
40
+
41
+ function updateParallax() {
42
+ if (!bgRef.value || !containerRef.value) return;
43
+ const rect = containerRef.value.getBoundingClientRect();
44
+ const viewportCenter = window.innerHeight / 2;
45
+ const elementCenter = rect.top + rect.height / 2;
46
+ const offset = (viewportCenter - elementCenter) * props.parallaxStrength;
47
+ bgRef.value.style.transform = `translate3d(0, ${offset}px, 0)`;
48
+ }
49
+
50
+ function onScroll() {
51
+ if (!isVisible || rafId !== null) return;
52
+ rafId = requestAnimationFrame(() => {
53
+ updateParallax();
54
+ rafId = null;
55
+ });
56
+ }
57
+
58
+ onMounted(() => {
59
+ updateParallax();
60
+
61
+ observer = new IntersectionObserver(
62
+ (entries) => {
63
+ isVisible = entries[0]!.isIntersecting;
64
+ if (isVisible) updateParallax();
65
+ },
66
+ { rootMargin: "50px" }
67
+ );
68
+
69
+ if (containerRef.value) observer.observe(containerRef.value);
70
+
71
+ window.addEventListener("scroll", onScroll, { passive: true });
72
+ window.addEventListener("resize", onScroll, { passive: true });
73
+ });
74
+
75
+ onUnmounted(() => {
76
+ window.removeEventListener("scroll", onScroll);
77
+ window.removeEventListener("resize", onScroll);
78
+ if (rafId !== null) cancelAnimationFrame(rafId);
79
+ observer?.disconnect();
80
+ });
81
+ </script>
82
+
83
+ <style lang="css">
84
+ @layer components {
85
+ .scroll-parallax-section {
86
+ --scroll-parallax-section-height: 25svh;
87
+ height: var(--scroll-parallax-section-height);
88
+ position: relative;
89
+ overflow: hidden;
90
+ width: 100%;
91
+
92
+ .scroll-parallax-section__bg {
93
+ position: absolute;
94
+ inset: v-bind(bgInset);
95
+ background-image: v-bind(bgImage);
96
+ background-position: center;
97
+ background-repeat: no-repeat;
98
+ background-size: cover;
99
+ will-change: transform;
100
+ }
101
+
102
+ .scroll-parallax-section__content {
103
+ position: relative;
104
+ z-index: 1;
105
+ }
106
+ }
107
+ }
108
+ </style>
@@ -0,0 +1,151 @@
1
+ import ScrollParallaxSection from "../ScrollParallaxSection.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+
4
+ const meta: Meta<typeof ScrollParallaxSection> = {
5
+ title: "Atoms/Effects/ScrollParallaxSection",
6
+ component: ScrollParallaxSection,
7
+ argTypes: {
8
+ tag: {
9
+ control: "select",
10
+ options: ["div", "section", "article", "aside"],
11
+ description: "HTML element rendered as the container",
12
+ table: { category: "Layout" },
13
+ },
14
+ backgroundImage: {
15
+ control: "text",
16
+ description: "Path or URL of the background image",
17
+ table: { category: "Image" },
18
+ },
19
+ parallaxStrength: {
20
+ control: { type: "range", min: 0, max: 2, step: 0.1 },
21
+ description:
22
+ "Multiplier for the parallax offset. 0 = no movement, 1 = standard, 2 = very dramatic. Also controls the image bleed (inset) so the full image fills the frame at all scroll positions.",
23
+ table: { category: "Layout" },
24
+ },
25
+ styleClassPassthrough: {
26
+ table: { disable: true },
27
+ },
28
+ },
29
+ parameters: {
30
+ docs: {
31
+ description: {
32
+ component:
33
+ "A section with a fixed-background parallax effect implemented via `requestAnimationFrame` and `IntersectionObserver`. The background image bleeds beyond the container bounds and is translated vertically as the component scrolls through the viewport. Height is controlled via the `--scroll-parallax-section-height` CSS custom property (default `25svh`). Slot content is layered above the background at `z-index: 1`.",
34
+ },
35
+ },
36
+ },
37
+ };
38
+
39
+ export default meta;
40
+ type Story = StoryObj<typeof ScrollParallaxSection>;
41
+
42
+ const scrollWrapper = (inner: string) => `
43
+ <div style="padding-block: 60vh; max-width: 960px; margin-inline: auto;">
44
+ <p style="text-align: center; font-size: 1.4rem; opacity: 0.5; margin-block-end: 4rem;">Scroll to see the parallax effect</p>
45
+ ${inner}
46
+ </div>
47
+ `;
48
+
49
+ export const Default: Story = {
50
+ args: {
51
+ backgroundImage: "/images/banners/banner-mid-brown.webp",
52
+ parallaxStrength: 1,
53
+ tag: "div",
54
+ },
55
+ render: (args) => ({
56
+ components: { ScrollParallaxSection },
57
+ setup() {
58
+ return { args };
59
+ },
60
+ template: scrollWrapper(`<ScrollParallaxSection v-bind="args" />`),
61
+ }),
62
+ parameters: {
63
+ docs: {
64
+ description: {
65
+ story: "Default configuration — standard parallax strength with no slot content.",
66
+ },
67
+ },
68
+ },
69
+ };
70
+
71
+ export const SubtleEffect: Story = {
72
+ args: {
73
+ backgroundImage: "/images/banners/banner-light-brunette.webp",
74
+ parallaxStrength: 0.4,
75
+ tag: "div",
76
+ },
77
+ render: Default.render,
78
+ parameters: {
79
+ docs: {
80
+ description: {
81
+ story: "A low parallaxStrength (0.4) gives a gentle, understated movement — good for hero banners where content legibility matters.",
82
+ },
83
+ },
84
+ },
85
+ };
86
+
87
+ export const DramaticEffect: Story = {
88
+ args: {
89
+ backgroundImage: "/images/banners/banner-ginger.webp",
90
+ parallaxStrength: 1.8,
91
+ tag: "div",
92
+ },
93
+ render: Default.render,
94
+ parameters: {
95
+ docs: {
96
+ description: {
97
+ story: "A high parallaxStrength (1.8) creates a dramatic sweep — the image travels a much larger distance relative to the scroll position.",
98
+ },
99
+ },
100
+ },
101
+ };
102
+
103
+ export const WithSlotContent: Story = {
104
+ args: {
105
+ backgroundImage: "/images/page/hero/hero-dark.jpg",
106
+ parallaxStrength: 1,
107
+ tag: "section",
108
+ },
109
+ render: (args) => ({
110
+ components: { ScrollParallaxSection },
111
+ setup() {
112
+ return { args };
113
+ },
114
+ template: scrollWrapper(`
115
+ <ScrollParallaxSection v-bind="args" style="--scroll-parallax-section-height: 40svh; display: flex; align-items: center; justify-content: center;">
116
+ <p style="color: white; font-size: 2.4rem; font-weight: 600; text-align: center; text-shadow: 0 2px 8px rgba(0,0,0,0.6); padding: 2rem;">
117
+ Slot content sits above the parallax background
118
+ </p>
119
+ </ScrollParallaxSection>
120
+ `),
121
+ }),
122
+ parameters: {
123
+ docs: {
124
+ description: {
125
+ story: "Slot content is placed at `z-index: 1` above the parallax layer. Height is increased via the `--scroll-parallax-section-height` CSS custom property to accommodate the text.",
126
+ },
127
+ },
128
+ },
129
+ };
130
+
131
+ export const TallSection: Story = {
132
+ args: {
133
+ backgroundImage: "/images/page/hero/hero-blonde.jpg",
134
+ parallaxStrength: 1,
135
+ tag: "div",
136
+ },
137
+ render: (args) => ({
138
+ components: { ScrollParallaxSection },
139
+ setup() {
140
+ return { args };
141
+ },
142
+ template: scrollWrapper(`<ScrollParallaxSection v-bind="args" style="--scroll-parallax-section-height: 60svh;" />`),
143
+ }),
144
+ parameters: {
145
+ docs: {
146
+ description: {
147
+ story: "The `--scroll-parallax-section-height` custom property overrides the default `25svh` height. Here it is set to `60svh` for a tall banner.",
148
+ },
149
+ },
150
+ },
151
+ };
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { nextTick } from "vue";
3
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
4
+ import ScrollParallaxSection from "../ScrollParallaxSection.vue";
5
+
6
+ describe("ScrollParallaxSection", () => {
7
+ beforeEach(() => {
8
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
9
+ cb(0);
10
+ return 0;
11
+ });
12
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
13
+ });
14
+
15
+ it("mounts without error", async () => {
16
+ const wrapper = await mountSuspended(ScrollParallaxSection, {
17
+ props: { backgroundImage: "/img/bg.jpg" },
18
+ });
19
+ expect(wrapper.vm).toBeTruthy();
20
+ });
21
+
22
+ it("renders the bg and content divs", async () => {
23
+ const wrapper = await mountSuspended(ScrollParallaxSection, {
24
+ props: { backgroundImage: "/img/bg.jpg" },
25
+ });
26
+ expect(wrapper.find(".scroll-parallax-section__bg").exists()).toBe(true);
27
+ expect(wrapper.find(".scroll-parallax-section__content").exists()).toBe(true);
28
+ });
29
+
30
+ it("renders slot content inside __content", async () => {
31
+ const wrapper = await mountSuspended(ScrollParallaxSection, {
32
+ props: { backgroundImage: "/img/bg.jpg" },
33
+ slots: { default: "<p>Hello</p>" },
34
+ });
35
+ expect(wrapper.find(".scroll-parallax-section__content p").text()).toBe("Hello");
36
+ });
37
+
38
+ it("renders as the given tag", async () => {
39
+ const wrapper = await mountSuspended(ScrollParallaxSection, {
40
+ props: { backgroundImage: "/img/bg.jpg", tag: "section" },
41
+ });
42
+ expect(wrapper.element.tagName.toLowerCase()).toBe("section");
43
+ });
44
+
45
+ it("defaults to div tag", async () => {
46
+ const wrapper = await mountSuspended(ScrollParallaxSection, {
47
+ props: { backgroundImage: "/img/bg.jpg" },
48
+ });
49
+ expect(wrapper.element.tagName.toLowerCase()).toBe("div");
50
+ });
51
+
52
+ it("applies styleClassPassthrough classes", async () => {
53
+ const wrapper = await mountSuspended(ScrollParallaxSection, {
54
+ props: { backgroundImage: "/img/bg.jpg", styleClassPassthrough: "my-custom-class" },
55
+ });
56
+ expect(wrapper.classes()).toContain("my-custom-class");
57
+ });
58
+
59
+ it("adds and removes scroll and resize listeners on mount/unmount", async () => {
60
+ const addSpy = vi.spyOn(window, "addEventListener");
61
+ const removeSpy = vi.spyOn(window, "removeEventListener");
62
+
63
+ const wrapper = await mountSuspended(ScrollParallaxSection, {
64
+ props: { backgroundImage: "/img/bg.jpg" },
65
+ });
66
+
67
+ expect(addSpy).toHaveBeenCalledWith("scroll", expect.any(Function), { passive: true });
68
+ expect(addSpy).toHaveBeenCalledWith("resize", expect.any(Function), { passive: true });
69
+
70
+ wrapper.unmount();
71
+
72
+ expect(removeSpy).toHaveBeenCalledWith("scroll", expect.any(Function));
73
+ expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function));
74
+ });
75
+
76
+ it("updates bg transform on scroll", async () => {
77
+ const wrapper = await mountSuspended(ScrollParallaxSection, {
78
+ props: { backgroundImage: "/img/bg.jpg", parallaxStrength: 0.3 },
79
+ });
80
+
81
+ const bg = wrapper.find(".scroll-parallax-section__bg").element as HTMLElement;
82
+
83
+ Object.defineProperty(wrapper.element, "offsetTop", { value: 400, configurable: true });
84
+ Object.defineProperty(wrapper.element, "offsetHeight", { value: 600, configurable: true });
85
+
86
+ window.dispatchEvent(new Event("scroll"));
87
+ await nextTick();
88
+
89
+ expect(bg.style.transform).toMatch(/translate3d\(/);
90
+ });
91
+ });
@@ -5,21 +5,15 @@
5
5
  </template>
6
6
 
7
7
  <script setup lang="ts">
8
- const props = defineProps({
9
- tag: {
10
- type: String,
11
- default: "div",
12
- validator(value: string) {
13
- return ["div", "section", "article", "aside"].includes(value);
14
- },
15
- },
16
- backgroundImage: {
17
- type: String,
18
- },
19
- styleClassPassthrough: {
20
- type: [String, Array] as PropType<string | string[]>,
21
- default: () => [],
22
- },
8
+ interface Props {
9
+ tag?: "div" | "section" | "article" | "aside";
10
+ backgroundImage: string;
11
+ styleClassPassthrough?: string | string[];
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ tag: "div",
16
+ styleClassPassthrough: () => [],
23
17
  });
24
18
 
25
19
  const slots = useSlots();
@@ -38,25 +32,25 @@ watch(
38
32
 
39
33
  <style lang="css">
40
34
  @layer components {
41
- .section-parallax {
42
- /* Component styles */
43
-
44
- min-height: 100svh;
45
- background-image: v-bind(backgroundImage);
46
- background-position: center;
47
- background-repeat: no-repeat;
48
- background-size: cover;
49
- position: relative;
50
-
51
- background-color: light-dark(var(--slate-01), var(--slate-08));
52
- width: 100%;
53
-
54
- @media (hover: hover) and (pointer: fine) {
55
- @supports (background-attachment: fixed) {
56
- background-attachment: fixed;
57
- min-height: 120vh;
35
+ .section-parallax {
36
+ /* Component styles */
37
+
38
+ min-height: 100svh;
39
+ background-image: v-bind(backgroundImage);
40
+ background-position: center;
41
+ background-repeat: no-repeat;
42
+ background-size: cover;
43
+ position: relative;
44
+
45
+ background-color: light-dark(var(--slate-01), var(--slate-08));
46
+ width: 100%;
47
+
48
+ @media (hover: hover) and (pointer: fine) {
49
+ @supports (background-attachment: fixed) {
50
+ background-attachment: fixed;
51
+ min-height: 120vh;
52
+ }
58
53
  }
59
54
  }
60
55
  }
61
- }
62
56
  </style>
@@ -68,6 +68,7 @@ const responsiveNavLinks = {
68
68
  { name: "Banner", path: "/ui/display-banner" },
69
69
  { name: "Banner Video", path: "/banner-video" },
70
70
  { name: "Section Parallax", path: "/ui/section-parallax" },
71
+ { name: "Scroll Parallax Section", path: "/ui/scroll-parallax-section" },
71
72
  { name: "Animated SVG Text", path: "/ui/animated-svg-text" },
72
73
  { name: "Carousel (Basic)", path: "/ui/carousel-basic" },
73
74
  { name: "Carousel (Infinite)", path: "/ui/carousel-infinite" },
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <div>
3
+ <NuxtLayout name="default">
4
+ <template #layout-content>
5
+ <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['expanding-panel-section', 'mbe-20']">
6
+ <h1 class="page-heading-1">ScrollParallaxSection Component</h1>
7
+ <p class="mbe-12">
8
+ JS-driven fixed background — works on all devices including iOS and Safari. The background image stays
9
+ fixed in the viewport while the section scrolls past it, replicating <code>background-attachment: fixed</code>.
10
+ </p>
11
+ </LayoutRow>
12
+ <ScrollParallaxSection
13
+ tag="section"
14
+ background-image="/images/rotating-carousel/image-3.webp"
15
+ :style-class-passthrough="['mbe-20']"
16
+ >
17
+ <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['expanding-panel-section']">
18
+ <h2 class="page-heading-1">First Section</h2>
19
+ <p class="page-body-normal">Scroll down to see the parallax effect in action.</p>
20
+ <p class="page-body-normal">The background image moves slower than the page, creating a depth effect.</p>
21
+ </LayoutRow>
22
+ </ScrollParallaxSection>
23
+ <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['expanding-panel-section', 'mbe-20']">
24
+ <h2 class="page-heading-2">Between sections</h2>
25
+ <p class="page-body-normal">Regular page content between two parallax sections.</p>
26
+ </LayoutRow>
27
+ <ScrollParallaxSection
28
+ tag="section"
29
+ background-image="/images/rotating-carousel/image-1.webp"
30
+ :style-class-passthrough="['mbe-20']"
31
+ >
32
+ <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['expanding-panel-section']">
33
+ <h2 class="page-heading-1">Second Section</h2>
34
+ <p class="page-body-normal">A second section to demonstrate multiple fixed-background sections on the same page.</p>
35
+ </LayoutRow>
36
+ </ScrollParallaxSection>
37
+ </template>
38
+ </NuxtLayout>
39
+ </div>
40
+ </template>
41
+
42
+ <script setup lang="ts">
43
+ definePageMeta({
44
+ layout: false,
45
+ });
46
+
47
+ useHead({
48
+ title: "ScrollParallaxSection Component",
49
+ meta: [
50
+ {
51
+ name: "description",
52
+ content: "JS-driven parallax background section — works on all devices including iOS and Safari.",
53
+ },
54
+ ],
55
+ bodyAttrs: {
56
+ class: "scroll-parallax-section-page",
57
+ },
58
+ });
59
+ </script>
60
+
61
+ <style lang="css">
62
+ .scroll-parallax-section-page {
63
+ margin-block-end: 100vh;
64
+ }
65
+ </style>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.35",
4
+ "version": "9.1.36",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",