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.
- package/.claude/settings.json +0 -24
- package/.claude/settings.local.json +29 -1
- package/.claude/skills/components/scroll-parallax-section.md +148 -0
- package/.claude/skills/index.md +1 -0
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +16 -14
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +4 -4
- package/app/components/01.atoms/scroll-parallax-section/ScrollParallaxSection.vue +108 -0
- package/app/components/01.atoms/scroll-parallax-section/stories/ScrollParallaxSection.stories.ts +151 -0
- package/app/components/01.atoms/scroll-parallax-section/tests/ScrollParallaxSection.spec.ts +91 -0
- package/app/components/parallax/SectionParallax.vue +27 -33
- package/app/layouts/default.vue +1 -0
- package/app/pages/ui/scroll-parallax-section.vue +65 -0
- package/package.json +1 -1
package/.claude/settings.json
CHANGED
|
@@ -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.
|
package/.claude/skills/index.md
CHANGED
|
@@ -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 <
|
|
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("
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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 [
|
|
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 [
|
|
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
|
-
"<
|
|
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"></
|
|
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>
|
package/app/components/01.atoms/scroll-parallax-section/stories/ScrollParallaxSection.stories.ts
ADDED
|
@@ -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
|
-
|
|
9
|
-
tag
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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>
|
package/app/layouts/default.vue
CHANGED
|
@@ -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>
|