Files
Wabenhain/src/components/hero.tsx
T
Nick Szaunig 107cbadf79 perf: Header-Medien sofort und in passender Groesse laden
Drei Gruende, warum Hero-Bild und -Video spuerbar spaet erschienen:

1. Das Standbild des Hero-Videos steckte im poster-Attribut. poster nimmt
   nur eine fertige URL und laedt die hochgeladene Datei damit in voller
   gespeicherter Groesse — am Bildoptimierer vorbei, ohne Ruecksicht auf
   das Geraet. Es liegt jetzt als eigene ContentImage-Ebene unter dem
   Video, laeuft also durch next/image. Gemessen: auf einem 500px breiten
   Viewport kommt statt der vollen Datei die 640px-Variante als WebP an.
   Das Video darueber ist transparent, bis es eigene Bilddaten hat — der
   Uebergang bleibt unsichtbar.

2. Die aeusserste Hero-Section startete bei opacity 0 und wurde erst per
   Animation nach der Hydration sichtbar. Das groesste Element im ersten
   Viewport wurde damit um die gesamte JS-Ladezeit zurueckgehalten. Die
   Animation dort ist entfernt; der gestaffelte Aufbau der Textspalte
   bleibt unveraendert.

3. next/image erhielt weiterhin die Prop "priority". Die ist seit
   Next.js 16 abgekuendigt (siehe node_modules/next/dist/docs, Changelog
   v16.0.0) und erzeugt nur noch einen <link rel="preload">, setzt am
   <img> selbst aber weder fetchpriority noch loading — im Trace kam das
   Hero-Bild deshalb trotz "priority" mit Prioritaet "Low" an. Die
   Dokumentation nennt dafuer ausdruecklich loading="eager" und
   fetchPriority="high" als Ersatz; ContentImage bildet seine unveraendert
   heissende priority-Prop jetzt darauf ab, die Produktgalerie ebenso.
   Danach bestand die LCP-Discovery-Pruefung von Chrome vollstaendig,
   vorher scheiterte sie an fetchpriority.

Ausserdem bekommen Inhaltsbilder leicht abgerundete Ecken (8px), Platzhalter
und echtes Bild gleichermassen, damit die Form beim Pflegen eines Bildes
nicht umspringt. Randlose Flaechen (Hero ueber die volle Fensterbreite)
bleiben bewusst eckig — dort saesse die Rundung an der Fensterkante.

Geprueft im Produktions-Build gegen Chrome: CLS 0.00, kein horizontaler
Ueberlauf bei schmalem Viewport, Video spielt (readyState 4), Hero-Bild
loading="eager" + fetchpriority="high", uebrige Bilder lazy.
2026-09-14 20:36:53 +02:00

152 lines
6.1 KiB
TypeScript

