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>
This commit is contained in:
NotPika69andClaude Opus 5.5 committed 2026-09-30 20:34:00 +02:00
commit 62916fc179
66 files changed
+3466

No files matched your search

+359
View File
@@ -0,0 +1,359 @@
/* ==========================================================================
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');
});
})();