Files
Anelia-Wolf/src/components/motion/ScrollProgress.tsx
T
knso c09f674653 QR Web
Handy ansicht und website ansicht für den Qr gemacht
2026-09-10 20:06:42 +02:00

132 lines
6.4 KiB
TypeScript
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.
"use client";
import { useLayoutEffect, useRef } from "react";
import { gsap, ScrollTrigger, EASE, MM } from "@/lib/gsap";
/**
* 1 px `--accent` line directly under the header (the header writes
* `--header-h` on <html>). Grows with the page scroll; purely decorative.
*
* Scrubbed by ScrollTrigger, not by a second animation runtime: this was the
* only reason `useScroll` (framer-motion) shipped on every route.
*
* ---------------------------------------------------------------------------
* Reduziert-Fähnchen
*
* Trägt eine Seite einen Gutschein, hängt an der Spitze des Balkens ein rotes
* Fähnchen mit dem Nachlass – dort, wo der Leser seine eigene Position abliest.
* Es blendet sich erst ein, wenn der Abschnitt mit dem Formular ins Bild kommt:
* Bis dahin sagt der Balken „so weit bist du“, ab dort zusätzlich „und hier wird
* er eingelöst“.
*
* Diese Komponente hängt in <Shell/> und läuft auf JEDER Seite – auf dem
* Impressum darf kein „−20 %“ kleben. Statt einen Zustand durch das Layout zu
* fädeln (was der App-Router nicht hergibt), liest sie den Text aus dem DOM:
* <ProcessContact/> schreibt ihn als `data-sale` an den Kontaktabschnitt, wenn
* ein Gutschein übergeben wurde. Denselben Weg geht schon die Regel in
* globals.css, die den Kopf-CTA auf der Messeseite ausblendet.
*/
export function ScrollProgress() {
const bar = useRef<HTMLDivElement>(null);
const tag = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = bar.current;
if (!el) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
/* Reduced motion too: the bar reports position, it does not animate for effect –
the scrub follows the reader's own scrolling either way. */
/* Die Strecke wird gemessen, nicht aus einem Element abgeleitet.
Vorher stand hier `trigger: document.documentElement` mit „top top“ bis
„bottom bottom“. <html> trägt aber `h-full`, ist also genau einen Bildschirm
hoch – Anfang und Ende fielen zusammen, die Strecke war null und der Balken
blieb auf 0. Auf dem Desktop fiel das nicht auf, weil Lenis dort seine eigene
Position meldet; auf Mobil ist Lenis aus (SmoothScroll.tsx überlässt Touch dem
nativen Scrollen), und der Balken zeichnete überhaupt nicht.
`maxScroll` fragt den tatsächlichen Scrollweg ab, `invalidateOnRefresh` rechnet
ihn neu, wenn die Seite ihre Höhe ändert. */
const scrub = {
start: 0,
end: () => ScrollTrigger.maxScroll(window),
scrub: true,
invalidateOnRefresh: true,
} as const;
gsap.set(el, { scaleX: 0, transformOrigin: "0 50%" });
gsap.to(el, { scaleX: 1, ease: EASE.none, scrollTrigger: scrub });
/* Das Fähnchen steht immer im Dokument und wird hier beschriftet, statt seinen
Text durch React-State zu schicken: Ein `setState` im Effekt wäre ein zweiter
Durchlauf für eine Angabe, die sich nach dem ersten Rendern nie mehr ändert
(und react-hooks verbietet es zu Recht). Ohne `data-sale` bleibt es leer und
durchsichtig – auf dem Impressum klebt kein „−20 %“. */
const flag = tag.current;
const anchor = document.querySelector<HTMLElement>("main [data-sale]");
if (!flag || !anchor?.dataset.sale) return;
const [value, stub] = flag.querySelectorAll<HTMLElement>("[data-cell]");
value.textContent = anchor.dataset.sale;
stub.textContent = anchor.dataset.saleStub ?? "";
/* Der Balken selbst wird über `scaleX` gezogen – ein Kind darin würde
mitgestaucht. Das Fähnchen läuft deshalb eigenständig über dieselbe
Strecke; `xPercent: -100` legt seine rechte Kante auf die Spitze, damit
es am Ende nicht über den Fensterrand hinausragt. Der Endwert ist eine
Funktion, `invalidateOnRefresh` rechnet ihn beim Größenwechsel neu. */
gsap.set(flag, { opacity: 0, xPercent: -100 });
gsap.fromTo(
flag,
{ x: 0 },
{ x: () => window.innerWidth, ease: EASE.none, scrollTrigger: { ...scrub } },
);
/* Nur `opacity` – nichts, was Fokus oder Tab-Reihenfolge anfasst. Bei
reduzierter Bewegung ohne Überblendung, aber mit demselben Ergebnis. */
const fade = ctx.conditions?.reduce ? 0 : 0.4;
ScrollTrigger.create({
trigger: anchor,
start: "top 90%",
onEnter: () => gsap.to(flag, { opacity: 1, duration: fade, ease: EASE.out }),
onLeaveBack: () => gsap.to(flag, { opacity: 0, duration: fade, ease: EASE.out }),
});
});
return () => mm.revert();
}, []);
return (
<>
<div
ref={bar}
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 z-50 h-px origin-left scale-x-0 bg-accent transition-opacity duration-300 [[data-menu-open]_&]:opacity-0"
style={{ top: "var(--header-h, 60px)" }}
/>
{/* Ein Gutschein im Kleinstformat: runde Ecken wie der große, und quer durch die
Mitte dieselbe Reißkante – hier hochkant, weil das Fähnchen breiter als hoch
ist. Rechts hinter der Perforation sitzt der Abriss.
Anders als beim großen Gutschein sind es keine gestanzten Kerben, sondern nur
die gestrichelte Linie: Eine Kerbe muss den Grund dahinter zeigen, und dieses
Fähnchen schwebt über der Seite – mal über Tinte, mal über Elfenbein. Eine
Kerbe in fester Farbe wäre auf einem von beiden Gründen ein Fleck.
Rot, weil Rot reduziert heißt und Gold das nicht sagen kann. `aria-hidden` wie
der Balken: Dass der Vorteil an der Anfrage hängt, steht als Text im Formular
darunter, nicht nur in dieser Farbe. Leer und durchsichtig, solange die Seite
keinen Gutschein trägt. */}
<div
ref={tag}
aria-hidden="true"
className="pointer-events-none fixed left-0 z-50 flex -translate-y-1/2 items-stretch overflow-hidden rounded-[5px] bg-sale font-mono text-[10px] uppercase leading-none tracking-[0.12em] text-ivory opacity-0 [[data-menu-open]_&]:!opacity-0"
style={{ top: "var(--header-h, 60px)" }}
>
<span data-cell="" className="px-2 py-[5px]" />
<span className="border-l border-dashed border-ivory/55" />
<span data-cell="" className="px-[7px] py-[5px]" />
</div>
</>
);
}
export default ScrollProgress;