/* ========================================================================== Kirschner Gebäudeservice — Motion Ein Modul. Alles Bewegte läuft in gsap.matchMedia(): bei reduced motion bleibt nur Funktion (Slider, Menü, Umschalter), keine Choreografie. Jede Animation hat einen Zweck — Liste in DESIGN.md. ========================================================================== */ (() => { const root = document.documentElement; root.classList.add('motion-ready'); const $ = (s, c = document) => c.querySelector(s); const $$ = (s, c = document) => [...c.querySelectorAll(s)]; let lenis = null; /* ---------- Fakten, die sich selbst aktualisieren ---------- */ const years = new Date().getFullYear() - 2003; $$('[data-years]').forEach((el) => { el.textContent = years; }); $$('[data-year]').forEach((el) => { el.textContent = new Date().getFullYear(); }); /* ---------- Header: fest nach 40px, weg beim Runterscrollen ---------- */ const header = $('.site-header'); const menuToggle = $('.menu-toggle'); let lastY = scrollY; const onScroll = () => { const y = scrollY; if (header) { header.classList.toggle('is-solid', y > 40); const menuOpen = menuToggle && menuToggle.getAttribute('aria-expanded') === 'true'; header.classList.toggle('is-hidden', !menuOpen && y > 480 && y > lastY + 2); if (y < lastY - 2) header.classList.remove('is-hidden'); } lastY = y; }; addEventListener('scroll', onScroll, { passive: true }); onScroll(); /* ---------- Mobiles Menü ---------- */ const mnav = $('#mobile-nav'); if (menuToggle && mnav) { mnav.inert = true; const setMenu = (open) => { menuToggle.setAttribute('aria-expanded', String(open)); menuToggle.setAttribute('aria-label', open ? 'Menü schließen' : 'Menü öffnen'); mnav.classList.toggle('is-open', open); mnav.inert = !open; document.body.style.overflow = open ? 'hidden' : ''; if (lenis) open ? lenis.stop() : lenis.start(); if (open && window.gsap && !matchMedia('(prefers-reduced-motion: reduce)').matches) { gsap.fromTo($$('.mobile-nav__link, .mobile-nav__meta', mnav), { yPercent: 60, opacity: 0 }, { yPercent: 0, opacity: 1, duration: .8, ease: 'expo.out', stagger: .05, delay: .18 }); } if (open) $('.mobile-nav__link', mnav)?.focus({ preventScroll: true }); }; menuToggle.addEventListener('click', () => setMenu(menuToggle.getAttribute('aria-expanded') !== 'true')); addEventListener('keydown', (e) => { if (e.key === 'Escape' && mnav.classList.contains('is-open')) { setMenu(false); menuToggle.focus(); } }); $$('a', mnav).forEach((a) => a.addEventListener('click', () => setMenu(false))); matchMedia('(min-width: 1081px)').addEventListener('change', (e) => { if (e.matches) setMenu(false); }); } /* ---------- Vorher/Nachher-Slider (Zeiger + Tastatur) ---------- */ $$('.compare').forEach((cmp) => { let pos = 50; const set = (v) => { pos = Math.max(0, Math.min(100, v)); cmp.style.setProperty('--pos', pos + '%'); cmp.setAttribute('aria-valuenow', Math.round(pos)); cmp.setAttribute('aria-valuetext', `${Math.round(pos)} % Vorher-Ansicht`); }; const fromEvent = (e) => { const r = cmp.getBoundingClientRect(); set(((e.clientX - r.left) / r.width) * 100); }; let dragging = false; cmp.addEventListener('pointerdown', (e) => { dragging = true; cmp.setPointerCapture(e.pointerId); fromEvent(e); }); cmp.addEventListener('pointermove', (e) => { if (dragging) fromEvent(e); }); cmp.addEventListener('pointerup', () => { dragging = false; }); cmp.addEventListener('pointercancel', () => { dragging = false; }); cmp.addEventListener('keydown', (e) => { const step = e.shiftKey ? 10 : 2; const map = { ArrowLeft: pos - step, ArrowDown: pos - step, ArrowRight: pos + step, ArrowUp: pos + step, Home: 0, End: 100, PageDown: pos - 10, PageUp: pos + 10 }; if (e.key in map) { e.preventDefault(); set(map[e.key]); } }); set(50); cmp._set = set; }); /* ---------- Notruf-Pille: nach dem Hero sichtbar, am Seitenende weg ---------- */ const sos = $('.sos'); if (sos) { const hero = $('.hero, .page-hero'); const footer = $('.site-footer'); let pastHero = false, atFooter = false; const update = () => sos.classList.toggle('is-visible', pastHero && !atFooter); if (hero) new IntersectionObserver(([e]) => { pastHero = !e.isIntersecting; update(); }, { rootMargin: '-20% 0px 0px 0px' }).observe(hero); if (footer) new IntersectionObserver(([e]) => { atFooter = e.isIntersecting; update(); }).observe(footer); } /* ====================================================================== Ab hier: Choreografie. Ohne GSAP bleibt alles sichtbar und benutzbar. ====================================================================== */ if (!window.gsap || !window.ScrollTrigger || !window.SplitText) { root.classList.remove('js', 'has-intro'); return; } gsap.registerPlugin(ScrollTrigger, SplitText); gsap.defaults({ ease: 'expo.out', duration: 1 }); const intro = $('.intro'); /* Intro: zwei Chevrons + Wortmarke, dann Winkel-Wipe (nur 1× pro Session) */ const playIntro = () => new Promise((resolve) => { if (!root.classList.contains('has-intro') || !intro) { resolve(); return; } try { sessionStorage.setItem('kk-intro', '1'); } catch (e) { /* privat */ } intro.style.animation = 'none'; gsap.set(intro, { clipPath: 'polygon(0% 0%, 120% 0%, 100% 50%, 120% 100%, 0% 100%)' }); const tl = gsap.timeline({ onComplete: () => { root.classList.remove('has-intro'); resolve(); }, }); tl.from('.intro__mark .chev', { xPercent: 140, opacity: 0, duration: .5, stagger: .09 }) .from('.intro__word span, .intro__word small', { yPercent: 115, duration: .5, stagger: .06 }, .15) .to(intro, { clipPath: 'polygon(0% 0%, 0% 0%, -20% 50%, 0% 100%, 0% 100%)', duration: .7, ease: 'expo.inOut' }, .75) .add(resolve, 1.0); /* Hero startet, während der Wipe noch läuft */ intro.addEventListener('click', () => tl.progress(1), { once: true }); }); const splitLines = (el, vars = {}) => SplitText.create(el, { type: 'lines', mask: 'lines', linesClass: 'line', autoSplit: true, onSplit(self) { el.classList.add('is-split'); return gsap.from(self.lines, { yPercent: 115, duration: 1.15, stagger: .09, ...vars }); }, }); const mm = gsap.matchMedia(); mm.add({ motion: '(prefers-reduced-motion: no-preference)', desktop: '(min-width: 1024px)', fine: '(pointer: fine)', }, (ctx) => { const { motion, desktop, fine } = ctx.conditions; if (!motion) return; /* ---- Smooth-Scroll (Lenis), synchron mit ScrollTrigger; Touch bleibt nativ ---- */ lenis = new Lenis({ lerp: .1, wheelMultiplier: 1, anchors: { offset: -90 } }); lenis.on('scroll', ScrollTrigger.update); window.lenis = lenis; /* für Anker-Sprünge & Debugging */ const raf = (t) => lenis.raf(t * 1000); gsap.ticker.add(raf); gsap.ticker.lagSmoothing(0); /* ---- Hero ---- */ const hero = $('.hero, .page-hero'); const heroH1 = hero && $('[data-split]', hero); const heroRest = hero ? $$('[data-hero]', hero) : []; const heroPaths = hero ? $$('.hero__chevrons path', hero) : []; gsap.set(heroRest, { opacity: 0, y: 24 }); gsap.set(heroPaths, { strokeDasharray: 1, strokeDashoffset: 1 }); const startHero = () => { if (heroH1) splitLines(heroH1, { delay: .05 }); gsap.to(heroRest, { opacity: 1, y: 0, duration: 1.1, stagger: .08, delay: .35 }); gsap.to(heroPaths, { strokeDashoffset: 0, duration: 1.8, ease: 'expo.inOut', stagger: .12, delay: .1 }); const media = hero && $('.hero__media img', hero); if (media) gsap.from(media, { scale: 1.12, duration: 2.2, ease: 'expo.out' }); }; document.fonts.ready.then(playIntro).then(startHero); if (hero) { const chev = $('.hero__chevrons', hero); const media = $('.hero__media img', hero); const content = $('.hero__content, .page-hero__grid', hero); const st = { trigger: hero, start: 'top top', end: 'bottom top', scrub: true }; if (chev) gsap.to(chev, { yPercent: -18, xPercent: 6, ease: 'none', scrollTrigger: st }); if (media) gsap.to(media, { yPercent: 10, ease: 'none', scrollTrigger: { ...st } }); if (content) gsap.to(content, { y: -60, opacity: .2, ease: 'none', scrollTrigger: { ...st, start: 'center center' } }); } /* ---- Leistungen: horizontal gepinnt (Desktop) ---- */ const hs = $('.hscroll'); let hsTween = null; if (hs && desktop) { hs.classList.add('is-pinned'); const track = $('.hscroll__track', hs); const bar = $('.hscroll__bar i', hs); const count = $('[data-hs-count]', hs); const panels = $$('.panel', hs); gsap.set(panels, { opacity: 1, y: 0 }); const dist = () => Math.max(0, track.scrollWidth - innerWidth); hsTween = gsap.to(track, { x: () => -dist(), ease: 'none', scrollTrigger: { trigger: hs, start: 'top top', end: () => '+=' + dist(), pin: true, scrub: .8, invalidateOnRefresh: true, onUpdate: (self) => { if (bar) bar.style.transform = `scaleX(${self.progress})`; if (count) count.textContent = String(Math.min(panels.length, Math.floor(self.progress * panels.length) + 1)).padStart(2, '0'); }, }, }); panels.forEach((p) => { const img = $('img', p); if (img) gsap.fromTo(img, { xPercent: -6, scale: 1.18 }, { xPercent: 6, scale: 1.18, ease: 'none', scrollTrigger: { trigger: p, containerAnimation: hsTween, start: 'left right', end: 'right left', scrub: true }, }); }); } /* ---- Textzeilen aus der Maske ---- */ $$('[data-split]').forEach((el) => { if (hero && hero.contains(el)) return; splitLines(el, { scrollTrigger: { trigger: el, start: 'top 86%', once: true } }); }); /* ---- Blöcke gestaffelt einblenden ---- */ const reveals = $$('[data-reveal]').filter((el) => !(hs && desktop && hs.contains(el))); ScrollTrigger.batch(reveals, { start: 'top 88%', once: true, onEnter: (batch) => gsap.to(batch, { opacity: 1, y: 0, duration: 1, stagger: .08, overwrite: true }), }); /* ---- Winkel-Reveal für Bilder (Signatur) ---- */ $$('.reveal-img').forEach((el) => { const img = $('img', el); const tl = gsap.timeline({ scrollTrigger: { trigger: el, start: 'top 82%', once: true } }); tl.to(el, { '--x': '0%', duration: 1.35, ease: 'expo.inOut' }); if (img) tl.from(img, { scale: 1.18, duration: 1.8, ease: 'expo.out' }, 0); }); /* ---- Philosophie: Wort für Wort hell (gescrubbt) ---- */ $$('[data-scrub-words]').forEach((el) => { SplitText.create(el, { type: 'words', wordsClass: 'word', autoSplit: true, onSplit(self) { return gsap.fromTo(self.words, { opacity: .14 }, { opacity: 1, ease: 'none', stagger: .12, scrollTrigger: { trigger: el, start: 'top 78%', end: 'bottom 42%', scrub: true }, }); }, }); }); /* ---- Timeline-Linie zeichnet sich ---- */ $$('.timeline').forEach((tl) => { const line = $('.timeline__line i', tl); if (line) gsap.fromTo(line, { scaleY: 0 }, { scaleY: 1, ease: 'none', scrollTrigger: { trigger: tl, start: 'top 70%', end: 'bottom 65%', scrub: true }, }); }); /* ---- Philosophie: Hintergrundbild läuft langsamer mit ---- */ $$('.philosophy__media picture').forEach((el) => { gsap.fromTo(el, { yPercent: -8 }, { yPercent: 8, ease: 'none', scrollTrigger: { trigger: el.closest('section'), start: 'top bottom', end: 'bottom top', scrub: true }, }); }); /* ---- Parallax für einzelne Elemente ---- */ $$('[data-parallax]').forEach((el) => { const amt = Number(el.dataset.parallax) || 10; gsap.fromTo(el, { yPercent: -amt }, { yPercent: amt, ease: 'none', scrollTrigger: { trigger: el.parentElement, start: 'top bottom', end: 'bottom top', scrub: true }, }); }); /* ---- Vorher/Nachher: einmal kurz zeigen, dass man ziehen kann ---- */ $$('.compare').forEach((cmp) => { const o = { v: 50 }; gsap.timeline({ scrollTrigger: { trigger: cmp, start: 'top 70%', once: true } }) .to(o, { v: 72, duration: .9, ease: 'expo.inOut', onUpdate: () => cmp._set(o.v) }) .to(o, { v: 50, duration: .9, ease: 'expo.inOut', onUpdate: () => cmp._set(o.v) }); }); /* ---- Magnet-Buttons (nur Maus) ---- */ if (fine) { $$('.btn, [data-magnetic]').forEach((btn) => { const xTo = gsap.quickTo(btn, 'x', { duration: .6, ease: 'expo.out' }); const yTo = gsap.quickTo(btn, 'y', { duration: .6, ease: 'expo.out' }); const clamp = gsap.utils.clamp(-6, 6); const move = (e) => { const r = btn.getBoundingClientRect(); xTo(clamp((e.clientX - r.left - r.width / 2) * .18)); yTo(clamp((e.clientY - r.top - r.height / 2) * .3)); }; const leave = () => { xTo(0); yTo(0); }; btn.addEventListener('pointermove', move); btn.addEventListener('pointerleave', leave); ctx.add(() => () => { btn.removeEventListener('pointermove', move); btn.removeEventListener('pointerleave', leave); }); }); } /* Nach Font-Load und Bildern Layout neu messen */ document.fonts.ready.then(() => ScrollTrigger.refresh()); addEventListener('load', () => ScrollTrigger.refresh(), { once: true }); return () => { gsap.ticker.remove(raf); lenis?.destroy(); lenis = null; hs?.classList.remove('is-pinned'); }; }); /* Reduced motion: Intro nie zeigen, Zähler stehen schon auf Endwert (HTML) */ mm.add('(prefers-reduced-motion: reduce)', () => { root.classList.remove('has-intro'); }); })();