Natives Modal mit <dialog>
Modale Fenster aus dem nativen <dialog>: showModal(), closedby="any" mit Klick-Fallback, ::backdrop und Ein- und Ausblenden per @starting-style, als Bildansicht, Bestätigung und Formular mit method="dialog".
Projektlisten, Galerien und Referenzen bei Architektur, Fotografie, Handwerk und Immobilien: großes Bild mit Bildunterschrift und Blättern.
- Modale Fenster entstehen aus dem HTML-Element <dialog> und öffnen per showModal(). Der Browser legt sie in die oberste Ebene, macht den Rest der Seite inert, schließt mit Esc und bringt den Fokus beim Schließen zum Auslöser zurück.
- closedby="any" erlaubt zusätzlich das Schließen per Klick auf den abgedunkelten Hintergrund; wo der Browser das nicht kennt, übernimmt ein kleiner Klick-Fallback.
- Ein- und Ausblenden laufen in CSS: @starting-style für den Startzustand, transition-behavior: allow-discrete für display und overlay, ::backdrop blendet mit.
- Formulare im Dialog nutzen method="dialog": Absenden schließt und liefert den Wert des Knopfs als returnValue.
Fokusfalle, Esc, inerter Hintergrund und Rückgabe des Fokus sind die Stellen, an denen selbst gebaute Modals scheitern. Das native Element bringt sie mit. Was bleibt, ist Gestaltung: ruhiges Ein- und Ausblenden, ein Hintergrund, der die Seite nicht verschluckt, und klare Knöpfe.
Wann einsetzen
Bestätigung vor einer folgenreichen Aktion, Bildansicht in Galerien und Projektlisten, kurze Formulare (Anfrage, Rückruf, Newsletter), Hinweise, die eine Entscheidung brauchen.
Wann nicht
Für Inhalte, die man neben der Seite lesen will (dann Seitenpanel ohne Modal oder eigene Seite). Für Menüs, Tooltips und Auswahllisten (dafür popover). Für lange Formulare mit mehreren Schritten (eigene Seite). Nicht beim Laden der Seite öffnen, ohne dass jemand es ausgelöst hat.
Varianten
- Bildansicht: großes Bild mit Bildunterschrift „Titel • Ort • Jahr“, Blättern per Knopf und Pfeiltasten, Fokus auf Schließen
- Bestätigung: kurzer Text, zwei Knöpfe in <form method="dialog">, Fokus auf der sicheren Wahl (Abbrechen)
- Formular: <form method="dialog">, Ergebnis über returnValue, Abbrechen als type="button"
- Auf dem Telefon als Blatt von unten (gleicher Code, andere Position und Startversatz)
Umsetzung
Auslöser: <button type="button"> mit dialog.showModal() (oder deklarativ commandfor="d" command="show-modal", wo unterstützt). <dialog id="d" closedby="any" aria-labelledby="d-titel">. CSS: dialog { opacity: 0; translate: 0 12px; transition: opacity .2s, translate .2s, overlay .2s allow-discrete, display .2s allow-discrete } dialog[open] { opacity: 1; translate: 0; transition-duration: .3s } @starting-style { dialog[open] { opacity: 0; translate: 0 12px } }, ::backdrop genauso über background-color. Die @starting-style-Regel steht nach der [open]-Regel. Fallback ohne closedby: Klick auf das dialog-Element außerhalb seines Rechtecks ruft close(). Fokus: autofocus auf das erste sinnvolle Element (Abbrechen, erstes Feld, Schließen); im close-Ereignis zum Auslöser zurück, falls der Browser das nicht schon getan hat. Formular: <form method="dialog">, Absenden mit value, Ergebnis im close-Ereignis über returnValue und FormData. Achtung: Enter in einem Feld löst den ersten Submit-Knopf im Formular aus. Abbrechen und × darum als type="button" mit close() bauen oder hinter den Absende-Knopf stellen. Seite dahinter: html:has(dialog:modal) { overflow: hidden }. Für Vorschauen in iframes nicht automatisch showModal() aufrufen: Das holt den Fokus in den Rahmen.
Robust umsetzen
Telefon. Bildansicht füllt den Bildschirm, Formular und Bestätigung kommen als Blatt von unten mit großen Knöpfen. Schließen-Knopf immer sichtbar, nicht nur Wischen oder Hintergrund-Tippen. Light-Dismiss: Seit Chrome 154 schließen Dialog und Popover erst beim click, nicht mehr bei pointerdown. Eine Wischgeste zum Scrollen oder ein Rechtsklick auf den Hintergrund schließt den Dialog dort nicht mehr; in älteren Versionen konnte das schon passieren. Den eigenen Fallback ohne closedby deshalb ebenfalls auf click legen, nicht auf pointerdown (Chrome-154-Versionshinweise).
Ohne Bewegung. Kein Versatz, nur kurzes Einblenden der Deckkraft (oder gar keine Transition). Funktion und Fokusführung bleiben gleich.
Barrierefrei. Rolle und Modalität kommen vom Element: showModal() setzt den Rest der Seite inert, Esc schließt (WCAG 2.1.2 No Keyboard Trap ist erfüllt, weil Esc und ein Schließen-Knopf herausführen). 4.1.2 Name, Role, Value: aria-labelledby auf die Überschrift, optional aria-describedby auf den Text. 2.4.3 Focus Order: Fokus beim Öffnen auf das erste sinnvolle Element (autofocus), beim Schließen zurück zum Auslöser. 2.4.7 Focus Visible und 2.4.11 Focus Not Obscured (Minimum): Fokus im Dialog deutlich, nichts liegt darüber. 2.5.8 Target Size: Knöpfe mindestens 24 px, besser 44 px. 1.4.11 Non-text Contrast: Dialogrand oder Hintergrund hebt das Fenster mit 3:1 ab. 3.3.1 und 3.3.2 im Formular: Beschriftungen sichtbar, Fehler als Text am Feld, aria-invalid. Hintergrund-Klick ist Zusatz, nie der einzige Weg zum Schließen.
Code
<!DOCTYPE html>
<!--
Natives Modal mit <dialog>
Fundus · Zugang & Robustheit · https://cecodesign.de/lab/fundus/zugang/dialog-modal-nativ/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Natives Modal mit <dialog> · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; font-style: italic; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept architektur-ruhiges-raster: achromatisch, ein Interaktionston */
--grund: #ffffff; --flaeche: #e1e3e3; --text: #231f20; --meta: #76767a; --meta-dunkel: #5f6063; --dunkel: #1f2323; --linie: #d4d6d6;
--fokus: #0022ff; --fehler: #b3261e;
--auf: cubic-bezier(0.1, 0.9, 0.3, 1); /* Öffnen, Ausklang (KAAN) */
--zu: cubic-bezier(0.1, 0.6, 0.4, 1); /* Schließen, Default (KAAN, Chipperfield) */
--dauer-auf: 300ms; --dauer-zu: 200ms; --ui: 250ms;
--versatz: 12px;
scrollbar-gutter: stable;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font: 400 1.0625rem/1.45 'Hanken Grotesk', system-ui, sans-serif; letter-spacing: -.012em; -webkit-font-smoothing: antialiased; }
html:has(dialog:modal) { overflow: hidden; }
body { min-height: 100svh; display: flex; flex-direction: column; overflow-x: hidden; }
button, input, select, textarea { font: inherit; color: inherit; letter-spacing: inherit; }
a { color: inherit; text-underline-offset: .35ex; text-decoration-thickness: 8%; }
.meta { font-size: .8rem; line-height: 1.4; color: var(--meta); }
.mono { font: 500 10.5px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.vh { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Fokus: ein Interaktionston, 2 px, Versatz 3 px */
:focus-visible, [data-fokus-sim] { outline: 2px solid var(--fokus); outline-offset: 3px; }
/* Kopf */
.kopf { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; padding: 22px clamp(18px, 4vw, 56px); }
.marke { font-weight: 500; text-decoration: none; font-size: 1.0625rem; }
.marke i { font-style: italic; font-weight: 400; color: var(--meta-dunkel); }
.nav { display: flex; gap: clamp(14px, 2.2vw, 30px); list-style: none; }
.nav a { text-decoration: none; padding: 4px 0; }
.nav a[aria-current] { text-decoration: underline; }
main { flex: 1; padding: clamp(20px, 4vw, 56px) clamp(18px, 4vw, 56px) 120px; }
.buehne[hidden] { display: none; }
.titelzeile { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 24px; margin-bottom: clamp(22px, 3vw, 40px); }
h1 { font-size: clamp(1.953rem, 1.2rem + 2.4vw, 3.052rem); font-weight: 450; line-height: 1.04; letter-spacing: -.02em; }
h1 em, h2 em { font-weight: 400; }
.lead { max-width: 50ch; color: var(--meta-dunkel); margin-top: 12px; text-wrap: pretty; }
/* Variante Bildansicht: Projektraster */
.raster { display: grid; grid-template-columns: 1.25fr 1fr 1fr; gap: clamp(14px, 2vw, 28px); list-style: none; align-items: start; }
.raster li { display: grid; gap: 10px; }
.bild-knopf { display: block; width: 100%; border: 0; padding: 0; background: var(--flaeche); cursor: zoom-in; overflow: hidden; }
.bild-knopf img { display: block; width: 100%; height: auto; aspect-ratio: 40 / 27; object-fit: cover; transition: opacity var(--ui) var(--zu); }
.bild-knopf:hover img { opacity: .9; }
.raster figcaption { display: flex; justify-content: space-between; gap: 12px; }
.raster figcaption span:first-child { color: var(--text); }
/* Variante Bestätigung: Anfahrt mit Karte nach Zustimmung */
.anfahrt { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.4fr); gap: clamp(20px, 4vw, 56px); align-items: start; }
.adresse { display: grid; gap: 18px; }
.adresse dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 18px; border-top: 1px solid var(--linie); padding-top: 16px; }
.adresse dt { color: var(--meta); font-size: .8rem; padding-top: 2px; }
.karte { position: relative; aspect-ratio: 16 / 10; background: var(--flaeche); overflow: hidden; }
.karte svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.karte .platzhalter { transition: opacity var(--dauer-auf) var(--auf); }
.karte .geladen { opacity: 0; transition: opacity var(--dauer-auf) var(--auf); }
.karte[data-geladen] .geladen { opacity: 1; }
.karte[data-geladen] .platzhalter, .karte[data-geladen] .karte-hinweis { opacity: 0; visibility: hidden; }
.karte-hinweis { position: absolute; left: 50%; top: 50%; translate: -50% -50%; width: min(86%, 380px); background: var(--grund); padding: 20px 22px; display: grid; gap: 12px; justify-items: start; transition: opacity var(--ui) var(--zu), visibility 0s linear var(--ui); }
.karte-hinweis p { font-size: .95rem; color: var(--meta-dunkel); }
.karte-fuss { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; }
.karte-fuss button { background: none; border: 0; text-decoration: underline; text-underline-offset: .35ex; cursor: pointer; color: var(--meta-dunkel); font-size: .8rem; min-height: 24px; }
/* Variante Formular: Kontakt als Visitenkarte */
.kontakt { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: clamp(24px, 5vw, 80px); align-items: end; }
.kontakt h1 { max-width: 18ch; }
.visitenkarte { background: var(--flaeche); padding: 24px 26px; display: grid; gap: 16px; }
.visitenkarte address { font-style: normal; }
.visitenkarte .meta { color: var(--meta-dunkel); }
.danke { min-height: 1.45em; color: var(--text); }
.danke:not(:empty)::before { content: ''; display: inline-block; width: 18px; height: 1px; background: var(--fokus); vertical-align: middle; margin-right: 10px; }
/* Knöpfe */
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 44px; padding: 0 20px; border: 1px solid var(--text); background: var(--text); color: var(--grund); cursor: pointer; font-weight: 500; transition: background-color var(--ui) var(--zu), color var(--ui) var(--zu), border-color var(--ui) var(--zu); }
.knopf:hover { background: var(--dunkel); }
.knopf.leise { background: transparent; color: var(--text); }
.knopf.leise:hover { background: var(--flaeche); }
.knopf.gedrueckt { background: var(--fokus); border-color: var(--fokus); color: #fff; }
/* ---------- Der Dialog ---------- */
dialog {
margin: auto; border: 0; padding: 0; color: var(--text); background: var(--grund);
width: min(560px, calc(100vw - 32px)); max-height: calc(100svh - 48px); overflow: auto;
opacity: 0; translate: 0 var(--versatz);
transition: opacity var(--dauer-zu) var(--zu), translate var(--dauer-zu) var(--zu),
overlay var(--dauer-zu) var(--zu) allow-discrete, display var(--dauer-zu) var(--zu) allow-discrete;
}
dialog[open] { opacity: 1; translate: 0 0; transition-duration: var(--dauer-auf); transition-timing-function: var(--auf); }
@starting-style { dialog[open] { opacity: 0; translate: 0 var(--versatz); } }
dialog::backdrop {
background-color: rgb(31 35 35 / 0);
transition: background-color var(--dauer-zu) var(--zu), overlay var(--dauer-zu) var(--zu) allow-discrete, display var(--dauer-zu) var(--zu) allow-discrete;
}
dialog[open]::backdrop { background-color: rgb(31 35 35 / .56); transition-duration: var(--dauer-auf); transition-timing-function: var(--auf); }
@starting-style { dialog[open]::backdrop { background-color: rgb(31 35 35 / 0); } }
/* Vorführung ohne Fokusübernahme: offen per Attribut, nachgebildeter Hintergrund */
dialog[data-sim] { position: fixed; inset: 0; z-index: 50; }
.sim-hintergrund { position: fixed; inset: 0; z-index: 49; background: rgb(31 35 35 / .56); opacity: 0; pointer-events: none; transition: opacity var(--dauer-zu) var(--zu); }
.sim-hintergrund.an { opacity: 1; transition-duration: var(--dauer-auf); transition-timing-function: var(--auf); }
.sim-hintergrund.bild { background: rgb(18 20 20 / .92); }
.dlg-kopf { display: flex; justify-content: space-between; align-items: start; gap: 16px; padding: 22px 24px 0; }
.dlg-kopf h2 { font-size: 1.563rem; font-weight: 450; line-height: 1.15; letter-spacing: -.02em; }
.dlg-text { padding: 12px 24px 0; color: var(--meta-dunkel); max-width: 52ch; }
.dlg-fuss { display: flex; justify-content: flex-end; gap: 10px; padding: 24px; flex-wrap: wrap; }
.schliessen { width: 44px; height: 44px; flex: none; border: 0; background: transparent; cursor: pointer; display: grid; place-items: center; margin: -10px -12px 0 0; transition: background-color var(--ui) var(--zu); }
.schliessen:hover { background: var(--flaeche); }
.schliessen svg { width: 18px; height: 18px; }
/* Bildansicht */
dialog.bildansicht { width: min(1240px, calc(100vw - 48px)); background: transparent; color: #fff; overflow: visible; }
dialog.bildansicht[open]::backdrop { background-color: rgb(18 20 20 / .92); }
.bildansicht figure { display: grid; gap: 14px; }
.bildansicht .rahmen { position: relative; background: #2a2e2e; }
.bildansicht img { display: block; width: 100%; height: auto; max-height: calc(100svh - 170px); object-fit: contain; aspect-ratio: 40 / 27; transition: opacity var(--ui) var(--zu); }
.bildansicht img.wechsel { opacity: 0; }
.bildansicht figcaption { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: center; }
.bildansicht figcaption .titel { font-size: 1.0625rem; font-weight: 450; }
.bildansicht figcaption .meta { color: #b9bdbd; }
.blaettern { display: flex; gap: 8px; align-items: center; }
.blaettern .zahl { color: #b9bdbd; margin-right: 8px; font-variant-numeric: tabular-nums; }
.rund { width: 44px; height: 44px; border: 1px solid rgb(255 255 255 / .5); background: transparent; color: #fff; cursor: pointer; display: grid; place-items: center; transition: background-color var(--ui) var(--zu), border-color var(--ui) var(--zu); }
.rund:hover { background: rgb(255 255 255 / .1); border-color: #fff; }
.rund svg { width: 16px; height: 16px; }
.bildansicht .schliessen { position: absolute; top: -54px; right: 0; margin: 0; color: #fff; border: 1px solid rgb(255 255 255 / .5); }
.bildansicht .schliessen:hover { background: rgb(255 255 255 / .1); }
.bildansicht :focus-visible, .bildansicht [data-fokus-sim] { outline-color: #8fa3ff; }
/* Formular im Dialog */
.felder { display: grid; gap: 16px; padding: 18px 24px 0; }
.feld { display: grid; gap: 6px; }
.feld label { font-size: .875rem; font-weight: 500; display: flex; justify-content: space-between; gap: 12px; }
.feld label span { font-weight: 400; color: var(--meta-dunkel); }
.feld input, .feld select, .feld textarea { width: 100%; border: 1px solid #8a8c8c; background: var(--grund); padding: 11px 12px; min-height: 46px; border-radius: 0; transition: border-color var(--ui) var(--zu); }
.feld textarea { min-height: 96px; resize: vertical; field-sizing: content; max-height: 220px; }
.feld input:focus-visible, .feld select:focus-visible, .feld textarea:focus-visible, .feld [data-fokus-sim] { outline-offset: 0; border-color: var(--fokus); }
.feld [aria-invalid="true"] { border-color: var(--fehler); border-width: 2px; padding: 10px 11px; }
.fehlertext { color: var(--fehler); font-size: .875rem; display: none; }
.feld:has([aria-invalid="true"]) .fehlertext { display: block; }
.zwei { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* Bedienpult der Demo */
.pult { position: fixed; left: 16px; bottom: 16px; z-index: 30; background: rgb(255 255 255 / .94); border: 1px solid var(--linie); padding: 10px 12px; display: grid; gap: 8px; max-width: min(560px, calc(100vw - 32px)); }
.pult-zeile { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.pult .mono { color: var(--meta-dunkel); }
.umschalter { display: flex; border: 1px solid var(--linie); }
.umschalter button { border: 0; background: transparent; padding: 8px 11px; cursor: pointer; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--meta-dunkel); min-height: 32px; }
.umschalter button + button { border-left: 1px solid var(--linie); }
.umschalter button[aria-pressed="true"] { background: var(--text); color: var(--grund); }
.werte { font: 400 10.5px/1.5 'Geist Mono', ui-monospace, monospace; color: var(--meta-dunkel); letter-spacing: .02em; }
.werte b { color: var(--text); font-weight: 500; }
/* Gedachter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 60; width: 20px; height: 20px; margin: -10px 0 0 -10px; border-radius: 50%; border: 2px solid var(--fokus); background: rgb(255 255 255 / .55); pointer-events: none; opacity: 0; transition: translate 650ms var(--auf), opacity 200ms var(--zu), scale 150ms var(--zu); }
.zeiger.an { opacity: 1; }
.zeiger.klick { scale: .65; }
/* Reduzierte Bewegung: kein Versatz, nur kurzes Einblenden */
:root.reduziert { --versatz: 0px; --dauer-auf: 120ms; --dauer-zu: 80ms; }
:root.reduziert .bildansicht img { transition: none; }
:root.reduziert .zeiger { transition: opacity 120ms linear; }
@media (hover: none) {
.bild-knopf:hover img { opacity: 1; }
.knopf:hover { background: var(--text); }
.knopf.leise:hover, .schliessen:hover { background: transparent; }
}
@media (max-width: 900px) {
/* Navigation bleibt sichtbar: zweite Zeile statt versteckt */
.kopf { flex-wrap: wrap; row-gap: 6px; }
.kopf nav { flex-basis: 100%; }
.nav { flex-wrap: wrap; gap: 0 18px; }
.nav a { display: inline-block; padding: 10px 0; }
.raster { grid-template-columns: 1fr 1fr; }
.raster li:first-child { grid-column: 1 / -1; }
.anfahrt, .kontakt { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.titelzeile { grid-template-columns: 1fr; }
.raster { grid-template-columns: 1fr; }
/* Formular und Bestätigung als Blatt von unten */
dialog:not(.bildansicht) { width: 100%; max-width: none; margin: auto 0 0; max-height: 92svh; --versatz: 32px; }
:root.reduziert dialog:not(.bildansicht) { --versatz: 0px; }
dialog:not(.bildansicht) .dlg-fuss { position: sticky; bottom: 0; background: var(--grund); border-top: 1px solid var(--linie); padding: 14px 18px; }
dialog:not(.bildansicht) .dlg-fuss .knopf { flex: 1; }
.zwei { grid-template-columns: 1fr; }
dialog.bildansicht { width: calc(100vw - 24px); }
.bildansicht figcaption { grid-template-columns: 1fr; }
.bildansicht .schliessen { top: -52px; }
}
/* Vorschau 960 × 720 */
.vorschau body { overflow: hidden; }
html.vorschau { scrollbar-gutter: auto; overflow: hidden; }
.vorschau main { padding-top: 12px; }
.vorschau .lead { display: none; }
.vorschau dialog.bildansicht { width: 720px; }
.vorschau .bildansicht img { max-height: 470px; }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#">Marktgasse <i>Architekten</i></a>
<nav aria-label="Hauptnavigation">
<ul class="nav">
<li><a href="#">Büro</a></li>
<li><a href="#" data-nav="bild">Projekte</a></li>
<li><a href="#">Team</a></li>
<li><a href="#">Aktuelles</a></li>
<li><a href="#" data-nav="kontakt">Kontakt</a></li>
</ul>
</nav>
</header>
<main>
<!-- Variante Bildansicht -->
<section class="buehne" data-buehne="bild" aria-labelledby="t-bild">
<div class="titelzeile">
<div>
<h1 id="t-bild">Projekte <em>in Vorarlberg</em></h1>
<p class="lead">Wohnhäuser, Schulen und kleine Werkstätten, meist in Holz und Beton. Ein Klick auf ein Bild öffnet die Ansicht.</p>
</div>
<p class="meta">3 von 28 · Wohnen, Bildung, Kultur</p>
</div>
<ul class="raster">
<li><figure>
<button class="bild-knopf" type="button" data-bild="0" aria-label="Haus am Ardetzenberg, Bild vergrößern"><img src="bilder/dialog-modal-nativ-haus.jpg" alt="" width="1600" height="1080"></button>
<figcaption class="meta"><span>Haus am Ardetzenberg</span><span>Feldkirch • 2025</span></figcaption>
</figure></li>
<li><figure>
<button class="bild-knopf" type="button" data-bild="1" aria-label="Volksschule Gisingen, Bild vergrößern"><img src="bilder/dialog-modal-nativ-schule.jpg" alt="" width="1600" height="1080"></button>
<figcaption class="meta"><span>Volksschule Gisingen</span><span>Feldkirch • 2024</span></figcaption>
</figure></li>
<li><figure>
<button class="bild-knopf" type="button" data-bild="2" aria-label="Atelierturm an der Ill, Bild vergrößern"><img src="bilder/dialog-modal-nativ-turm.jpg" alt="" width="1600" height="1080"></button>
<figcaption class="meta"><span>Atelierturm an der Ill</span><span>Feldkirch • 2026</span></figcaption>
</figure></li>
</ul>
</section>
<!-- Variante Bestätigung -->
<section class="buehne" data-buehne="bestaetigung" aria-labelledby="t-anfahrt" hidden>
<div class="anfahrt">
<div class="adresse">
<h1 id="t-anfahrt">Anfahrt <em>zum Büro</em></h1>
<p class="lead">Zwei Gassen hinter dem Domplatz, im zweiten Stock über der Buchbinderei.</p>
<dl>
<dt>Adresse</dt><dd>Marktgasse 14, 6800 Feldkirch</dd>
<dt>Bahn</dt><dd>Feldkirch Bahnhof, 8 Minuten zu Fuß</dd>
<dt>Auto</dt><dd>Tiefgarage Neustadt, dann 3 Minuten</dd>
<dt>Zugang</dt><dd>Aufzug im Hof, stufenlos</dd>
</dl>
</div>
<div>
<div class="karte" data-karte>
<svg viewBox="0 0 640 400" aria-hidden="true" class="platzhalter">
<rect width="640" height="400" fill="#e1e3e3"/>
<g fill="none" stroke="#c9cccc" stroke-width="10" stroke-linecap="round"><path d="M-20 300 C140 270 230 330 380 290 S560 220 680 240"/><path d="M120 -20 L180 420"/><path d="M-20 140 L660 110"/><path d="M420 -20 L470 420"/></g>
<path d="M-20 360 C120 330 220 380 330 350 S520 300 680 330" fill="none" stroke="#d3d6d6" stroke-width="34"/>
</svg>
<svg viewBox="0 0 640 400" class="geladen" role="img" aria-label="Stadtplan Feldkirch Altstadt mit Markierung Marktgasse 14">
<rect width="640" height="400" fill="#f1f2f2"/>
<path d="M-20 360 C120 330 220 380 330 350 S520 300 680 330" fill="none" stroke="#b9c9d6" stroke-width="34"/>
<text x="560" y="352" font-size="11" fill="#5f6063" font-family="Geist Mono, monospace" letter-spacing="1.5">ILL</text>
<g fill="#e1e3e3"><rect x="200" y="150" width="70" height="52"/><rect x="282" y="150" width="58" height="52"/><rect x="200" y="214" width="140" height="44"/><rect x="352" y="140" width="62" height="118"/><rect x="60" y="160" width="100" height="90"/><rect x="430" y="132" width="120" height="66"/><rect x="430" y="210" width="90" height="56"/></g>
<g fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round"><path d="M-20 300 C140 270 230 330 380 290 S560 220 680 240"/><path d="M180 -20 L190 420"/><path d="M-20 130 L660 112"/><path d="M420 -20 L424 420"/><path d="M190 208 L420 204"/></g>
<g fill="none" stroke="#231f20" stroke-width="1"><path d="M-20 300 C140 270 230 330 380 290 S560 220 680 240" stroke-opacity=".18"/></g>
<text x="234" y="198" font-size="10.5" fill="#5f6063" font-family="Geist Mono, monospace" letter-spacing="1.2">MARKTGASSE</text>
<text x="440" y="124" font-size="10.5" fill="#5f6063" font-family="Geist Mono, monospace" letter-spacing="1.2">DOMPLATZ</text>
<circle cx="300" cy="208" r="22" fill="#0022ff" fill-opacity=".14"/><circle cx="300" cy="208" r="7" fill="#0022ff"/>
<text x="314" y="236" font-size="13" fill="#231f20" font-family="Hanken Grotesk, sans-serif">Marktgasse 14</text>
</svg>
<div class="karte-hinweis">
<p>Die Karte kommt von einem externen Kartendienst. Sie wird erst nach Ihrer Zustimmung geladen.</p>
<button class="knopf" type="button" data-oeffne="bestaetigung">Karte anzeigen</button>
</div>
</div>
<p class="karte-fuss meta"><span>Karte: externer Dienst, nach Zustimmung</span><button type="button" data-karte-zurueck hidden>Karte wieder ausblenden</button></p>
</div>
</div>
</section>
<!-- Variante Formular -->
<section class="buehne" data-buehne="formular" aria-labelledby="t-kontakt" hidden>
<div class="kontakt">
<div>
<h1 id="t-kontakt">Ein Haus, ein Umbau, <em>eine Schule?</em> Erzählen Sie uns davon.</h1>
<p class="lead">Wir antworten innerhalb von drei Werktagen und schlagen ein erstes Gespräch vor, im Büro oder auf dem Grundstück.</p>
</div>
<div class="visitenkarte">
<address>Marktgasse Architekten ZT GmbH<br>Marktgasse 14, 6800 Feldkirch<br><a href="#">+43 5522 31 84 0</a><br><a href="#">buero@marktgasse-architekten.at</a></address>
<p class="meta">Mitglied der Ziviltechnikerkammer für Tirol und Vorarlberg</p>
<p><button class="knopf" type="button" data-oeffne="formular">Projekt anfragen</button></p>
<p class="danke" role="status" data-danke></p>
</div>
</div>
</section>
</main>
<!-- Dialog 1: Bildansicht -->
<dialog class="bildansicht" id="dlg-bild" closedby="any" aria-labelledby="bild-titel" aria-describedby="bild-text">
<figure>
<div class="rahmen">
<img data-gross src="bilder/dialog-modal-nativ-haus.jpg" alt="" width="1600" height="1080">
<button class="schliessen" type="button" data-schliessen autofocus aria-label="Bildansicht schließen"><svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 3l12 12M15 3 3 15" stroke="currentColor" stroke-width="1.5"/></svg></button>
</div>
<figcaption>
<div aria-live="polite">
<h2 class="titel" id="bild-titel" data-bild-titel>Haus am Ardetzenberg • Feldkirch • 2025</h2>
<p class="meta" id="bild-text" data-bild-text>Einfamilienhaus am Hang, Lärche unbehandelt, 164 m²</p>
</div>
<div class="blaettern">
<span class="zahl mono" data-bild-zahl aria-hidden="true">1 / 3</span>
<button class="rund" type="button" data-bild-zurueck aria-label="Vorheriges Projekt"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 2 4 8l6 6" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
<button class="rund" type="button" data-bild-weiter aria-label="Nächstes Projekt"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 2 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
</div>
</figcaption>
</figure>
</dialog>
<!-- Dialog 2: Bestätigung -->
<dialog id="dlg-bestaetigung" role="alertdialog" closedby="any" aria-labelledby="best-titel" aria-describedby="best-text">
<form method="dialog">
<div class="dlg-kopf"><h2 id="best-titel">Karte von einem externen Dienst laden?</h2></div>
<p class="dlg-text" id="best-text">Dabei überträgt Ihr Browser Ihre IP-Adresse an den Kartendienst. Ohne Karte finden Sie die Adresse links. Mehr dazu in der Datenschutzerklärung.</p>
<div class="dlg-fuss">
<button class="knopf leise" type="submit" value="abbrechen" autofocus>Abbrechen</button>
<button class="knopf" type="submit" value="laden">Karte laden</button>
</div>
</form>
</dialog>
<!-- Dialog 3: Formular -->
<dialog id="dlg-formular" closedby="any" aria-labelledby="form-titel">
<form method="dialog" novalidate data-anfrage>
<div class="dlg-kopf">
<h2 id="form-titel">Projekt anfragen</h2>
<button class="schliessen" type="button" data-abbrechen aria-label="Anfrage schließen"><svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 3l12 12M15 3 3 15" stroke="currentColor" stroke-width="1.5"/></svg></button>
</div>
<p class="dlg-text">Drei Angaben genügen. Pläne können Sie später schicken.</p>
<div class="felder">
<div class="zwei">
<div class="feld">
<label for="f-name">Name</label>
<input id="f-name" name="name" autocomplete="name" required autofocus aria-describedby="e-name" aria-invalid="false">
<p class="fehlertext" id="e-name">Bitte geben Sie Ihren Namen an.</p>
</div>
<div class="feld">
<label for="f-mail">E-Mail</label>
<input id="f-mail" name="email" type="email" autocomplete="email" required aria-describedby="e-mail" aria-invalid="false">
<p class="fehlertext" id="e-mail">Bitte eine E-Mail-Adresse wie name@beispiel.at.</p>
</div>
</div>
<div class="zwei">
<div class="feld">
<label for="f-art">Vorhaben</label>
<select id="f-art" name="vorhaben"><option>Neubau</option><option>Umbau</option><option>Sanierung</option><option>Wettbewerb</option></select>
</div>
<div class="feld">
<label for="f-ort">Ort <span>optional</span></label>
<input id="f-ort" name="ort" autocomplete="address-level2">
</div>
</div>
<div class="feld">
<label for="f-text">Worum geht es? <span>optional</span></label>
<textarea id="f-text" name="nachricht" rows="3"></textarea>
</div>
</div>
<div class="dlg-fuss">
<button class="knopf leise" type="button" data-abbrechen>Abbrechen</button>
<button class="knopf" type="submit" value="senden">Anfrage senden</button>
</div>
</form>
</dialog>
<div class="sim-hintergrund" aria-hidden="true" data-sim-hintergrund></div>
<span class="zeiger" aria-hidden="true" data-zeiger></span>
<div class="pult">
<div class="pult-zeile">
<span class="mono">Variante</span>
<div class="umschalter" role="group" aria-label="Variante wählen">
<button type="button" data-v="bild" aria-pressed="true">Bildansicht</button>
<button type="button" data-v="bestaetigung" aria-pressed="false">Bestätigung</button>
<button type="button" data-v="formular" aria-pressed="false">Formular</button>
</div>
</div>
<p class="werte" data-werte></p>
</div>
<script>
(() => {
const html = document.documentElement;
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const reduziert = () => html.classList.contains('reduziert');
const VARIANTEN = ['bild', 'bestaetigung', 'formular'];
const closedbyNativ = 'closedBy' in HTMLDialogElement.prototype;
const dialoge = { bild: $('#dlg-bild'), bestaetigung: $('#dlg-bestaetigung'), formular: $('#dlg-formular') };
let ausloeser = null, vorfuehrung = false;
/* ---------- Öffnen und Schließen ---------- */
function oeffne(name, knopf) {
const d = dialoge[name];
ausloeser = knopf || document.activeElement;
if (vorfuehrung) { // ohne Fokusübernahme (Vorschau im iframe)
d.setAttribute('data-sim', ''); d.setAttribute('open', '');
simHintergrund.classList.toggle('bild', name === 'bild'); simHintergrund.classList.add('an');
const ziel = $('[autofocus]', d); if (ziel) ziel.setAttribute('data-fokus-sim', '');
return;
}
d.showModal(); // oberste Ebene, Rest inert, Esc, Fokus auf [autofocus]
}
Object.entries(dialoge).forEach(([name, d]) => {
// Fallback für Browser ohne closedby: Klick auf den Hintergrund
if (!closedbyNativ) d.addEventListener('click', e => {
if (e.target !== d) return;
const r = d.getBoundingClientRect();
const drin = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
if (!drin) d.close('hintergrund');
});
d.addEventListener('close', () => {
simHintergrund.classList.remove('an'); d.removeAttribute('data-sim');
$$('[data-fokus-sim]', d).forEach(e => e.removeAttribute('data-fokus-sim'));
auswerten(name, d.returnValue);
d.returnValue = '';
// Fokus zurück zum Auslöser (Browser tun das meist selbst; hier sicher)
if (!vorfuehrung && ausloeser && document.contains(ausloeser) && (document.activeElement === document.body || d.contains(document.activeElement))) ausloeser.focus();
});
});
const simHintergrund = $('[data-sim-hintergrund]');
simHintergrund.addEventListener('click', () => Object.values(dialoge).forEach(d => d.open && d.close('hintergrund')));
$$('[data-oeffne]').forEach(b => b.addEventListener('click', () => oeffne(b.dataset.oeffne, b)));
/* ---------- Bildansicht ---------- */
const BILDER = [
{ src: 'bilder/dialog-modal-nativ-haus.jpg', titel: 'Haus am Ardetzenberg • Feldkirch • 2025', text: 'Einfamilienhaus am Hang, Lärche unbehandelt, 164 m²', alt: 'Holzhaus mit senkrechter grauer Lärchenschalung und zwei großen Fenstern auf einer Wiese am Hang, dahinter Berge unter bedecktem Himmel.' },
{ src: 'bilder/dialog-modal-nativ-schule.jpg', titel: 'Volksschule Gisingen • Feldkirch • 2024', text: 'Acht Klassen, Sichtbeton mit Fensterbändern, Neubau', alt: 'Zweigeschossige Schule aus hellem Sichtbeton mit zwei langen Fensterbändern, davor ein Platz und Bäume.' },
{ src: 'bilder/dialog-modal-nativ-turm.jpg', titel: 'Atelierturm an der Ill • Feldkirch • 2026', text: 'Drei Ateliers übereinander, Fichte geflammt, im Bau', alt: 'Schmaler Holzturm am Fluss in der Abenddämmerung, drei erleuchtete Fenster spiegeln sich im Wasser.' }
];
const gross = $('[data-gross]');
let bildNr = 0;
function zeigeBild(i, sofort) {
bildNr = (i + BILDER.length) % BILDER.length;
const b = BILDER[bildNr];
const setzen = () => { gross.src = b.src; gross.alt = b.alt; gross.classList.remove('wechsel'); };
$('[data-bild-titel]').textContent = b.titel; $('[data-bild-text]').textContent = b.text;
$('[data-bild-zahl]').textContent = `${bildNr + 1} / ${BILDER.length}`;
if (sofort || reduziert()) setzen(); else { gross.classList.add('wechsel'); setTimeout(setzen, 150); }
}
$$('[data-bild]').forEach(k => {
const i = +k.dataset.bild; k.querySelector('img').alt = BILDER[i].alt;
k.addEventListener('click', () => { zeigeBild(i, true); oeffne('bild', k); });
});
$('[data-bild-weiter]').addEventListener('click', () => zeigeBild(bildNr + 1));
$('[data-bild-zurueck]').addEventListener('click', () => zeigeBild(bildNr - 1));
$('[data-schliessen]').addEventListener('click', () => dialoge.bild.close());
dialoge.bild.addEventListener('keydown', e => {
if (e.key === 'ArrowRight') { e.preventDefault(); zeigeBild(bildNr + 1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); zeigeBild(bildNr - 1); }
});
/* ---------- Bestätigung: Karte nach Zustimmung ---------- */
const karte = $('[data-karte]'), karteZurueck = $('[data-karte-zurueck]');
function karteZeigen(an) {
karte.toggleAttribute('data-geladen', an); karteZurueck.hidden = !an;
$('.karte-hinweis', karte).inert = an;
}
karteZurueck.addEventListener('click', () => { karteZeigen(false); $('[data-oeffne="bestaetigung"]').focus(); });
/* ---------- Formular mit method="dialog" ---------- */
const anfrage = $('[data-anfrage]'), danke = $('[data-danke]');
const pflicht = $$('input[required]', anfrage);
function pruefe(feld) { const ok = feld.checkValidity() && feld.value.trim() !== ''; feld.setAttribute('aria-invalid', String(!ok)); return ok; }
anfrage.addEventListener('submit', e => {
const falsch = pflicht.filter(f => !pruefe(f));
if (falsch.length) { e.preventDefault(); if (!vorfuehrung) falsch[0].focus(); }
});
// Abbrechen ist type="button": Enter in einem Feld löst sonst den ersten Submit-Knopf aus
$$('[data-abbrechen]', anfrage).forEach(b => b.addEventListener('click', () => dialoge.formular.close('abbrechen')));
pflicht.forEach(f => {
f.addEventListener('blur', () => { if (f.value) pruefe(f); });
f.addEventListener('input', () => { if (f.getAttribute('aria-invalid') === 'true') pruefe(f); });
});
function auswerten(name, wert) {
if (name === 'bestaetigung' && wert === 'laden') karteZeigen(true);
if (name === 'formular') {
if (wert === 'senden') {
const daten = new FormData(anfrage);
const vorname = String(daten.get('name')).trim().split(/\s+/)[0];
danke.textContent = `Danke, ${vorname}. Ihre Anfrage zum ${String(daten.get('vorhaben')).toLowerCase()} ist angekommen. Wir melden uns innerhalb von drei Werktagen.`;
anfrage.reset();
}
pflicht.forEach(f => f.setAttribute('aria-invalid', 'false'));
}
}
/* ---------- Varianten (?v=) ---------- */
const WERTE = {
gemeinsam: 'Öffnen <b>300 ms</b> cubic-bezier(0.1, 0.9, 0.3, 1) · Schließen <b>200 ms</b> cubic-bezier(0.1, 0.6, 0.4, 1) · Versatz <b>12 px</b> · closedby: <b>' + (closedbyNativ ? 'nativ' : 'Fallback per Klick') + '</b>',
bild: ' · Hintergrund <b>rgb(18 20 20 / .92)</b> · Bildwechsel <b>250 ms</b> · Fokus auf Schließen',
bestaetigung: ' · Hintergrund <b>rgb(31 35 35 / .56)</b> · Fokus auf Abbrechen · returnValue <b>laden | abbrechen</b>',
formular: ' · method=<b>dialog</b> · Fokus auf erstes Feld · Abbrechen type=<b>button</b> → close()'
};
const schalter = $$('[data-v]');
let variante = 'bild';
function setzeVariante(v, url) {
if (!VARIANTEN.includes(v)) v = VARIANTEN[0];
variante = v;
Object.values(dialoge).forEach(d => d.open && d.close());
$$('[data-buehne]').forEach(s => s.hidden = s.dataset.buehne !== v);
schalter.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
$('[data-werte]').innerHTML = WERTE.gemeinsam + WERTE[v];
$$('[data-nav]').forEach(a => a.toggleAttribute('aria-current', (a.dataset.nav === 'bild') === (v === 'bild') && (a.dataset.nav === 'kontakt') === (v !== 'bild')));
if (url) { const q = new URLSearchParams(location.search); q.set('v', v); history.replaceState(null, '', location.pathname + '?' + q + location.hash); }
}
schalter.forEach(b => b.addEventListener('click', () => { const lief = vorfuehrung; if (lief) neustart(); setzeVariante(b.dataset.v, true); if (lief) vorfuehren(); }));
$$('[data-nav]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); setzeVariante(a.dataset.nav === 'bild' ? 'bild' : 'formular', true); }));
setzeVariante(new URLSearchParams(location.search).get('v') || 'bild', false);
/* ---------- Vorführung: gedachter Zeiger, Dialog ohne Fokusübernahme ---------- */
const zeiger = $('[data-zeiger]');
let uhren = [], laeuft = false;
const nach = (ms, f) => uhren.push(setTimeout(f, ms));
function hin(el, t) { nach(t, () => { const r = el.getBoundingClientRect(); zeiger.classList.add('an'); zeiger.style.translate = `${r.left + r.width / 2}px ${r.top + r.height / 2}px`; }); }
function klick(el, t, f) {
hin(el, t);
nach(t + 750, () => { zeiger.classList.add('klick'); el.classList.add('gedrueckt'); });
nach(t + 900, () => { zeiger.classList.remove('klick'); el.classList.remove('gedrueckt'); f ? f() : el.click(); });
}
function tippen(feld, text, t, ms = 60) {
nach(t, () => { $$('[data-fokus-sim]').forEach(e => e.removeAttribute('data-fokus-sim')); feld.setAttribute('data-fokus-sim', ''); });
[...text].forEach((z, k) => nach(t + 120 + k * ms, () => { feld.value += z; }));
return t + 120 + text.length * ms;
}
function runde() {
zeiger.style.translate = `${innerWidth * .62}px ${innerHeight * .86}px`;
if (variante === 'bild') {
const k = $('[data-bild="0"]');
klick(k, 400);
klick($('[data-bild-weiter]'), 2600);
klick($('[data-bild-weiter]'), 4300);
hin(simHintergrund, 6000);
nach(6000, () => { zeiger.style.translate = `${innerWidth * .5}px ${Math.min(innerHeight - 30, innerHeight * .97)}px`; });
nach(6900, () => { zeiger.classList.add('klick'); }); nach(7050, () => { zeiger.classList.remove('klick'); dialoge.bild.close('hintergrund'); });
nach(8600, runde);
} else if (variante === 'bestaetigung') {
karteZeigen(false);
klick($('[data-oeffne="bestaetigung"]'), 400);
klick($('#dlg-bestaetigung [value="laden"]'), 3000, () => { dialoge.bestaetigung.returnValue = 'laden'; dialoge.bestaetigung.close('laden'); });
nach(6800, runde);
} else {
danke.textContent = '';
klick($('[data-oeffne="formular"]'), 400);
let t = tippen($('#f-name'), 'Lena Fritsch', 1700);
t = tippen($('#f-mail'), 'lena.fritsch@beispiel.at', t + 250, 45);
nach(t + 200, () => { $('#f-art').value = 'Umbau'; $('#f-art').setAttribute('data-fokus-sim', ''); $('#f-mail').removeAttribute('data-fokus-sim'); });
klick($('#dlg-formular [value="senden"]'), t + 700, () => { $('#f-art').removeAttribute('data-fokus-sim'); dialoge.formular.returnValue = 'senden'; dialoge.formular.close('senden'); });
nach(t + 4200, runde);
}
}
function vorfuehren() { neustart(); vorfuehrung = true; laeuft = true; html.classList.add('vorfuehrung'); runde(); }
function pause() { uhren.forEach(clearTimeout); uhren = []; laeuft = false; }
function weiter() { if (!laeuft && vorfuehrung) { laeuft = true; runde(); } }
function neustart() {
pause(); vorfuehrung = false; html.classList.remove('vorfuehrung');
zeiger.classList.remove('an');
Object.values(dialoge).forEach(d => { if (d.open) d.close(); d.removeAttribute('data-sim'); });
simHintergrund.classList.remove('an');
karteZeigen(false); anfrage.reset(); danke.textContent = '';
$$('[data-fokus-sim]').forEach(e => e.removeAttribute('data-fokus-sim'));
}
})();
</script>
</body>
</html>