Skip to content

Instantly share code, notes, and snippets.

@jaythanelam
Last active June 4, 2026 01:40
Show Gist options
  • Select an option

  • Save jaythanelam/0ca774026ddfbcc9c2c8d3bb55c86337 to your computer and use it in GitHub Desktop.

Select an option

Save jaythanelam/0ca774026ddfbcc9c2c8d3bb55c86337 to your computer and use it in GitHub Desktop.
teleskope-v3 scroll-video-expand interaction (hosted for Webflow custom code)
// ============================================================
// 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