OnePager/Komfort-Version
Die bisher gezeigten - nur mit CSS realisierten - Beispiele sind voll funktionsfähig. Allerdings lassen sich einige Extras wie Weiter-Buttons nur mit vielen Zeilen CSS realisieren und sind dann schwer zu erweitern.
Dieses Tutorial zeigt, wie man mit JavaScript und der Intersection-Observer-API den aktuell sichtbaren Teil der Webseite identifiziert und dann den entsprechenden Seitenanker in der Navigation mit aria-current auszeichnen sowie Links zum nächsten Kapitel erzeugen kann. Bilder werden mit Lazy-Loading erst eingeblendet, wenn der Nutzer in den entsprechenden Seitenbereich scrollt.
Inhaltsverzeichnis
Vorüberlegungen
HTML-Markup
Als Vorlage wurden die aus mehreren Unterseiten bestehende Webseite der Schreinerei Meier aus dem HTML-Einstieg zu einem Dokument zusammengefasst.
Wie im vorhergehenden Kapitel besteht unser HTML-Markup aus verschiedenen section-Abschnitten mit h2- und h3-Überschriften, die alle in ein main-Element gelegt wurden:
<main>
<section id="willkommen">
<h2>Willkommen bei der Schreinerei Meier im Internet!</h2>
...
</section>
<section id="geschichte">
<h2>Unsere Geschichte:</h2>
...
</section>
<section id="preise">
<h2>Unsere nur zu Beispielzwecken erdachten Phantasie-Preise</h2>
...
Damit beim Navigieren zu den einzelnen Abschnitten der Seitenkopf und die Navigation sichtbar bleiben, und damit die Navigation in einer Zeile angezeigt wird, wurde das CSS um folgende Regeln erweitert:
body {
height: 100vh;
display: flex;
flex-direction: column;
}
nav ul {
list-style: none;
padding-inline: 0;
display: flex;
flex-flow: row wrap;
gap: 1em 2em;
}
main {
overflow: auto;
padding: 0 .5em;
}
Dadurch wird nur im main-Element gescrollt:
@media (prefers-reduced-motion: no-preference) {
main {
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
scroll-padding: 0; /* nicht nötig */
}
section {
min-height: 100vh;
scroll-snap-align: start;
scroll-margin: 0; /* nicht nötig */
}
}
Eine Erläuterung zu diesem CSS findet sich im vorhergehenden Kapitel.
Dateiorganisation
Sollte CSS und JavaScript im Dokument integriert werden oder in Einzeldateien in Unterverzeichnissen angelegt werden?
Für die Unterverzeichnisse spricht …
- die spätere Erweiterbarkeit mit Unterseiten (die bei einem OnePager ja eigentlich unnötig, aber nicht ausgeschlossen ist)
- dass ausgelagertes CSS und JavaScript bei schnellen DSL-Verbindungen schon parallel zum HTML geladen wird.
Für die Kompaktvariante spricht,
- dass die Latenzzeiten in Mobilfunknetzen bei mehreren Dateien zu einem verzögertem Laden führen, so dass es vorteilhaft ist, alles zusammen in einem HTTP-Request zu haben.
Auch der Aufbau des Frickl-Beispiels wird in der Kompaktvariante einfacher.
Komfort mit JavaScript
Das HTML-Dokument bleibt unverändert. Nach den Regeln des Unobtrusive JavaScript wird dem Dokument durch JavaScript zusätzliche Funktionalität hinzugefügt.
Es ermittelt den sichtbaren Bereich der Webseite und kennzeichnet diesen und fügt Links zu benachbarten Abschnitten hinzu. Dies alles geschieht automatisch - bei einer Änderung des HTMLs ist keine Anpassung des Scripts nötig.
Intersection Observer API
Die Intersection Observer API ermöglicht es, ein Element zu beobachten und zu erkennen, wenn es einen bestimmten Punkt in einem Scroll-Container - oft (aber nicht immer) den Viewport - passiert und eine Callback-Funktion auslöst.[1]
Die Intersection Observer API soll verwendet werden, um in der Seitennavigation den Link zum gerade sichtbaren Abschnitt hervor zu heben. Dieses soll sowohl beim Anspringen durch Klick auf den Link als auch beim Scrollen erfolgen.
Dazu muss ein Observer definiert und den Sections zugeordnet werden:
const observer = new IntersectionObserver(function(entries) {
...
}, { threshold: 0.1 });
document.querySelectorAll("section").forEach(element => observer.observe(element));
Hierdurch wird die dem IntersectionObserver als Parameter mitgegebene anonyme Funktion immer dann aufgerufen, wenn sich die Sichtbarkeit des überwachten Elements ändert. Der zweite Parameter ( threshold
) gibt an, wie weit das Element in den Viewport ragen muss, bis der Observer reagiert, hier 10%.
Die Observerfunktion soll dann den Link zum Element, dessen Sichtbarkeit sich geändert hat, suchen und markieren. Die Suche erfolgt über die ID des section-Elements. Als Markierung wird dem Link das aria-current-Attribut mit dem Wert location
gegeben oder genommen.
Das Script sieht dann so aus:
const observer = new IntersectionObserver(function(entries) {
entries.forEach(entry => {
const linkselector = `nav [href='#${entry.target.id}']`;
const linkelement = document.querySelector(linkselector);
if(linkelement) {
if(entry.isIntersecting) linkelement.parentNode.setAttribute("aria-current", "location");
else linkelement.parentNode.removeAttribute("aria-current");
}
});
}, { threshold: 0.1 });
document.querySelectorAll("section").forEach(element => observer.observe(element));
Durch die Verwendung des Attributs aria-current wird auch assistiven Technologien mitgeteilt, welcher Teil der Seite gerade angesprungen oder angescrollt wurde.
Damit der Link nun auch optisch hervorgehoben wird, wird dem CSS noch folgende Regel hinzugefügt:
[aria-current=location]:before {
content: "► ";
}
Zusätzlich zur Navigation sollten die jeweils vorhergehenden und folgenden Abschnitte mit Vor- und Zurück-Pfeilen verlinkt werden.
Bei unserer Schreinereiseite würden wir einen "nach oben"- und einen " nach unten"-Pfeil realisieren – in dieser Variante wird von links nach rechts gescrollt, deshalb erscheint ein Rechtspfeil:
// Bsp. folgt
ToDo (weitere ToDos)
Ich würde hier eine neue Funktion verwenden, die [aria-current=location] ausliest und mit Element.closest dann die id's der Nachbar-sections ausliest und dafür Links erstellt. Wir brauchen ja keine Linktexte, sondern nur Pfeile!
Variante mit Skip-to-Top-Link
Die oben vorgestelle Variante, die wir gleich weiter ausbauen, hat einen fixen header, der immer am oberen Rand sichtbar bleibt, auch wenn zum vorher unten liegenden Inhalt gescrollt wird.
Diese Variante soll ohne fixen Header gestaltet werden. Sobald die Navigation unsichtbar wird, soll ein Skip-To-Top-Link erscheinen, der nach oben zielt.
<a href="#willkommen" id="skip-to-top" hidden>skip to top</a>
Der Link wird mit dem hidden-Attribut ausgeblendet.
Information: Trennung von Inhalt, Darstellung und Verhalten
Geht so eine Lösung nur mit CSS?[2] - Hier hilft es, sich vom vermeintlich einfachen Lösungsweg - dem Verzicht auf JS - zu lösen.
- HTML ist für die Auszeichnung des Inhalts zuständig
- CSS sorgt für die Darstellung
- JS reagiert auf Verhalten des Nutzers
const topLink = document.querySelector('#skip-to-top');
const nav = document.querySelector('nav');
let observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) {
topLink.hidden = false;
} else {
topLink.hidden = true;
}
});
}, {
threshold: [0]
});
observer.observe(nav);
Ein IntersectionObserver beobachtet mit der isIntersecting-Eigenschaft, ob sich der übergebende Parameter (das nav-Element) im sichtbaren Bereich befindet.
- trifft das nicht zu, wird dem skip-to-top-Link das hidden-Attribut entfernt
- ansonsten wieder hinzugefügt.
Der oben besprochene threshold
-Parameter wurde hier auf 0 gesetzt.
Lazy-Loading
Damit beim OnePager die Bilder erst geladen werden, wenn sie in den sichtbaren Bereich kommen, erhält das img-Element ein loading-Attribut. Es legt fest, wie externe Medien geladen werden sollen. [3]
<img src="img/cabinet.svg" alt="Kommode" loading="lazy" width="200" height="200">
...
Mit loading="lazy"
wird festgelegt, dass Medien außerhalb des sichtbaren Bereichs erst geladen werden, wenn der Benutzer scrollt. Zusätzlich sollte mit einer Breitenangabe der Patz für das Bild reserviert werden, damit die Seite nicht immer wieder neu gerendert werden muss.
Dies wollen wir mit ein bisschen CSS animieren:
<span class="lazyContainer">
<img src="img/cabinet.svg" alt="Kommode" loading="lazy">
</span>
.lazyContainer {
position:relative;
display:inline-block;
width: 100%;
aspect-ratio: 4/3;
overflow: hidden;
vertical-align: bottom;
}
.lazyImage img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 1;
transform: scale(1);
transition: all 0.5s;
}
[4]
ToDo (weitere ToDos)
URL-Anpassung
Über die Links der Navigation kommt man zu den internen Seitenankern auf die einzelnen „Seiten“. Dabei werden die URLs, bzw. Seitenanker in der History API gespeichert. Ein Klick auf den Zurück-Button des Browsers führt zum letzten geklickten Abschnitt, nicht zur letzen anderen Webseite.[5][6]
Dies könnte man auch mit JavaScript verfeinern.
Feinschliff mit CSS
Scrollbalken
Da die Seite so gestaltet ist, dass der Seitenkopf beim Scrollen stehen bleibt, und dieses dadurch realisiert wurde, dass nur der Main-Bereich scrollt, gibt es „hässliche“ Scrollbalken innerhalb der Seite. Diese lassen sich mit der scrollbar-Eigenschaft umgestalten. Allerdings ist die Browserunterstützung noch nicht einheitlich, so dass mit Prefixes gearbeitet werden muss:
main {
scrollbar-color: firebrick beige;
}
main::-webkit-scrollbar {
width: 1em;
height: 2em;
background-color: beige;
}
main::-webkit-scrollbar:hover {
border: thin solid lightgray;
border-radius: .5em 0 .5em .5em;
}
main::-webkit-scrollbar-thumb {
background: firebrick;
border-radius: .5em 0 .5em .5em;
}
Kleine Viewports
Auf kleinen Bildschirmen bleibt unter dem feststehenden Seitenkopf zu wenig Platz für den Seiteninhalt. Daher wird in diesem Fall beim Scrollen der Seitenkopf auf das Logo und die Navigationszeile reduziert. Hierzu ist noch etwas CSS und JavaScript nötig.
Im CSS wird über eine Mediaquery festgelegt, ab welcher Viewporthöhe der header ausgeblendet werden soll. Hier wird eine Custom Property definiert, die dann im JavaScript ausgewertet wird.
body {
--viewporthoehe: hoch;
}
@media (max-height: 30em) {
body {
--viewporthoehe: niedrig;
}
}
Im JavaScript wird dann ein Handler für das resize-Event notiert, der die im CSS definierte custom property --viewporthoehe
ausliest und bei kleinem Viewport einen Eventhandler für das scroll-Event notiert. Dieser Handler gibt oder nimmt dann dem Header abhängig von der Scrollposition die Klasse niedrig
.
// Bei geringer Viewporthöhe Teile des Headers ausblenden
const header = document.querySelector("header");
const main = document.querySelector("main");
function handleScroll(event) {
if(event.target.scrollTop > 10) {
header.classList.add("niedrig");
}
else {
header.classList.remove("niedrig");
}
}
function handleResize() {
if(getComputedStyle(document.body).getPropertyValue("--viewporthoehe")=="niedrig") {
main.addEventListener("scroll",handleScroll);
}
else {
header.classList.remove("niedrig");
main.removeEventListener("scroll", handleScroll);
}
}
window.addEventListener("resize", handleResize);
handleResize();
Dem css werden dann noch Regeln für diese Klasse hinzugefügt:
header.niedrig hgroup {
display: none;
}
nav:not(header.niedrig nav) {
grid-column: 1/-1;
}
Die fertige Seite
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schreinerei Meier, Dingenskirchen</title>
<style>
body {
max-width:60em;
margin: 0 /*1em*/ auto;
background-color: beige;
}
header {
padding: 0 1em;
margin-top: 1em;
margin-bottom: 0em;
display: grid;
grid-template-columns: 6em 1fr;
}
header p {
top: 1.7em;
transform: rotate(-10deg);
border: thin solid;
padding: 0.5em;
border-radius: 0.2em;
}
header #backlink {
display:inline-block;
text-shadow: none;
color: transparent;
background: transparent;
background-size: contain;
width: 5em;
height: 3em;
}
hgroup {
display: flex;
flex-flow: row wrap;
align-items: start;
gap: 0 3em;
}
@media (max-width: 45em) {
hgroup p {
display: none;
}
}
footer {
display: grid;
grid-template-columns: 1fr 10em;
}
footer > * {
grid-column: -2 / -1;
}
h1 {
color: brown;
font-size: 2em;
font-weight: bold;
}
h2 {
background-color: firebrick;
color: beige;
padding: 0.5em;
font-family: sans-serif;
border-radius: 0 1em 1em 1em;
}
h3 {
font-size: 1.4em; /*2.6em;*/
color: firebrick;
background-color: LightSalmon ;
border: thin solid;
font-family: cursive;
text-align: center;
}
p {
color: brown;
font-size: 1.1em;
}
@media (min-width: 30em) {
#leistungen {
display: grid;
grid-template-columns: 1fr 12em;
}
#leistungen h3 {
grid-column: 1 / -1;
}
}
#angebot {
background-color: firebrick;
padding: 1em;
width: 10em;
text-align: center;
}
#angebot h4 {
font-size: 2em;
font-variant: small-caps;
color: lightyellow;
}
#angebot p {
color: white;
}
#produkte img {
max-width: 100%;
}
/* Für One-Pager */
body {
--viewporthoehe: hoch;
}
@media (max-height: 30em) {
body {
--viewporthoehe: niedrig;
}
}
body {
height: 100vh;
display: flex;
flex-direction: column;
}
header.niedrig hgroup {
display: none;
}
nav:not(header.niedrig nav) {
grid-column: 1/-1;
}
nav ul {
list-style: none;
padding-inline: 0;
display: flex;
flex-flow: row wrap;
gap: 1em 2em;
}
main {
overflow: auto;
padding: 0 .5em;
scrollbar-color: firebrick beige;
}
main::-webkit-scrollbar {
width: 1em;
height: 2em;
background-color: beige;
}
main::-webkit-scrollbar:hover {
border: thin solid lightgray;
border-radius: .5em 0 .5em .5em;
}
main::-webkit-scrollbar-thumb {
background: firebrick;
border-radius: .5em 0 .5em .5em;
}
[aria-current=location]:before {
content: "► ";
}
</style>
<script type="module">
"use strict";
// Prüfen, welche Section sichtbar ist, und link auf diese Section hervorheben
const observer = new IntersectionObserver(function(entries) {
entries.forEach(entry => {
const linkselector = `nav [href='#${entry.target.id}']`;
const linkelement = document.querySelector(linkselector);
if(linkelement) {
if(entry.isIntersecting) linkelement.parentNode.setAttribute("aria-current", "location");
else linkelement.parentNode.removeAttribute("aria-current");
}
});
}, { threshold: 0.1 });
document.querySelectorAll("section").forEach(element => observer.observe(element));
// Bei geringer Viewporthöhe Teile des Headers ausblenden
const header = document.querySelector("header");
const main = document.querySelector("main");
function handleScroll(event) {
if(event.target.scrollTop > 10) {
header.classList.add("niedrig");
}
else {
header.classList.remove("niedrig");
}
}
function handleResize() {
if(getComputedStyle(document.body).getPropertyValue("--viewporthoehe")=="niedrig") {
main.addEventListener("scroll",handleScroll);
}
else {
header.classList.remove("niedrig");
main.removeEventListener("scroll", handleScroll);
}
}
window.addEventListener("resize", handleResize);
handleResize();
</script>
</head>
<body>
<header>
<a id="backlink" href="#willkommen"><img src="img/logo.svg" alt="Willkommen"></a>
<hgroup>
<h1>Schreinerei Meier</h1>
<p>ihre Werkstatt für gutes Wohnen!</p>
</hgroup>
<nav>
<ul>
<li><a href="#willkommen">Willkommen</a></li>
<li><a href="#preise">Unsere Preise</a></li>
<li><a href="#produkte">Bilder von unseren Produkten</a></li>
<li><a href="#kontakt">Kontakt und Impressum</a></li>
</ul>
</nav>
</header>
<main>
<section id="willkommen">
<h2>Willkommen bei der Schreinerei Meier im Internet!</h2>
<p>Wir sind seit vielen Jahren darauf spezialisiert, alle Kundenwünsche zu erfüllen. In unserer
Werkstatt produzieren wir selbst - mit Holz aus regionaler, nachhaltiger Forstwirtschaft.</p>
<section id="leistungen">
<h3>Unsere Leistungen:</h3>
<ul>
<li>Möbel nach Ihren Wünschen
<ul>
<li>Küchenmöbel</li>
<li>Regale und Schrankwände</li>
<li>Badezimmermöbel</li>
</ul>
</li>
<li>Haustüren</li>
<li>Gartenzäune</li>
<li>Reparaturen</li>
</ul>
<aside id="angebot">
<h4>Angebot</h4>
<p>Nächste Woche 10% auf alles!</p>
</aside>
</section>
<section>
<h3>Unsere Geschichte:</h3>
<p>Die Anfänge unserer Firma reichen bis ins Mittelalter zurück, als Horst Holzmann begann seine
bisher für den Eigenbedarf hergestellten Möbel auch auf dem Markt der nächsten Stadt zu verkaufen.</p>
<p>Sein Sohn führte diese Tradition fort und nach ihm noch viele weitere Kinder und Kindeskinder.
Heute führt Schwiegersohn Harry Meier den Betrieb in der 15. Generation weiter und arbeitet bereits seinen Enkel als Nachfolger ein.</p>
</section>
</section>
<section id="preise">
<h2>Unsere nur zu Beispielzwecken erdachten Phantasie-Preise</h2>
<table>
<tr>
<th>Produkt</th>
<th>Preis</th>
</tr>
<tr>
<td>Tisch</td>
<td>50 €</td>
</tr>
<tr>
<td>Schrank</td>
<td>70 €</td>
</tr>
<tr>
<td>Bett</td>
<td>100 €</td>
</tr>
</table>
</section>
<section id="produkte">
<h2>Unsere Produkte</h2>
<p>Hier sehen Sie einen Überblick über unsere Angebote.</p>
<p>
<img src="img/cabinet.svg" alt="Kommode" loading="lazy">
<img src="img/dresser.svg" alt="Kommode mit Schubladen" loading="lazy">
<img src="img/filing-cabinet.svg" alt="Aktenschrank" loading="lazy">
<img src="img/table.svg" alt="Tisch" loading="lazy">
<img src="img/cupboard.svg" alt="Kleiderschrank" width="500" height="400" loading="lazy">
<img src="img/chair.svg" alt="Schaukelstuhl" loading="lazy">
<img src="img/desk.svg" alt="Schreibtisch" loading="lazy">
</p>
</section>
<section id="kontakt">
<h2>Impressum</h2>
<p>Angaben gem. § 5 TMG</p>
<dl>
<dt>Betreiber und Kontakt:</dt>
<dd>Schreinerei Meier</dd>
<dd>Möbelstr. 1</dd>
<dt></dt>
<dd>D-12345</dd>
<dt></dt>
<dd>Musterstadt</dd>
<dt></dt>
<dd>Germany</dd>
<dt>Tel:</dt>
<dd>+49 1234 5678</dd>
<dt>Fax:</dt>
<dd>+49 1234 5679</dd>
<dt>E-Mail-Adresse:</dt>
<dd><a href="mailto:test@example.com">test@example.com</a></dd>
</dl>
<dl>
<dt>Vertretung:</dt>
<dd>Die Schreinerei Meier wird rechtlich vertreten durch Herrn Harry Meier</dd>
</dl>
<h3>Berufsspezifische Angaben:</h3>
<p>Berufsbezeichnung: k. A. </p>
<p>Zuständige Kammer: IHK Bayern</p>
<p>Verliehen in/durch: k. A.</p>
<p>Folgende berufsrechtliche Regelungen finden Anwendung: k. A. <br>
Diese Regelungen können Sie einsehen unter: k. A. </p>
<p>Zuständige Aufsichtsbehörde: Finanzamt Musterstadt</p>
<p>Register und Registernummer: 999999 999999</p>
<p>Umsatzsteuer-ID: 999999 999999</p>
<p>Verantwortlicher für journalistisch-redaktionelle Inhalte gem. § 55 II RstV:<br />Hans Jürgen Mustermann</p>
<p>Quelle: <a href='http://www.deutsche-anwaltshotline.de/recht-auf-ihrer-website/impressum-generator'>Impressum-Generator</a> der Deutschen Anwaltshotline AG</p>
</section>
<aside>
<h3>Kontakt</h3>
<dl>
<dt>E-Mail-Adresse:</dt>
<dd>test@example.com</dd>
<dt>Tel:</dt>
<dd>+49 1234 5678</dd>
<dt>Fax:</dt>
<dd>+49 1234 5679</dd>
</dl>
</aside>
<footer>
<p>© 2021 by SELFHTML</p>
</footer>
</main>
</body>
</html>
Fazit
Hiermit haben Sie einen eleganten und komfortablen OnePager für einen kleinen Webauftritt oder eine Produktseite.
Sie könnten die Navigation auch dynamisch erzeugen, indem Sie mit JavaScript alle Kapitelüberschriften auslesen und in die Navigation einfügen.
Hauptartikel: HTML/Tutorials/Listen/Hybride Nummerierung#dynamisch erstelltes Inhaltsverzeichnis
Dies alles bleibt Ihnen überlassen.
Weblinks
- ↑ MDN: Intersection Observer API
- ↑ Skip-To-Link nur mit CSS? -Ja!
Pure CSS Smooth-Scroll "Back to Top " von Stephanie Eckles - ↑ MDN: Lazy loading - Web Performance
- ↑ Enhancing HTML 5 Lazy Loading With CSS and Minimal JavaScript von Jason Knight, Oct 6, 2020
- ↑ developers.google.com: Grundlagen von JavaScript-SEO
History API anstelle von Fragmenten verwenden - ↑ Intelligent State Handling