@manufosela/hero-scroll-animation 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 manufosela
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # @manufosela/hero-scroll-animation
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@manufosela/hero-scroll-animation)](https://www.npmjs.com/package/@manufosela/hero-scroll-animation)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Hero section with scroll-driven parallax animation built with [Lit 3](https://lit.dev/).
7
+
8
+ As the user scrolls, the hero content fades out and slides up while a center image rises and side images slide in from the edges. On mobile, the animation is triggered by an intersection observer instead of continuous scroll tracking.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install @manufosela/hero-scroll-animation
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```javascript
19
+ import '@manufosela/hero-scroll-animation';
20
+ ```
21
+
22
+ ```html
23
+ <hero-scroll-animation>
24
+ <img slot="background" src="bg.jpg" />
25
+ <img slot="center" src="center.png" />
26
+ <img slot="left" src="left.png" />
27
+ <img slot="right" src="right.png" />
28
+ <div slot="content">
29
+ <h1>Hero Title</h1>
30
+ <p>Subtitle text</p>
31
+ </div>
32
+ </hero-scroll-animation>
33
+ ```
34
+
35
+ ## Attributes
36
+
37
+ | Attribute | Type | Default | Description |
38
+ |-----------|------|---------|-------------|
39
+ | `background-text` | String | `''` | Large decorative text behind the images |
40
+ | `scroll-height` | Number | `450` | Scroll distance in vh for the parallax effect |
41
+ | `overlay-opacity` | Number | `0.5` | Opacity of the dark overlay (0-1) |
42
+ | `scrub` | Number | `1` | Smoothing factor for scroll interpolation |
43
+ | `mobile-breakpoint` | Number | `768` | Viewport width (px) below which mobile mode activates |
44
+ | `mobile-scroll-height` | Number | `220` | Scroll distance in vh for mobile mode |
45
+
46
+ ## Slots
47
+
48
+ | Slot | Description |
49
+ |------|-------------|
50
+ | `content` | Main content (headings, text, CTAs) displayed over the hero |
51
+ | `background` | Hidden `<img>` whose `src` is used as the background image |
52
+ | `center` | Hidden `<img>` whose `src` is used for the center parallax image |
53
+ | `left` | Hidden `<img>` whose `src` is used for the left side image |
54
+ | `right` | Hidden `<img>` whose `src` is used for the right side image |
55
+
56
+ ## CSS Custom Properties
57
+
58
+ | Property | Default | Description |
59
+ |----------|---------|-------------|
60
+ | `--hero-accent-color` | `#bfa15f` | Accent color for decorative elements |
61
+ | `--hero-text-color` | `#f0f0f0` | Default text color inside the hero |
62
+ | `--hero-bg-gradient-start` | `#d4af37` | Start color for the background text gradient |
63
+ | `--hero-bg-gradient-end` | `#f4e4b0` | End color for the background text gradient |
64
+
65
+ ## Examples
66
+
67
+ ### With background text
68
+
69
+ ```html
70
+ <hero-scroll-animation background-text="PREMIUM">
71
+ <img slot="background" src="bg.jpg" />
72
+ <img slot="center" src="center.png" />
73
+ <div slot="content">
74
+ <h1>Premium Collection</h1>
75
+ </div>
76
+ </hero-scroll-animation>
77
+ ```
78
+
79
+ ### Custom overlay and scroll distance
80
+
81
+ ```html
82
+ <hero-scroll-animation overlay-opacity="0.7" scroll-height="350">
83
+ <img slot="background" src="bg.jpg" />
84
+ <img slot="center" src="center.png" />
85
+ <img slot="left" src="left.png" />
86
+ <img slot="right" src="right.png" />
87
+ <div slot="content">
88
+ <h1>Dark Hero</h1>
89
+ </div>
90
+ </hero-scroll-animation>
91
+ ```
92
+
93
+ ### Custom gradient colors
94
+
95
+ ```html
96
+ <hero-scroll-animation
97
+ background-text="EXPLORE"
98
+ style="
99
+ --hero-bg-gradient-start: #e63946;
100
+ --hero-bg-gradient-end: #f4a261;
101
+ "
102
+ >
103
+ <img slot="background" src="bg.jpg" />
104
+ <img slot="center" src="center.png" />
105
+ <div slot="content">
106
+ <h1>Explore</h1>
107
+ </div>
108
+ </hero-scroll-animation>
109
+ ```
110
+
111
+ ## License
112
+
113
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@manufosela/hero-scroll-animation",
3
+ "version": "1.0.0",
4
+ "description": "Hero section with scroll-driven parallax animation and image reveal",
5
+ "license": "MIT",
6
+ "author": "manufosela",
7
+ "type": "module",
8
+ "main": "./src/hero-scroll-animation.js",
9
+ "module": "./src/hero-scroll-animation.js",
10
+ "types": "./src/hero-scroll-animation.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/hero-scroll-animation.d.ts",
14
+ "import": "./src/hero-scroll-animation.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "src"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/manufosela/ui-components",
23
+ "directory": "packages/hero-scroll-animation"
24
+ },
25
+ "keywords": [
26
+ "web-components",
27
+ "lit",
28
+ "hero",
29
+ "scroll-animation",
30
+ "parallax"
31
+ ],
32
+ "homepage": "https://github.com/manufosela/ui-components/tree/main/packages/hero-scroll-animation#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/manufosela/ui-components/issues"
35
+ },
36
+ "dependencies": {
37
+ "lit": "^3.2.1"
38
+ },
39
+ "customElements": "custom-elements.json",
40
+ "scripts": {
41
+ "start": "web-dev-server --node-resolve --open demo/ --watch",
42
+ "test": "web-test-runner",
43
+ "test:watch": "web-test-runner --watch",
44
+ "test:coverage": "web-test-runner --coverage",
45
+ "build:types": "tsc --declaration --declarationMap --emitDeclarationOnly --allowJs --checkJs --outDir ./src ./src/hero-scroll-animation.js"
46
+ }
47
+ }
@@ -0,0 +1,670 @@
1
+ import { LitElement, html, css } from 'lit';
2
+
3
+ /**
4
+ * Hero section with scroll-driven parallax animation.
5
+ *
6
+ * As the user scrolls, the hero content fades out and slides up, while
7
+ * a center image rises and side images slide in from the edges. Supports
8
+ * both desktop (scroll-linked) and mobile (intersection-triggered) modes.
9
+ *
10
+ * Images are provided via named slots (`background`, `center`, `left`, `right`)
11
+ * and their `src` attributes are extracted to render internal `<img>` elements.
12
+ * The `content` slot holds arbitrary markup displayed over the hero background.
13
+ *
14
+ * @element hero-scroll-animation
15
+ *
16
+ * @attr {String} background-text - Large decorative text rendered behind the images
17
+ * @attr {Number} scroll-height - Scroll distance in vh units for the parallax effect (default: 450)
18
+ * @attr {Number} overlay-opacity - Opacity of the dark overlay on the background image (default: 0.5)
19
+ * @attr {Number} scrub - Smoothing factor for the scroll interpolation (default: 1)
20
+ * @attr {Number} mobile-breakpoint - Viewport width in px below which mobile mode is used (default: 768)
21
+ * @attr {Number} mobile-scroll-height - Scroll distance in vh units for mobile mode (default: 220)
22
+ *
23
+ * @cssprop [--hero-accent-color=#bfa15f] - Accent color used for decorative elements
24
+ * @cssprop [--hero-text-color=#f0f0f0] - Default text color inside the hero
25
+ * @cssprop [--hero-bg-gradient-start=#d4af37] - Start color for the background text gradient
26
+ * @cssprop [--hero-bg-gradient-end=#f4e4b0] - End color for the background text gradient
27
+ *
28
+ * @slot content - Main content displayed over the hero (headings, text, CTAs)
29
+ * @slot background - Hidden `<img>` whose `src` is used as the hero background image
30
+ * @slot center - Hidden `<img>` whose `src` is used for the center parallax image
31
+ * @slot left - Hidden `<img>` whose `src` is used for the left side parallax image
32
+ * @slot right - Hidden `<img>` whose `src` is used for the right side parallax image
33
+ */
34
+ export class HeroScrollAnimation extends LitElement {
35
+ static properties = {
36
+ backgroundText: { type: String, attribute: 'background-text' },
37
+ scrollHeight: { type: Number, attribute: 'scroll-height' },
38
+ overlayOpacity: { type: Number, attribute: 'overlay-opacity' },
39
+ scrub: { type: Number },
40
+ mobileBreakpoint: { type: Number, attribute: 'mobile-breakpoint' },
41
+ mobileScrollHeight: { type: Number, attribute: 'mobile-scroll-height' },
42
+ _progress: { type: Number, state: true },
43
+ _isMobile: { type: Boolean, state: true },
44
+ _reducedMotion: { type: Boolean, state: true },
45
+ _mobileTriggered: { type: Boolean, state: true },
46
+ };
47
+
48
+ static styles = css`
49
+ :host {
50
+ display: block;
51
+ position: relative;
52
+ --_accent: var(--hero-accent-color, #bfa15f);
53
+ --_text: var(--hero-text-color, #f0f0f0);
54
+ --_grad-start: var(--hero-bg-gradient-start, #d4af37);
55
+ --_grad-end: var(--hero-bg-gradient-end, #f4e4b0);
56
+ }
57
+
58
+ .scroller {
59
+ position: relative;
60
+ }
61
+
62
+ .canvas {
63
+ position: sticky;
64
+ top: 0;
65
+ height: 100vh;
66
+ overflow: hidden;
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ background-color: #1a1618;
71
+ background-size: cover;
72
+ background-position: center;
73
+ background-repeat: no-repeat;
74
+ }
75
+
76
+ .overlay {
77
+ position: absolute;
78
+ inset: 0;
79
+ background: rgba(0, 0, 0, var(--_overlay-opacity, 0.5));
80
+ z-index: 1;
81
+ will-change: opacity;
82
+ }
83
+
84
+ .content-wrapper {
85
+ position: relative;
86
+ z-index: 2;
87
+ width: 100%;
88
+ height: 100%;
89
+ display: flex;
90
+ flex-direction: column;
91
+ align-items: center;
92
+ justify-content: center;
93
+ text-align: center;
94
+ will-change: transform, opacity;
95
+ }
96
+
97
+ ::slotted([slot='content']) {
98
+ position: relative;
99
+ z-index: 2;
100
+ }
101
+
102
+ .bg-text {
103
+ position: absolute;
104
+ bottom: -20%;
105
+ left: 50%;
106
+ transform: translate(-50%, 0);
107
+ font-size: clamp(4rem, 15vw, 12rem);
108
+ font-weight: 700;
109
+ background: linear-gradient(
110
+ 135deg,
111
+ var(--_grad-start) 0%,
112
+ var(--_grad-end) 50%,
113
+ var(--_grad-start) 100%
114
+ );
115
+ -webkit-background-clip: text;
116
+ -webkit-text-fill-color: transparent;
117
+ background-clip: text;
118
+ opacity: 0.4;
119
+ z-index: 0;
120
+ white-space: nowrap;
121
+ pointer-events: none;
122
+ will-change: transform, opacity;
123
+ }
124
+
125
+ .center-img {
126
+ position: absolute;
127
+ bottom: -35%;
128
+ left: 50%;
129
+ transform: translateX(-50%) translateZ(0);
130
+ width: min(60vw, 900px);
131
+ max-width: 90vw;
132
+ z-index: 3;
133
+ will-change: transform;
134
+ pointer-events: none;
135
+ }
136
+
137
+ .center-img img {
138
+ width: 100%;
139
+ height: auto;
140
+ filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.3));
141
+ }
142
+
143
+ .side-img {
144
+ position: absolute;
145
+ top: 50%;
146
+ z-index: 2;
147
+ width: min(40vw, 700px);
148
+ max-width: 45vw;
149
+ will-change: transform, opacity;
150
+ pointer-events: none;
151
+ }
152
+
153
+ .side-img img {
154
+ width: 100%;
155
+ height: auto;
156
+ filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.4));
157
+ }
158
+
159
+ .side-img--left {
160
+ left: 5%;
161
+ transform: translateY(-50%) translateZ(0);
162
+ }
163
+
164
+ .side-img--right {
165
+ right: 5%;
166
+ transform: translateY(-50%) translateZ(0);
167
+ }
168
+
169
+ /* Hide slotted images used as sources */
170
+ ::slotted([slot='background']),
171
+ ::slotted([slot='center']),
172
+ ::slotted([slot='left']),
173
+ ::slotted([slot='right']) {
174
+ display: none !important;
175
+ }
176
+
177
+ /* Mobile keyframe animations */
178
+ @keyframes fadeOutContent {
179
+ from {
180
+ opacity: 1;
181
+ transform: translateY(0);
182
+ }
183
+ to {
184
+ opacity: 0;
185
+ transform: translateY(-60px);
186
+ }
187
+ }
188
+
189
+ @keyframes slideUpCenter {
190
+ from {
191
+ transform: translateX(-50%) translateY(0) scale(1);
192
+ }
193
+ to {
194
+ transform: translateX(-50%) translateY(-55vh) scale(0.75);
195
+ }
196
+ }
197
+
198
+ @keyframes slideInLeft {
199
+ from {
200
+ opacity: 0;
201
+ transform: translateY(-50%) translateX(-200px) scale(0.7);
202
+ }
203
+ to {
204
+ opacity: 1;
205
+ transform: translateY(-50%) translateX(0) scale(1);
206
+ }
207
+ }
208
+
209
+ @keyframes slideInRight {
210
+ from {
211
+ opacity: 0;
212
+ transform: translateY(-50%) translateX(200px) scale(0.7);
213
+ }
214
+ to {
215
+ opacity: 1;
216
+ transform: translateY(-50%) translateX(0) scale(1);
217
+ }
218
+ }
219
+
220
+ @keyframes fadeBgText {
221
+ from {
222
+ opacity: 0;
223
+ transform: translate(-50%, 0) translateY(0) scale(1.2);
224
+ }
225
+ to {
226
+ opacity: 0.4;
227
+ transform: translate(-50%, 0) translateY(-55vh) scale(1);
228
+ }
229
+ }
230
+
231
+ :host([mobile-triggered]) .content-wrapper {
232
+ animation: fadeOutContent 0.6s ease-out forwards;
233
+ }
234
+
235
+ :host([mobile-triggered]) .overlay {
236
+ animation: fadeOutContent 0.6s ease-out forwards;
237
+ }
238
+
239
+ :host([mobile-triggered]) .center-img {
240
+ animation: slideUpCenter 0.8s cubic-bezier(0.33, 1, 0.68, 1) 0.3s forwards;
241
+ }
242
+
243
+ :host([mobile-triggered]) .side-img--left {
244
+ animation: slideInLeft 0.8s cubic-bezier(0.33, 1, 0.68, 1) 0.35s forwards;
245
+ }
246
+
247
+ :host([mobile-triggered]) .side-img--right {
248
+ animation: slideInRight 0.8s cubic-bezier(0.33, 1, 0.68, 1) 0.35s forwards;
249
+ }
250
+
251
+ :host([mobile-triggered]) .bg-text {
252
+ animation: fadeBgText 0.8s ease-out 0.35s forwards;
253
+ }
254
+
255
+ /* Initial hidden state for mobile side images */
256
+ :host([data-mobile]) .side-img {
257
+ opacity: 0;
258
+ }
259
+
260
+ /* Reduced motion */
261
+ @media (prefers-reduced-motion: reduce) {
262
+ :host {
263
+ /* Show static layout */
264
+ }
265
+
266
+ .scroller {
267
+ height: 100vh !important;
268
+ }
269
+
270
+ .canvas {
271
+ position: relative;
272
+ }
273
+
274
+ .content-wrapper {
275
+ opacity: 1 !important;
276
+ transform: none !important;
277
+ }
278
+
279
+ .overlay {
280
+ opacity: 1 !important;
281
+ }
282
+
283
+ .center-img,
284
+ .side-img,
285
+ .bg-text {
286
+ display: none;
287
+ }
288
+
289
+ :host([mobile-triggered]) .content-wrapper,
290
+ :host([mobile-triggered]) .overlay,
291
+ :host([mobile-triggered]) .center-img,
292
+ :host([mobile-triggered]) .side-img--left,
293
+ :host([mobile-triggered]) .side-img--right,
294
+ :host([mobile-triggered]) .bg-text {
295
+ animation: none !important;
296
+ }
297
+ }
298
+
299
+ /* Mobile responsive */
300
+ @media (max-width: 768px) {
301
+ .center-img {
302
+ width: 55vw;
303
+ max-width: 85vw;
304
+ bottom: -25%;
305
+ }
306
+
307
+ .side-img {
308
+ width: 28vw;
309
+ max-width: 35vw;
310
+ top: 60%;
311
+ }
312
+
313
+ .side-img--left {
314
+ left: 2%;
315
+ }
316
+
317
+ .side-img--right {
318
+ right: 2%;
319
+ }
320
+
321
+ .bg-text {
322
+ font-size: min(10vw, 5rem);
323
+ }
324
+ }
325
+ `;
326
+
327
+ constructor() {
328
+ super();
329
+ this.backgroundText = '';
330
+ this.scrollHeight = 450;
331
+ this.overlayOpacity = 0.5;
332
+ this.scrub = 1;
333
+ this.mobileBreakpoint = 768;
334
+ this.mobileScrollHeight = 220;
335
+ this._progress = 0;
336
+ this._isMobile = false;
337
+ this._reducedMotion = false;
338
+ this._mobileTriggered = false;
339
+
340
+ this._currentProgress = 0;
341
+ this._targetProgress = 0;
342
+ this._rafId = null;
343
+ this._scrollHandler = null;
344
+ this._resizeHandler = null;
345
+ this._observer = null;
346
+
347
+ this._bgSrc = '';
348
+ this._centerSrc = '';
349
+ this._leftSrc = '';
350
+ this._rightSrc = '';
351
+ }
352
+
353
+ connectedCallback() {
354
+ super.connectedCallback();
355
+ this._reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
356
+ this._checkMobile();
357
+
358
+ this._resizeHandler = this._onResize.bind(this);
359
+ window.addEventListener('resize', this._resizeHandler, { passive: true });
360
+ }
361
+
362
+ disconnectedCallback() {
363
+ super.disconnectedCallback();
364
+ this._cleanup();
365
+ if (this._resizeHandler) {
366
+ window.removeEventListener('resize', this._resizeHandler);
367
+ }
368
+ }
369
+
370
+ firstUpdated() {
371
+ this._extractSlotSources();
372
+ this._setupAnimation();
373
+ }
374
+
375
+ _getImgSrc(slotEl) {
376
+ if (!slotEl) return '';
377
+ if (slotEl.tagName === 'PICTURE') {
378
+ const webpSource = slotEl.querySelector('source[type="image/webp"]');
379
+ if (webpSource) return webpSource.getAttribute('srcset') || '';
380
+ const img = slotEl.querySelector('img');
381
+ return img ? img.getAttribute('src') || '' : '';
382
+ }
383
+ return slotEl.getAttribute('src') || '';
384
+ }
385
+
386
+ _extractSlotSources() {
387
+ const bgSlot = this.querySelector('[slot="background"]');
388
+ const centerSlot = this.querySelector('[slot="center"]');
389
+ const leftSlot = this.querySelector('[slot="left"]');
390
+ const rightSlot = this.querySelector('[slot="right"]');
391
+
392
+ this._bgSrc = this._getImgSrc(bgSlot);
393
+ this._centerSrc = this._getImgSrc(centerSlot);
394
+ this._leftSrc = this._getImgSrc(leftSlot);
395
+ this._rightSrc = this._getImgSrc(rightSlot);
396
+
397
+ this.requestUpdate();
398
+ }
399
+
400
+ _checkMobile() {
401
+ const wasMobile = this._isMobile;
402
+ this._isMobile = window.innerWidth <= this.mobileBreakpoint;
403
+
404
+ if (this._isMobile) {
405
+ this.setAttribute('data-mobile', '');
406
+ } else {
407
+ this.removeAttribute('data-mobile');
408
+ }
409
+
410
+ if (wasMobile !== this._isMobile && this.hasUpdated) {
411
+ this._cleanup();
412
+ this._mobileTriggered = false;
413
+ this.removeAttribute('mobile-triggered');
414
+ this._setupAnimation();
415
+ }
416
+ }
417
+
418
+ _onResize() {
419
+ this._checkMobile();
420
+ }
421
+
422
+ _cleanup() {
423
+ if (this._rafId) {
424
+ cancelAnimationFrame(this._rafId);
425
+ this._rafId = null;
426
+ }
427
+ if (this._scrollHandler) {
428
+ window.removeEventListener('scroll', this._scrollHandler);
429
+ this._scrollHandler = null;
430
+ }
431
+ if (this._observer) {
432
+ this._observer.disconnect();
433
+ this._observer = null;
434
+ }
435
+ }
436
+
437
+ _setupAnimation() {
438
+ if (this._reducedMotion) return;
439
+
440
+ if (this._isMobile) {
441
+ this._setupMobileAnimation();
442
+ } else {
443
+ this._setupDesktopAnimation();
444
+ }
445
+ }
446
+
447
+ _setupDesktopAnimation() {
448
+ this._scrollHandler = this._onScroll.bind(this);
449
+ window.addEventListener('scroll', this._scrollHandler, { passive: true });
450
+ this._startRafLoop();
451
+ }
452
+
453
+ _setupMobileAnimation() {
454
+ const scroller = this.shadowRoot.querySelector('.scroller');
455
+ if (!scroller) return;
456
+
457
+ const scrollerHeight = scroller.offsetHeight;
458
+ const triggerPoint = scrollerHeight * 0.15;
459
+
460
+ this._scrollHandler = () => {
461
+ if (this._mobileTriggered) return;
462
+ const rect = scroller.getBoundingClientRect();
463
+ const scrolled = -rect.top;
464
+
465
+ if (scrolled > triggerPoint) {
466
+ this._mobileTriggered = true;
467
+ this.setAttribute('mobile-triggered', '');
468
+ this._handleMobileA11y();
469
+ window.removeEventListener('scroll', this._scrollHandler);
470
+ this._scrollHandler = null;
471
+ }
472
+ };
473
+
474
+ window.addEventListener('scroll', this._scrollHandler, { passive: true });
475
+ }
476
+
477
+ _handleMobileA11y() {
478
+ setTimeout(() => {
479
+ this._setContentA11y(true);
480
+ }, 600);
481
+ }
482
+
483
+ _onScroll() {
484
+ const scroller = this.shadowRoot.querySelector('.scroller');
485
+ if (!scroller) return;
486
+
487
+ const rect = scroller.getBoundingClientRect();
488
+ const scrollableHeight = scroller.offsetHeight - window.innerHeight;
489
+
490
+ if (scrollableHeight <= 0) {
491
+ this._targetProgress = 0;
492
+ return;
493
+ }
494
+
495
+ const scrolled = -rect.top;
496
+ this._targetProgress = Math.max(0, Math.min(1, scrolled / scrollableHeight));
497
+ }
498
+
499
+ _startRafLoop() {
500
+ const loop = () => {
501
+ const lerpFactor = 1 / (1 + this.scrub * 10);
502
+ this._currentProgress += (this._targetProgress - this._currentProgress) * lerpFactor;
503
+
504
+ if (Math.abs(this._currentProgress - this._targetProgress) > 0.0001) {
505
+ this._applyDesktopTransforms(this._currentProgress);
506
+ } else {
507
+ this._currentProgress = this._targetProgress;
508
+ this._applyDesktopTransforms(this._currentProgress);
509
+ }
510
+
511
+ this._rafId = requestAnimationFrame(loop);
512
+ };
513
+ this._rafId = requestAnimationFrame(loop);
514
+ }
515
+
516
+ _easeOutCubic(t) {
517
+ return 1 - Math.pow(1 - t, 3);
518
+ }
519
+
520
+ _getSubProgress(progress, start, end) {
521
+ if (progress <= start) return 0;
522
+ if (progress >= end) return 1;
523
+ return (progress - start) / (end - start);
524
+ }
525
+
526
+ _applyDesktopTransforms(progress) {
527
+ const contentWrapper = this.shadowRoot.querySelector('.content-wrapper');
528
+ const overlay = this.shadowRoot.querySelector('.overlay');
529
+ const centerImg = this.shadowRoot.querySelector('.center-img');
530
+ const bgText = this.shadowRoot.querySelector('.bg-text');
531
+ const leftImg = this.shadowRoot.querySelector('.side-img--left');
532
+ const rightImg = this.shadowRoot.querySelector('.side-img--right');
533
+
534
+ // Phase 1: 0.0 -> 0.25 - Content fade out
535
+ const phase1 = this._easeOutCubic(this._getSubProgress(progress, 0, 0.25));
536
+ if (contentWrapper) {
537
+ contentWrapper.style.opacity = 1 - phase1;
538
+ contentWrapper.style.transform = `translateY(${-60 * phase1}px)`;
539
+ }
540
+ if (overlay) {
541
+ overlay.style.opacity = 1 - phase1;
542
+ }
543
+
544
+ // A11y: hide content from tab when scrolled past
545
+ this._setContentA11y(phase1 > 0.9);
546
+
547
+ // Phase 2: 0.3 -> 0.85 - All 3 images move together
548
+ const phase2 = this._easeOutCubic(this._getSubProgress(progress, 0.3, 0.85));
549
+
550
+ // Center image rises
551
+ if (centerImg) {
552
+ const translateY = -65 * phase2; // vh
553
+ const scale = 1 - 0.3 * phase2; // 1 -> 0.7
554
+ centerImg.style.transform = `translateX(-50%) translateY(${translateY}vh) scale(${scale})`;
555
+ }
556
+
557
+ // Bg text follows center image (same vertical movement)
558
+ if (bgText) {
559
+ const fontSize = 12 - 5 * phase2; // 12rem -> 7rem
560
+ const translateY = -65 * phase2; // same as center image
561
+ bgText.style.fontSize = `clamp(${4 - 1 * phase2}rem, ${15 - 7 * phase2}vw, ${fontSize}rem)`;
562
+ bgText.style.transform = `translate(-50%, 0) translateY(${translateY}vh)`;
563
+ }
564
+
565
+ // Side images appear together with center (slight delay: 0.35 -> 0.9)
566
+ const phaseSides = this._easeOutCubic(this._getSubProgress(progress, 0.35, 0.9));
567
+ if (leftImg) {
568
+ const x = -200 * (1 - phaseSides);
569
+ const scale = 0.7 + 0.3 * phaseSides;
570
+ leftImg.style.opacity = phaseSides;
571
+ leftImg.style.transform = `translateY(-50%) translateX(${x}px) scale(${scale})`;
572
+ }
573
+ if (rightImg) {
574
+ const x = 200 * (1 - phaseSides);
575
+ const scale = 0.7 + 0.3 * phaseSides;
576
+ rightImg.style.opacity = phaseSides;
577
+ rightImg.style.transform = `translateY(-50%) translateX(${x}px) scale(${scale})`;
578
+ }
579
+ }
580
+
581
+ _setContentA11y(hidden) {
582
+ const contentSlot = this.querySelector('[slot="content"]');
583
+ if (!contentSlot) return;
584
+
585
+ const focusableElements = contentSlot.querySelectorAll('a, button, input, [tabindex]');
586
+ if (hidden) {
587
+ contentSlot.setAttribute('aria-hidden', 'true');
588
+ focusableElements.forEach((el) => el.setAttribute('tabindex', '-1'));
589
+ } else {
590
+ contentSlot.removeAttribute('aria-hidden');
591
+ focusableElements.forEach((el) => el.removeAttribute('tabindex'));
592
+ }
593
+ }
594
+
595
+ _getScrollerHeight() {
596
+ if (this._reducedMotion) return '100vh';
597
+ const h = this._isMobile ? this.mobileScrollHeight : this.scrollHeight;
598
+ return `${h}vh`;
599
+ }
600
+
601
+ render() {
602
+ return html`
603
+ <div
604
+ class="scroller"
605
+ style="height: ${this._getScrollerHeight()}"
606
+ role="region"
607
+ aria-label="Hero animado con scroll"
608
+ >
609
+ <div
610
+ class="canvas"
611
+ style="
612
+ background-image: ${this._bgSrc ? `url('${this._bgSrc}')` : 'none'};
613
+ --_overlay-opacity: ${this.overlayOpacity};
614
+ "
615
+ >
616
+ <div class="overlay"></div>
617
+
618
+ <div class="content-wrapper">
619
+ <slot name="content"></slot>
620
+ </div>
621
+
622
+ ${this.backgroundText ? html`<div class="bg-text">${this.backgroundText}</div>` : ''}
623
+ ${this._centerSrc
624
+ ? html`
625
+ <div class="center-img">
626
+ <img src="${this._centerSrc}" alt="" loading="eager" width="900" height="506" />
627
+ </div>
628
+ `
629
+ : ''}
630
+ ${this._leftSrc
631
+ ? html`
632
+ <div class="side-img side-img--left">
633
+ <img
634
+ src="${this._leftSrc}"
635
+ alt=""
636
+ loading="lazy"
637
+ decoding="async"
638
+ width="960"
639
+ height="540"
640
+ />
641
+ </div>
642
+ `
643
+ : ''}
644
+ ${this._rightSrc
645
+ ? html`
646
+ <div class="side-img side-img--right">
647
+ <img
648
+ src="${this._rightSrc}"
649
+ alt=""
650
+ loading="lazy"
651
+ decoding="async"
652
+ width="960"
653
+ height="540"
654
+ />
655
+ </div>
656
+ `
657
+ : ''}
658
+ </div>
659
+ </div>
660
+
661
+ <!-- Hidden slots for source images -->
662
+ <slot name="background"></slot>
663
+ <slot name="center"></slot>
664
+ <slot name="left"></slot>
665
+ <slot name="right"></slot>
666
+ `;
667
+ }
668
+ }
669
+
670
+ customElements.define('hero-scroll-animation', HeroScrollAnimation);