1Router.register('sharkninja', { 2 mount(el) { 3 window.setPageTitle?.('SharkNinja'); 4 el.classList.add('is-project'); 5 window.setTerminalBack?.('#portfolio'); 6 window.setProjectAccent?.('#e8362b'); 7 this._el = el; 8 9 // 4 columns à 2 screens. Cols 1&3 start lower to match Figma stagger. 10 const colConfigs = [ 11 { stagger: 120, speed: -80, screens: ['screen_1', 'screen_2'] }, 12 { stagger: 0, speed: 65, screens: ['screen_3', 'screen_4'] }, 13 { stagger: 120, speed: -70, screens: ['screen_5', 'screen_6'] }, 14 { stagger: 0, speed: 90, screens: ['screen_7', 'screen_8'] }, 15 ]; 16 17 el.innerHTML = ` 18 <div class="proj-page"> 19 20 <div class="proj-scene"> 21 <div class="proj-hero-wrap"> 22 <video class="proj-hero" src="images/portfolio/sharkninja/hero.mp4" poster="images/portfolio/sharkninja/hero.webp" autoplay loop muted playsinline></video> 23 </div> 24 <div class="proj-header-overlay"> 25 <section class="proj-header"> 26 <div class="proj-header-left"> 27 <h1>SharkNinja.com</h1> 28 <div class="proj-meta"> 29 <p>SharkNinja, USA</p> 30 </div> 31 </div> 32 <div class="proj-desc"> 33 <ul class="proj-services"> 34 <li>Brand Design</li> 35 <li>Web Design</li> 36 <li>Ecommerce</li> 37 <li>Creative Strategy</li> 38 </ul> 39 <p><strong>Reimagined SharkNinja's</strong> digital presence by uniting the Shark and Ninja brands on one cohesive eCommerce experience, designed to match the innovation and excellence of their physical products.</p> 40 </div> 41 </section> 42 <span class="proj-copyright">©2025-26</span> 43 </div> 44 </div> 45 46 <div class="proj-body"> 47 <section class="proj-mobile-desc proj-reveal"> 48 <p><strong>Reimagined SharkNinja's</strong> digital presence by uniting the Shark and Ninja brands on one cohesive eCommerce experience, designed to match the innovation and excellence of their physical products.</p> 49 </section> 50 <section class="proj-quote proj-quote--sn proj-reveal"> 51 <p>SharkNinja makes some of the most talked-about products on the internet â the kind of kitchen and home gadgets that explode across TikTok feeds and rack up millions of views. The challenge was building a DTC experience worthy of that energy. We unified two iconic brands into a single global platform engineered for shareability, fandom, and buzz.</p> 52 </section> 53 <video class="proj-img proj-reveal" src="images/portfolio/sharkninja/beckham_home.mp4" autoplay loop muted playsinline></video> 54 <div class="sn-desktop-scroll proj-reveal"> 55 <div class="sn-desktop-sticky"> 56 <img class="sn-desktop-img" src="images/portfolio/sharkninja/desktop.webp" alt="SharkNinja homepage"> 57 </div> 58 </div> 59 60 <div class="sn-phones-track proj-reveal"> 61 <div class="sn-phones-scene"> 62 ${colConfigs.map(c => ` 63 <div class="sn-col" style="padding-top:${c.stagger}px" data-speed="${c.speed}"> 64 ${c.screens.map(s => ` 65 <div class="sn-phone"> 66 <img src="images/portfolio/sharkninja/${s}.webp" alt="SharkNinja mobile screen" loading="lazy"> 67 </div> 68 `).join('')} 69 </div> 70 `).join('')} 71 </div> 72 </div> 73 74 75 <section class="proj-quote proj-quote--sn proj-reveal"> 76 <p>We designed social, snackable, and watchable product experiences, DTC-exclusive moments like Build Your Own Bundles and a flexible design system that scales across countless products and markets. The result: a digital experience that doesn't just sell â it sells itself, turning customers into fans and advocates. Launched in under nine months, and engineered for what's next.</p> 77 </section> 78 <img class="proj-img proj-reveal" src="images/portfolio/sharkninja/slushi.webp" width="1920" height="2011" alt="SharkNinja Slushi experience"> 79 <img class="proj-img proj-reveal" src="images/portfolio/sharkninja/pdp.webp" width="1920" height="1080" alt="SharkNinja product detail pages"> 80 81 <img class="proj-img proj-reveal" src="images/portfolio/sharkninja/glam_01.webp" width="1920" height="1080" alt="SharkNinja GLAM before and after"> 82 83 <img class="proj-img proj-reveal" src="images/portfolio/sharkninja/ds_grid.webp" width="3840" height="2160" alt="SharkNinja design system"> 84 85 <nav class="proj-pager proj-reveal"> 86 <a class="proj-pager-link" href="#android-xr"><svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="7,1 1,7 7,13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Android XR</a> 87 <a class="proj-pager-link proj-pager-next" href="#kpmg-lakehouse">KPMG Lakehouse <svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="1,1 7,7 1,13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a> 88 </nav> 89 </div> 90 91 </div> 92 `; 93 94 const progress = document.createElement('div'); 95 progress.id = 'proj-progress'; 96 document.body.appendChild(progress); 97 this._progress = progress; 98 99 const scene = el.querySelector('.sn-phones-scene'); 100 const cols = el.querySelectorAll('.sn-col'); 101 102 const tickParallax = () => { 103 if (!scene) return; 104 const rect = scene.getBoundingClientRect(); 105 const vh = el.clientHeight; 106 const centerY = rect.top + rect.height / 2; 107 const maxDist = vh / 2 + rect.height / 2; 108 const t = (centerY - vh / 2) / maxDist; 109 const clamped = Math.max(-1.2, Math.min(1.2, t));
110 cols.forEach(col => { 111 col.style.transform = `translateY(${clamped * +col.dataset.speed}px)`; 112 }); 113 }; 114 115 // Desktop scrolljack 116 const desktopScroll = el.querySelector('.sn-desktop-scroll'); 117 const desktopSticky = el.querySelector('.sn-desktop-sticky'); 118 const desktopImg = el.querySelector('.sn-desktop-img'); 119 120 let desktopRange = 0; 121 122 const initDesktop = () => { 123 if (window.innerWidth <= 768) { 124 desktopRange = 0; 125 if (desktopScroll) desktopScroll.style.height = ''; 126 if (desktopImg) desktopImg.style.transform = ''; 127 return; 128 } 129 if (!desktopScroll || !desktopImg || !desktopImg.naturalWidth) return; 130 // Use window dimensions rather than offsetHeight/offsetWidth â those can 131 // return 0 when called synchronously right after innerHTML is set. 132 const stickyH = window.innerHeight; // sticky is 100vh 133 const imgW = window.innerWidth * 0.75; // image is width:75% 134 const imgH = imgW * (desktopImg.naturalHeight / desktopImg.naturalWidth); 135 desktopRange = Math.max(0, imgH - stickyH); 136 desktopScroll.style.height = `${stickyH + desktopRange}px`; 137 }; 138 139 const tickDesktop = () => { 140 if (!desktopScroll || !desktopImg || !desktopRange) return; 141 const rect = desktopScroll.getBoundingClientRect(); 142 const offset = Math.max(0, Math.min(desktopRange, -rect.top)); 143 desktopImg.style.transform = `translateY(${-offset}px)`; 144 }; 145 146 if (desktopImg?.complete && desktopImg.naturalHeight > 0) { 147 initDesktop(); 148 } else { 149 desktopImg?.addEventListener('load', initDesktop); 150 } 151 this._onResize = initDesktop; 152 window.addEventListener('resize', this._onResize); 153 154 const isMobile = () => window.innerWidth <= 768; 155 156 let _scrollRafId = null; 157 this._onScroll = () => { 158 const pct = el.scrollTop / (el.scrollHeight - el.clientHeight); 159 progress.style.height = (Math.min(pct, 1) * 100) + '%'; 160 if (!isMobile() && !_scrollRafId) { 161 _scrollRafId = requestAnimationFrame(() => { 162 _scrollRafId = null; 163 tickParallax(); tickDesktop(); 164 }); 165 } 166 }; 167 this._cancelScrollRaf = () => { if (_scrollRafId) { cancelAnimationFrame(_scrollRafId); _scrollRafId = null; } }; 168 el.addEventListener('scroll', this._onScroll); 169 if (!isMobile()) { tickParallax(); } 170 171 const observer = new IntersectionObserver( 172 entries => entries.forEach(entry => { 173 if (entry.isIntersecting) { 174 entry.target.classList.add('is-revealed'); 175 observer.unobserve(entry.target); 176 } 177 }), 178 { threshold: 0, rootMargin: '0px 0px -80px 0px' } 179 ); 180 el.querySelectorAll('.proj-reveal').forEach(node => observer.observe(node)); 181 this._observer = observer; 182 }, 183 184 unmount() { 185 this._observer?.disconnect(); 186 this._observer = null; 187 if (this._progress) { this._progress.remove(); this._progress = null; } 188 this._cancelScrollRaf?.(); 189 this._cancelScrollRaf = null; 190 if (this._onScroll) { 191 this._el?.removeEventListener('scroll', this._onScroll); 192 this._onScroll = null; 193 } 194 if (this._onResize) { 195 window.removeEventListener('resize', this._onResize); 196 this._onResize = null; 197 } 198 this._el = null; 199 document.getElementById('panel')?.classList.remove('is-project'); 200 window.clearTerminalBack?.(); 201 window.clearProjectAccent?.(); 202 } 203});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.