"use client";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { Container, LinkButton } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { useI18n } from "@/i18n/i18n-provider";
const itemContainerVariants: Variants = {
hidden: {},
visible: { transition: { delayChildren: 0.3, staggerChildren: 0.12 } },
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.7, ease: "easeOut" } },
};
/**
* Startseiten-Hero mit grossflaechigem Bild und gestaffelter
* Einblend-Animation (Eyebrow, Headline, Text, Buttons) beim Laden.
*/
export function Hero({
intro,
footnote,
location,
image,
video,
}: {
intro: string;
footnote: string;
location: string;
image: string | null;
video: string | null;
}) {
const { t } = useI18n();
const shouldReduceMotion = useReducedMotion();
return (
// Bewusst KEINE Einblend-Animation auf der aeussersten Ebene: eine
// Section, die bei opacity 0 startet, wird erst sichtbar, wenn das
// JS-Bundle geladen und die Animation gelaufen ist. Das Hero-Bild ist
// aber das groesste Element im ersten Viewport (LCP) — es so lange
// zurueckzuhalten verschiebt den gemessenen Ladezeitpunkt der Seite um
// die gesamte Hydrationsdauer. Der gestaffelte Aufbau der Textspalte
// weiter unten bleibt unveraendert, der laeuft unabhaengig davon.
<section className="relative">
{video ? (
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
{/* Standbild als eigene Ebene UNTER dem Video statt im
poster-Attribut. poster nimmt nur eine fertige URL und laedt die
hochgeladene Datei damit in voller gespeicherter Groesse (bis
MAX_IMAGE_DIMENSION, siehe lib/uploads.ts) — am Bildoptimierer
vorbei und ohne Ruecksicht auf das Geraet. Als ContentImage
laeuft dasselbe Bild durch next/image: passende Breite je
Bildschirm, WebP statt JPEG, und mit "priority" als erstes
angefordert. Das Video liegt darueber und ist transparent, bis es
eigene Bilddaten hat — der Uebergang ist deshalb nicht sichtbar. */}
<div className="absolute inset-0">
<ContentImage
src={image}
alt=""
tone="forest"
className="h-full w-full"
rounded={false}
sizes="100vw"
priority
/>
</div>
<video
src={video}
className="absolute inset-0 h-full w-full object-cover"
// Nutzer mit "Bewegung reduzieren" bekommen nur das Standbild
// (poster) statt der automatisch laufenden Videoschleife (WCAG
// 2.2.2 "Pause, Stop, Hide" fuer automatisch startende Bewegung).
autoPlay={!shouldReduceMotion}
muted
loop
playsInline
// Ohne Autoplay (Einstellung "Bewegung reduzieren") gibt es
// nichts abzuspielen — dann nur die Kopfdaten holen statt die
// ganze Datei. Mit Autoplay ignorieren Browser den Hinweis
// ohnehin und laden so viel, wie zum Starten noetig ist.
preload={shouldReduceMotion ? "metadata" : "auto"}
// JS-Fallback zusaetzlich zum loop-Attribut: manche Browser
// (u.a. aeltere iOS-Safari-Versionen) starten ein Video nach
// "ended" nicht zuverlaessig neu, wenn autoplay/muted/playsinline
// kombiniert sind. Erzwingt den Neustart explizit, damit die
// Dauerschleife garantiert nie sichtbar stehen bleibt.
onEnded={(event) => {
const el = event.currentTarget;
el.currentTime = 0;
void el.play().catch(() => {});
}}
/>
</div>
) : (
<ContentImage
src={image}
alt=""
tone="forest"
className="h-[78vh] min-h-[520px] w-full"
label="Bienenstand im Morgenlicht · Altenmünster"
rounded={false}
priority
/>
)}
{/* Gleichmaessige Abdunklung ueber die gesamte Flaeche — noetig, weil
redaktionell gepflegtes Bild-/Videomaterial (z.B. helle Etiketten,
Himmel) sonst je nach Motiv beliebig hell ausfallen und den Text
unlesbar machen kann. Der gerichtete Verlauf darunter verstaerkt das
gezielt dort, wo die Textspalte tatsaechlich sitzt (unten links). */}
<div className="absolute inset-0 bg-forest/25" />
<div className="absolute inset-0 flex items-center bg-gradient-to-tr from-forest/70 via-forest/25 to-transparent">
<Container>
<motion.div
className="max-w-xl text-cream"
initial={shouldReduceMotion ? false : "hidden"}
animate="visible"
variants={itemContainerVariants}
>
<motion.p
variants={itemVariants}
className="tracking-wide-caps text-xs text-honey-light mb-4"
>
{location}
</motion.p>
<motion.h1 variants={itemVariants} className="text-4xl md:text-6xl leading-tight">
{t.footer.tagline}
</motion.h1>
<motion.p variants={itemVariants} className="mt-6 text-cream/85 max-w-md leading-relaxed">
{intro}
</motion.p>
<motion.div variants={itemVariants} className="mt-10 flex flex-wrap gap-4">
<LinkButton href="/shop">{t.home.hero.ctaPrimary}</LinkButton>
<LinkButton
href="/imkerei"
variant="secondary"
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest"
>
{t.home.hero.ctaSecondary}
</LinkButton>
</motion.div>
<motion.p variants={itemVariants} className="mt-8 text-xs text-cream/60 tracking-wide">
{footnote}
</motion.p>
</motion.div>
</Container>
</div>
</section>
);
}