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.
This commit is contained in:
1 parent
2a16b3f962
commit
107cbadf79
3 files changed
+74
-21
No files matched your search
@@ -14,6 +14,12 @@ interface ContentImageProps {
|
|||||||
* true setzen — laedt das Bild mit hoher Prioritaet ohne Lazy-Loading,
|
* true setzen — laedt das Bild mit hoher Prioritaet ohne Lazy-Loading,
|
||||||
* statt es erst beim Scannen weiter unten im Dokument zu entdecken. */
|
* statt es erst beim Scannen weiter unten im Dokument zu entdecken. */
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
|
/** Leicht abgerundete Ecken. Standard true — passt fuer Karten, Teaser
|
||||||
|
* und Inhaltsbilder, die als eigene Flaeche auf dem Seitenhintergrund
|
||||||
|
* liegen. Fuer randlose Flaechen (Hero ueber die volle Fensterbreite)
|
||||||
|
* bewusst false: dort saesse die Rundung direkt an der Fensterkante und
|
||||||
|
* wirkte wie ein Darstellungsfehler. */
|
||||||
|
rounded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,9 +40,16 @@ export function ContentImage({
|
|||||||
label,
|
label,
|
||||||
sizes = "100vw",
|
sizes = "100vw",
|
||||||
priority = false,
|
priority = false,
|
||||||
|
rounded = true,
|
||||||
}: ContentImageProps) {
|
}: ContentImageProps) {
|
||||||
|
// Dieselbe Rundung fuer Platzhalter UND echtes Bild — sonst spraenge die
|
||||||
|
// Form in dem Moment um, in dem ein Bild gepflegt wird.
|
||||||
|
const roundedClass = rounded ? "rounded-lg" : "";
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return <PlaceholderImage tone={tone} className={className} label={label} />;
|
return (
|
||||||
|
<PlaceholderImage tone={tone} className={`${roundedClass} ${className}`} label={label} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// "label" traegt bereits eine kurze inhaltliche Einordnung (Ernte-Jahr/Typ,
|
// "label" traegt bereits eine kurze inhaltliche Einordnung (Ernte-Jahr/Typ,
|
||||||
// Kategorie, Ort — siehe Aufrufer), die bisher nur auf der Platzhalterkachel
|
// Kategorie, Ort — siehe Aufrufer), die bisher nur auf der Platzhalterkachel
|
||||||
@@ -45,8 +58,22 @@ export function ContentImage({
|
|||||||
// Bildersuche als der Produkt-/Ernteame allein.
|
// Bildersuche als der Produkt-/Ernteame allein.
|
||||||
const descriptiveAlt = label ? `${alt} – ${label}` : alt;
|
const descriptiveAlt = label ? `${alt} – ${label}` : alt;
|
||||||
return (
|
return (
|
||||||
<div className={`relative overflow-hidden ${className}`}>
|
<div className={`relative overflow-hidden ${roundedClass} ${className}`}>
|
||||||
<Image src={src} alt={descriptiveAlt} fill sizes={sizes} priority={priority} className="object-cover" />
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={descriptiveAlt}
|
||||||
|
fill
|
||||||
|
sizes={sizes}
|
||||||
|
// Next.js 16 hat die frueher hier verwendete Prop "priority"
|
||||||
|
// abgekuendigt: sie erzeugt nur noch einen <link rel="preload">, setzt
|
||||||
|
// am <img> selbst aber weder fetchpriority noch loading. Gemessen kam
|
||||||
|
// das Hero-Bild damit trotz "priority" mit niedriger Netzwerk-
|
||||||
|
// Prioritaet an. Die Dokumentation nennt fuer diesen Fall
|
||||||
|
// ausdruecklich loading="eager" und fetchPriority="high" als Ersatz.
|
||||||
|
loading={priority ? "eager" : "lazy"}
|
||||||
|
fetchPriority={priority ? "high" : "auto"}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+35
-13
@@ -5,11 +5,6 @@ import { Container, LinkButton } from "@/components/ui";
|
|||||||
import { ContentImage } from "@/components/content-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
const sectionVariants: Variants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: { opacity: 1, transition: { duration: 0.6 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemContainerVariants: Variants = {
|
const itemContainerVariants: Variants = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
visible: { transition: { delayChildren: 0.3, staggerChildren: 0.12 } },
|
visible: { transition: { delayChildren: 0.3, staggerChildren: 0.12 } },
|
||||||
@@ -41,17 +36,38 @@ export function Hero({
|
|||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.section
|
// Bewusst KEINE Einblend-Animation auf der aeussersten Ebene: eine
|
||||||
className="relative"
|
// Section, die bei opacity 0 startet, wird erst sichtbar, wenn das
|
||||||
initial={shouldReduceMotion ? false : "hidden"}
|
// JS-Bundle geladen und die Animation gelaufen ist. Das Hero-Bild ist
|
||||||
animate="visible"
|
// aber das groesste Element im ersten Viewport (LCP) — es so lange
|
||||||
variants={sectionVariants}
|
// 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 ? (
|
{video ? (
|
||||||
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
|
<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
|
<video
|
||||||
src={video}
|
src={video}
|
||||||
poster={image ?? undefined}
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
// Nutzer mit "Bewegung reduzieren" bekommen nur das Standbild
|
// Nutzer mit "Bewegung reduzieren" bekommen nur das Standbild
|
||||||
// (poster) statt der automatisch laufenden Videoschleife (WCAG
|
// (poster) statt der automatisch laufenden Videoschleife (WCAG
|
||||||
@@ -60,6 +76,11 @@ export function Hero({
|
|||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
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
|
// JS-Fallback zusaetzlich zum loop-Attribut: manche Browser
|
||||||
// (u.a. aeltere iOS-Safari-Versionen) starten ein Video nach
|
// (u.a. aeltere iOS-Safari-Versionen) starten ein Video nach
|
||||||
// "ended" nicht zuverlaessig neu, wenn autoplay/muted/playsinline
|
// "ended" nicht zuverlaessig neu, wenn autoplay/muted/playsinline
|
||||||
@@ -79,6 +100,7 @@ export function Hero({
|
|||||||
tone="forest"
|
tone="forest"
|
||||||
className="h-[78vh] min-h-[520px] w-full"
|
className="h-[78vh] min-h-[520px] w-full"
|
||||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
label="Bienenstand im Morgenlicht · Altenmünster"
|
||||||
|
rounded={false}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -124,6 +146,6 @@ export function Hero({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ export function ProductGallery({ images, productName, description }: ProductGall
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="relative aspect-[4/3] overflow-hidden touch-pan-y"
|
className="relative aspect-[4/3] overflow-hidden rounded-lg touch-pan-y"
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
@@ -72,14 +72,18 @@ export function ProductGallery({ images, productName, description }: ProductGall
|
|||||||
// /uploads/... (siehe uploads.ts/isValidUploadPath) — next/image
|
// /uploads/... (siehe uploads.ts/isValidUploadPath) — next/image
|
||||||
// braucht dafuer KEINEN remotePatterns-Eintrag (nur externe
|
// braucht dafuer KEINEN remotePatterns-Eintrag (nur externe
|
||||||
// Bildquellen brauchen den), siehe auch content-image.tsx. Als
|
// Bildquellen brauchen den), siehe auch content-image.tsx. Als
|
||||||
// wahrscheinlichstes LCP-Element der Produktseite mit "priority"
|
// wahrscheinlichstes LCP-Element der Produktseite sofort und mit
|
||||||
// geladen, statt erst beim Scannen des Bild-Tags entdeckt zu werden.
|
// hoher Netzwerk-Prioritaet geladen, statt erst beim Scannen des
|
||||||
|
// Bild-Tags entdeckt zu werden. Bewusst NICHT ueber die Prop
|
||||||
|
// "priority": die ist seit Next.js 16 abgekuendigt und erzeugt nur
|
||||||
|
// noch einen Preload-Link, ohne fetchpriority am <img> zu setzen.
|
||||||
<Image
|
<Image
|
||||||
src={active.src}
|
src={active.src}
|
||||||
alt={mainImageAlt}
|
alt={mainImageAlt}
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 768px) 50vw, 100vw"
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
priority
|
loading="eager"
|
||||||
|
fetchPriority="high"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -95,7 +99,7 @@ export function ProductGallery({ images, productName, description }: ProductGall
|
|||||||
onClick={() => setActiveIndex(index)}
|
onClick={() => setActiveIndex(index)}
|
||||||
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
||||||
aria-current={index === activeIndex}
|
aria-current={index === activeIndex}
|
||||||
className={`relative aspect-square overflow-hidden border-2 transition-colors ${
|
className={`relative aspect-square overflow-hidden rounded-lg border-2 transition-colors ${
|
||||||
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
|
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in new issue
Block a user