astro-scroll-transition 0.0.1

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/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Astro Scroll Transition
2
+
3
+ A scroll-driven image transition component for Astro. Import the `.astro` component, provide an ordered list of images, and add matching named slots for the content shown with each image.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install astro-scroll-transition
9
+ ```
10
+
11
+ Astro is a peer dependency and must be installed in the consuming project.
12
+
13
+ ## Use
14
+
15
+ ```astro
16
+ ---
17
+ import Transition from "astro-scroll-transition/Transition.astro";
18
+ import firstImage from "../assets/first.jpg";
19
+ import secondImage from "../assets/second.jpg";
20
+ import thirdImage from "../assets/third.jpg";
21
+ ---
22
+
23
+ <Transition
24
+ images={[firstImage, secondImage, thirdImage]}
25
+ transitionDistance="75vh"
26
+ transitionHold="25vh"
27
+ >
28
+ <section slot="content-0"><h2>First scene</h2></section>
29
+ <section slot="content-1"><h2>Second scene</h2></section>
30
+ <section slot="content-2"><h2>Third scene</h2></section>
31
+ </Transition>
32
+ ```
33
+
34
+ Slots are named `content-0`, `content-1`, and so on, matching the order of the `images` array. Images may be imported assets or image URLs supported by Astro's [image service](https://docs.astro.build/en/guides/images/).
35
+
36
+ ### Props
37
+
38
+ | Prop | Default | Description |
39
+ | --- | --- | --- |
40
+ | `images` | `[]` | Ordered image sources for the transition. |
41
+ | `transitionDistance` | `75vh` | Scroll distance used to fade between adjacent images. |
42
+ | `transitionHold` | `25vh` | Scroll distance to hold each image before the next fade. |
43
+ | `scrollStartHeight` | `0vh` | Scroll spacing before the first transition. |
44
+ | `scrollEndHeight` | `0vh` | Scroll spacing after the last transition. |
45
+ | `containerHeight` | calculated | Optional explicit height for the component wrapper. Otherwise derived from the viewport and transition props. |
46
+
47
+ ## Run the demo locally
48
+
49
+ The repository root is also a standalone Astro demo site. It imports the component through the package export, so the demo exercises the same import path used by consumers.
50
+
51
+ ```sh
52
+ npm install
53
+ npm run demo
54
+ ```
55
+
56
+ Astro starts the demo at `http://localhost:4321`. Build and preview the demo with `npm run build` and `npm run preview`.
57
+
58
+ ## Package and publish
59
+
60
+ Before publishing, ensure the `name` in `package.json` is available on npm. Preview the exact contents of the package tarball with:
61
+
62
+ ```sh
63
+ npm pack --dry-run
64
+ ```
65
+
66
+ The published package contains the component and this README. `npm publish` runs the demo production build first through the `prepublishOnly` script.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "astro-scroll-transition",
3
+ "description": "Scroll-driven image transitions for Astro.",
4
+ "type": "module",
5
+ "version": "0.0.1",
6
+ "exports": {
7
+ "./Transition.astro": "./src/components/Transition.astro"
8
+ },
9
+ "files": [
10
+ "src/components/Transition.astro",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.12.0"
15
+ },
16
+ "scripts": {
17
+ "dev": "astro dev",
18
+ "demo": "astro dev",
19
+ "build": "astro build",
20
+ "preview": "astro preview",
21
+ "prepublishOnly": "npm run build",
22
+ "pack:dry-run": "npm pack --dry-run",
23
+ "astro": "astro"
24
+ },
25
+ "peerDependencies": {
26
+ "astro": ">=5.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "astro": "^7.3.2"
30
+ },
31
+ "allowScripts": {
32
+ "esbuild": true
33
+ }
34
+ }
@@ -0,0 +1,193 @@
1
+ ---
2
+ import { Image } from "astro:assets";
3
+
4
+ const {
5
+ // Scroll distance before the first image transition begins.
6
+ scrollStartHeight = "0vh",
7
+ // Scroll distance after the final image transition ends.
8
+ scrollEndHeight = "0vh",
9
+ // Optional explicit wrapper height; otherwise it is calculated from the other props.
10
+ containerHeight,
11
+ // Image paths shown in sequence during the scroll transition.
12
+ images = [],
13
+ // Scroll distance spent holding each image before the next fade begins.
14
+ transitionHold = "25vh",
15
+ // Scroll distance used to fade from one image to the next.
16
+ transitionDistance = "75vh",
17
+ } = Astro.props;
18
+
19
+ const transitionCount = Math.max(images.length - 1, 0);
20
+ const dynamicContainerHeight =
21
+ containerHeight ??
22
+ `calc(100vh + ${scrollStartHeight} + ${transitionCount} * (${transitionDistance} + ${transitionHold}) + ${scrollEndHeight})`;
23
+
24
+ const getImageName = (imagePath: string) => {
25
+ const pathWithoutQuery = imagePath.split(/[?#]/)[0];
26
+ const fileName = pathWithoutQuery.split("/").pop() ?? "image";
27
+
28
+ return fileName.replace(/\.[^/.]+$/, "");
29
+ };
30
+ ---
31
+
32
+ <div
33
+ class="transition-wrapper"
34
+ data-scroll-start-height={scrollStartHeight}
35
+ data-scroll-end-height={scrollEndHeight}
36
+ data-transition-hold={transitionHold}
37
+ data-transition-distance={transitionDistance}
38
+ style={`height: ${dynamicContainerHeight};`}
39
+ >
40
+ {
41
+ images.map((imagePath: string, index: number) => (
42
+ <div
43
+ class="content"
44
+ id={`content-${getImageName(imagePath)}-${index}`}
45
+ style={`--content-index: ${index}; --scroll-start: ${scrollStartHeight}; --transition-distance: ${transitionDistance}; --transition-hold: ${transitionHold};`}
46
+ >
47
+ <slot name={`content-${index}`} />
48
+ </div>
49
+ ))
50
+ }
51
+
52
+ {
53
+ images.map((imagePath: string) => (
54
+ <Image
55
+ class="transition-image"
56
+ src={imagePath}
57
+ alt=""
58
+ width={600}
59
+ height={400}
60
+ />
61
+ ))
62
+ }
63
+ </div>
64
+
65
+ <script>
66
+ const transitionWrapper = document.querySelector<HTMLElement>(
67
+ ".transition-wrapper",
68
+ );
69
+ const transitionImages = Array.from(
70
+ document.querySelectorAll<HTMLElement>(".transition-image"),
71
+ );
72
+ const transitionContent = Array.from(
73
+ document.querySelectorAll<HTMLElement>(".content"),
74
+ );
75
+
76
+ const scrollStartHeight =
77
+ transitionWrapper?.dataset.scrollStartHeight || "100vh";
78
+ const scrollEndHeight = transitionWrapper?.dataset.scrollEndHeight || "100vh";
79
+
80
+ const getHeightInPixels = (height: string) => {
81
+ if (height.endsWith("vh")) {
82
+ return (Number.parseFloat(height) / 100) * window.innerHeight;
83
+ }
84
+
85
+ if (height.endsWith("vw")) {
86
+ return (Number.parseFloat(height) / 100) * window.innerWidth;
87
+ }
88
+
89
+ return Number.parseFloat(height);
90
+ };
91
+
92
+ const transitionHold = Math.max(
93
+ getHeightInPixels(transitionWrapper?.dataset.transitionHold || "0"),
94
+ 0,
95
+ );
96
+ const transitionDistance = Math.max(
97
+ getHeightInPixels(transitionWrapper?.dataset.transitionDistance || "100vh"),
98
+ 0,
99
+ );
100
+
101
+ const updateOpacity = () => {
102
+ if (!transitionWrapper || transitionImages.length === 0) return;
103
+
104
+ const section = transitionWrapper.getBoundingClientRect();
105
+ const scrollRange = transitionWrapper.offsetHeight - window.innerHeight;
106
+ const startOffset = getHeightInPixels(scrollStartHeight);
107
+ const endOffset = getHeightInPixels(scrollEndHeight);
108
+ const animationRange = Math.max(scrollRange - startOffset - endOffset, 0);
109
+ const animationProgress = animationRange
110
+ ? (-section.top - startOffset) / animationRange
111
+ : 0;
112
+ const scrollPercent = Math.min(Math.max(animationProgress, 0), 1);
113
+ const transitionCount = transitionImages.length - 1;
114
+ const fadeDistance = transitionCount ? transitionDistance : 0;
115
+ const segmentDistance = fadeDistance + transitionHold;
116
+ const scrollDistance = scrollPercent * animationRange;
117
+ const imageProgress = segmentDistance
118
+ ? scrollDistance / segmentDistance
119
+ : 0;
120
+ const transitionIndex = Math.min(
121
+ Math.floor(imageProgress),
122
+ transitionImages.length - 2,
123
+ );
124
+ const segmentProgress = segmentDistance
125
+ ? scrollDistance - transitionIndex * segmentDistance
126
+ : 0;
127
+ const imagePosition =
128
+ transitionImages.length === 1
129
+ ? 0
130
+ : segmentProgress < fadeDistance && fadeDistance
131
+ ? transitionIndex + segmentProgress / fadeDistance
132
+ : transitionIndex + 1;
133
+
134
+ const imageOpacities = transitionImages.map((_, index) =>
135
+ Math.max(1 - Math.abs(imagePosition - index), 0),
136
+ );
137
+
138
+ transitionImages.forEach((image, index) => {
139
+ image.style.opacity = `${imageOpacities[index]}`;
140
+ });
141
+
142
+ const activeContentIndex = Math.min(
143
+ Math.max(Math.round(imagePosition), 0),
144
+ transitionContent.length - 1,
145
+ );
146
+
147
+ transitionContent.forEach((content, index) => {
148
+ const isActive = index === activeContentIndex;
149
+ content.classList.toggle("active", isActive);
150
+ content.style.zIndex = isActive ? "2" : "1";
151
+ content.style.pointerEvents = isActive ? "auto" : "none";
152
+ content.inert = !isActive;
153
+ content.setAttribute("aria-hidden", `${!isActive}`);
154
+ });
155
+ };
156
+
157
+ window.addEventListener("scroll", updateOpacity, { passive: true });
158
+ updateOpacity();
159
+ </script>
160
+
161
+ <style>
162
+ .transition-wrapper {
163
+ position: relative;
164
+ display: grid;
165
+
166
+ .transition-image {
167
+ position: sticky;
168
+ top: 0;
169
+ width: 100%;
170
+ height: 100vh;
171
+ object-fit: cover;
172
+ grid-area: 1 / 1;
173
+ align-self: start;
174
+ z-index: 0;
175
+ }
176
+
177
+ .content {
178
+ grid-area: 1 / 1;
179
+ z-index: 1;
180
+ position: relative;
181
+ pointer-events: none;
182
+ padding-top: calc(
183
+ var(--scroll-start) + var(--content-index) *
184
+ (var(--transition-distance) + var(--transition-hold))
185
+ );
186
+
187
+ &.active {
188
+ z-index: 2;
189
+ pointer-events: auto;
190
+ }
191
+ }
192
+ }
193
+ </style>