Last active
June 4, 2026 01:40
-
-
Save jaythanelam/0ca774026ddfbcc9c2c8d3bb55c86337 to your computer and use it in GitHub Desktop.
teleskope-v3 scroll-video-expand interaction (hosted for Webflow custom code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================ | |
| // Scroll Video Expand — teleskope-ai | |
| // A native <video> that expands to full-bleed and plays as it | |
| // scrolls into focus, dwells (hover to reveal controls + unmute), | |
| // then clip-shrinks back into its contained bento card. Built on | |
| // GSAP ScrollTrigger using a FLIP-style transform (scale/translate) | |
| // so the geometry animates on the compositor — no width/height thrash. | |
| // | |
| // Production artifact: hosted on a CDN and registered as a Webflow | |
| // hosted script (footer). Portable — no-ops on any page that lacks | |
| // the target element, and only loads GSAP when that element exists. | |
| // | |
| // Target: teleskope-v3 /home---fullwidth → .section-dark .video-wrap | |
| // ============================================================ | |
| (function () { | |
| // ── Config ──────────────────────────────────────────────── | |
| var CONFIG = { | |
| // Transcoded MP4 the interaction plays (swap freely). | |
| // Renditions: _576p (17MB) · _720p (26MB) · _1080p (124MB) · source (232MB). | |
| videoSrc: "https://r2.vidzflow.com/v/w8F8qVqxbg_720p_1779046231.mp4", | |
| poster: "https://r2.vidzflow.com/thumbnails/w8F8qVqxbg_1779997642.jpg", | |
| wrapSelector: ".video-wrap", // element that expands/shrinks | |
| sectionSelector: ".section-dark", // scroll trigger context (falls back to wrap) | |
| aspect: 529 / 940, // video height / width (matches the source ~16:9) | |
| radius: 16, // contained-card corner radius (px); animates to 0 at full-bleed | |
| zIndex: 50, // stacking while expanded | |
| // Scroll choreography (timeline units; scrub maps them to scroll distance). | |
| expandDur: 1, // expand and shrink phases | |
| dwellDur: 0.6, // hold at full-bleed (time to hover for audio) | |
| scrollLength: 2.2, // pin distance as a multiple of viewport height | |
| ease: "power2.inOut", | |
| loop: true, | |
| // GSAP is loaded from CDN only when the target element is present, | |
| // and only if the page hasn't already loaded it. | |
| gsapUrl: "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js", | |
| stUrl: "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js", | |
| }; | |
| // ── Build the native video into the wrapper ──────────────── | |
| function buildVideo(wrap) { | |
| // Replace whatever is there (e.g. the Embedly iframe) with a clean stage. | |
| wrap.innerHTML = ""; | |
| var stage = document.createElement("div"); | |
| stage.className = "sve-stage"; | |
| stage.style.cssText = [ | |
| "position:relative", | |
| "width:100%", | |
| "padding-top:" + (CONFIG.aspect * 100) + "%", | |
| "border-radius:" + CONFIG.radius + "px", | |
| "overflow:hidden", | |
| "transform-origin:center center", | |
| "will-change:transform", | |
| "z-index:" + CONFIG.zIndex, | |
| "background:#000", | |
| ].join(";"); | |
| var video = document.createElement("video"); | |
| video.className = "sve-video"; | |
| video.muted = true; // required for autoplay; user unmutes via controls | |
| video.loop = CONFIG.loop; | |
| video.playsInline = true; | |
| video.setAttribute("playsinline", ""); | |
| // Metadata only — the full clip never downloads until the section is in view. | |
| video.preload = "metadata"; | |
| if (CONFIG.poster) video.poster = CONFIG.poster; | |
| video.style.cssText = [ | |
| "position:absolute", | |
| "inset:0", | |
| "width:100%", | |
| "height:100%", | |
| "object-fit:cover", | |
| "display:block", | |
| ].join(";"); | |
| var source = document.createElement("source"); | |
| source.src = CONFIG.videoSrc; | |
| source.type = "video/mp4"; | |
| video.appendChild(source); | |
| stage.appendChild(video); | |
| wrap.appendChild(stage); | |
| // Playback is gated to the viewport so the clip never competes with | |
| // the page's initial load. Play when near view; pause when far away. | |
| // Observe the SECTION, not the wrap: ScrollTrigger pins the wrap | |
| // (position:fixed), which throws its rect off-screen during the | |
| // full-bleed dwell — observing it there would wrongly pause the video. | |
| // The section stays in normal flow and spans the whole pinned region. | |
| var gateTarget = document.querySelector(CONFIG.sectionSelector) || wrap; | |
| gatePlayback(gateTarget, video); | |
| return { stage: stage, video: video }; | |
| } | |
| // Lazy playback: only stream/play while the section is near the viewport. | |
| function gatePlayback(target, video) { | |
| function play() { | |
| var p = video.play(); | |
| if (p && typeof p.catch === "function") p.catch(function () {}); | |
| } | |
| if (!("IntersectionObserver" in window)) { | |
| play(); | |
| return; | |
| } | |
| var io = new IntersectionObserver( | |
| function (entries) { | |
| for (var i = 0; i < entries.length; i++) { | |
| if (entries[i].isIntersecting) { | |
| play(); | |
| } else if (!video.paused) { | |
| video.pause(); | |
| } | |
| } | |
| }, | |
| { rootMargin: "200px 0px" } // start buffering just before it scrolls in | |
| ); | |
| io.observe(target); | |
| } | |
| // ── Interaction (requires GSAP + ScrollTrigger) ───────────── | |
| function run(built, wrap) { | |
| var stage = built.stage; | |
| var video = built.video; | |
| // The .video-wrap has overflow:hidden and is narrower than the viewport | |
| // (it sits inside a width-constraining column). When the stage scales to | |
| // full-bleed it genuinely reaches 100vw, but the wrap clips it back to the | |
| // column width — so the video never appears edge-to-edge. Let the scaled | |
| // stage spill past the wrap. The contained-card rounding lives on the stage | |
| // itself (its own border-radius + overflow), so nothing is lost here. | |
| wrap.style.overflow = "visible"; | |
| gsap.registerPlugin(ScrollTrigger); | |
| // Full-bleed transform, recomputed on refresh/resize. The stage scales to | |
| // span the viewport and slides to center it in BOTH axes. FB.x centers | |
| // horizontally; FB.y centers vertically (see recenterY below). | |
| var FB = { scale: 1, x: 0, y: 0 }; | |
| var stageDocCenterY = 0; // stage center in document coords (transform-immune) | |
| // Document-coordinate top via the offsetTop chain. Unlike | |
| // getBoundingClientRect, offsetTop is NOT affected by ancestor transforms, | |
| // so the scroll-driven IX2 transform on .grid.bento can't corrupt it. | |
| function docTop(el) { | |
| var t = 0; | |
| while (el) { t += el.offsetTop; el = el.offsetParent; } | |
| return t; | |
| } | |
| function recalc() { | |
| var prev = stage.style.transform; | |
| stage.style.transform = "none"; | |
| var r = stage.getBoundingClientRect(); | |
| stage.style.transform = prev; | |
| FB.scale = window.innerWidth / r.width; | |
| FB.x = window.innerWidth / 2 - (r.left + r.width / 2); | |
| stageDocCenterY = docTop(stage) + stage.offsetHeight / 2; | |
| } | |
| recalc(); | |
| // Vertical re-centering. The transform on the .grid.bento ancestor (Webflow | |
| // IX2) corrupts ScrollTrigger's start-position math, so the pin engages a | |
| // couple hundred px "late" and the full-bleed video lands above viewport | |
| // center (letterbox gap below it). The pin itself holds the element at | |
| // (stageDocCenterY - self.start); we cancel the difference from the true | |
| // viewport center. Computed in onRefresh because self.start is only known | |
| // after ScrollTrigger calculates positions. | |
| function recenterY(self) { | |
| var pinnedCenterY = stageDocCenterY - self.start; | |
| FB.y = window.innerHeight / 2 - pinnedCenterY; | |
| tl.invalidate(); | |
| } | |
| var total = CONFIG.expandDur * 2 + CONFIG.dwellDur; | |
| var enterP = CONFIG.expandDur / total; // progress where full-bleed begins | |
| var exitP = (CONFIG.expandDur + CONFIG.dwellDur) / total; // where it starts shrinking | |
| var isFull = false; | |
| var isHover = false; | |
| function syncState() { | |
| var active = isFull || isHover; | |
| video.controls = active; | |
| // Contained + un-hovered = clean silent loop ("pressed into the page"). | |
| if (!active) video.muted = true; | |
| } | |
| wrap.addEventListener("mouseenter", function () { isHover = true; syncState(); }); | |
| wrap.addEventListener("mouseleave", function () { isHover = false; syncState(); }); | |
| var tl = gsap.timeline({ | |
| defaults: { ease: CONFIG.ease }, | |
| scrollTrigger: { | |
| // Trigger on the wrap itself (not the oversized .section-dark): the pin | |
| // must engage when the VIDEO is centered. The section can be many | |
| // viewports tall, so "section center" would lock the pin long after the | |
| // video has scrolled off-screen, leaving an empty pin-spacer void. | |
| trigger: wrap, | |
| start: "center center", | |
| end: "+=" + Math.round(window.innerHeight * CONFIG.scrollLength), | |
| scrub: true, | |
| pin: wrap, | |
| pinSpacing: true, | |
| // Webflow Interactions (IX2) put a transform on the .grid.bento | |
| // ancestor (even an identity matrix), which becomes the containing | |
| // block for position:fixed and breaks the pin (the video scrolls | |
| // away, leaving an empty pin-spacer void). pinType "transform" holds | |
| // the pin with a transform relative to its in-flow position instead, | |
| // which is immune to transformed ancestors. | |
| pinType: "transform", | |
| anticipatePin: 1, | |
| invalidateOnRefresh: true, | |
| onRefreshInit: recalc, | |
| onRefresh: recenterY, | |
| onUpdate: function (self) { | |
| var p = self.progress; | |
| var nowFull = p >= enterP - 0.02 && p <= exitP + 0.02; | |
| if (nowFull !== isFull) { isFull = nowFull; syncState(); } | |
| }, | |
| }, | |
| }); | |
| // Expand → dwell → shrink (radius rides along with the geometry). | |
| tl.fromTo( | |
| stage, | |
| { scale: 1, x: 0, y: 0, borderRadius: CONFIG.radius }, | |
| { scale: function () { return FB.scale; }, x: function () { return FB.x; }, y: function () { return FB.y; }, borderRadius: 0, duration: CONFIG.expandDur } | |
| ) | |
| .to(stage, { duration: CONFIG.dwellDur }) | |
| .to(stage, { scale: 1, x: 0, y: 0, borderRadius: CONFIG.radius, duration: CONFIG.expandDur }); | |
| syncState(); | |
| // Content ABOVE the video keeps shifting after ScrollTrigger's first | |
| // measurement — lazy-loaded images (the site's performance_lazy_load | |
| // script), late web fonts, and the fixed nav's reveal interaction all move | |
| // the pin's start offset. If we don't re-measure, the pin engages at a stale | |
| // scroll position: the full-bleed video lands off-center and scrubbing | |
| // desyncs from scroll (it feels "stuck"). Refresh once everything settles so | |
| // recalc()/recenterY() re-run with correct positions. (GSAP loads async, so | |
| // 'load' may have already fired — call directly if the doc is complete.) | |
| function settleRefresh() { ScrollTrigger.refresh(); } | |
| if (document.readyState === "complete") settleRefresh(); | |
| else window.addEventListener("load", settleRefresh); | |
| if (document.fonts && document.fonts.ready) document.fonts.ready.then(settleRefresh); | |
| setTimeout(settleRefresh, 600); // catch-all for late layout shifts | |
| } | |
| // ── Boot: element-first, so GSAP only loads where it's needed ── | |
| function boot() { | |
| var wrap = document.querySelector(CONFIG.wrapSelector); | |
| if (!wrap) return; // no target on this page — load nothing, cost nothing. | |
| var built = buildVideo(wrap); | |
| var reduceMotion = | |
| window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| if (reduceMotion) { | |
| // Clean, controllable contained card — no scroll animation, no GSAP. | |
| built.video.controls = true; | |
| return; | |
| } | |
| ensureGsap(function () { | |
| if (typeof gsap === "undefined" || typeof ScrollTrigger === "undefined") { | |
| console.warn("[teleskope] scroll-video-expand: GSAP/ScrollTrigger missing — static fallback."); | |
| built.video.controls = true; | |
| return; | |
| } | |
| run(built, wrap); | |
| }); | |
| } | |
| // ── Ensure GSAP + ScrollTrigger, then continue ────────────── | |
| function loadScript(src, cb) { | |
| var s = document.createElement("script"); | |
| s.src = src; | |
| s.onload = cb; | |
| s.onerror = function () { console.warn("[teleskope] failed to load " + src); cb(); }; | |
| document.head.appendChild(s); | |
| } | |
| function ensureGsap(cb) { | |
| if (typeof gsap !== "undefined" && typeof ScrollTrigger !== "undefined") return cb(); | |
| if (typeof gsap === "undefined") { | |
| loadScript(CONFIG.gsapUrl, function () { loadScript(CONFIG.stUrl, cb); }); | |
| } else { | |
| loadScript(CONFIG.stUrl, cb); | |
| } | |
| } | |
| // Run after DOM is parsed | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", boot); | |
| } else { | |
| boot(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment