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>
360 lines
15 KiB
JavaScript
360 lines
15 KiB
JavaScript
/* ==========================================================================
|
||
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');
|
||
});
|
||
})();
|