Files
Kirschner-Geb-udeservice/assets/motion.js
T
NotPika69andClaude Opus 5.5 62916fc179 Neue Website Kirschner Gebäudeservice
Statische Seite ohne Build und ohne Fremdanfragen: Start, Leistungen,
Unternehmen, Karriere, Kontakt, Impressum und Datenschutz. Design
„Präzision in Blau" mit Archivo Variable, Motion über lokal eingebundenes
GSAP/ScrollTrigger/SplitText und Lenis, reduced-motion-Fallback.
Seiten entstehen aus tools/build_pages.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:34:00 +02:00

360 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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;
});
/* ---------- Umschalter Privat / Gewerbe (Tabs) ---------- */
$$('.switch').forEach((sw) => {
const tabs = $$('[role="tab"]', sw);
const pill = $('.switch__pill', sw);
const place = (t) => { if (pill) { pill.style.width = t.offsetWidth + 'px'; pill.style.transform = `translateX(${t.offsetLeft - 4}px)`; } };
const select = (t, focus) => {
tabs.forEach((x) => {
const on = x === t;
x.setAttribute('aria-selected', String(on));
x.tabIndex = on ? 0 : -1;
const panel = document.getElementById(x.getAttribute('aria-controls'));
if (panel) panel.hidden = !on;
if (on && panel && window.gsap && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.fromTo(panel.children, { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: .6, ease: 'expo.out', stagger: .035 });
}
});
place(t);
if (focus) t.focus();
};
tabs.forEach((t, i) => {
t.addEventListener('click', () => select(t));
t.addEventListener('keydown', (e) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (d) { e.preventDefault(); select(tabs[(i + d + tabs.length) % tabs.length], true); }
});
});
const initial = tabs.find((t) => t.getAttribute('aria-selected') === 'true') || tabs[0];
requestAnimationFrame(() => place(initial));
addEventListener('resize', () => place(tabs.find((t) => t.getAttribute('aria-selected') === 'true')));
});
/* ---------- 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);
});
/* ---- Zähler ---- */
$$('[data-count]').forEach((el) => {
const to = el.dataset.count === 'years' ? years : Number(el.dataset.count);
const o = { v: 0 };
el.textContent = '0';
gsap.to(o, {
v: to, duration: 1.8, ease: 'expo.out',
scrollTrigger: { trigger: el, start: 'top 90%', once: true },
onUpdate: () => { el.textContent = Math.round(o.v); },
});
});
/* ---- 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 },
});
});
/* ---- 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');
});
})();