Beliebige Inhalte im Dialog: die Elementgruppe als Modal in Contao 5.3
In diesem Artikel zeigen wir dir, wie du in Contao 5.3 beliebige Inhalte in einem Dialog-Element anzeigst – ohne Erweiterung.
Es gab mal eine Contao-Erweiterung von uns: euf_overlay. Mit ihr ließen sich Overlays gestalten, die sich von selbst einblendeten. Direkt beim Öffnen einer Seite. Nach einer bestimmten Scroll-Strecke, etwa 75 Prozent. Oder genau dann, wenn jemand die Website verlassen wollte.
Weiterentwickelt haben wir sie nicht. Der Grund: Ungefragte Overlays gelten heute als schlechte Praxis. Sie unterbrechen Besucher:innen bei dem, wofür sie gekommen sind. Auch Google sieht das kritisch und rät ausdrücklich von aufdringlichen Interstitials und Dialogfeldern ab, die den Inhalt verdecken.
Das Problem war nie das Overlay. Das Problem war: Es kam ungefragt.
Denn Inhalte in einem Dialog zu zeigen, kann auch nützlich sein: mehr Infos zu einem Produkt oder einer Leistung, ein Video, ein Kontaktformular. In unserem Contao-Theme TORNADO öffnen Referenzen und News im Dialog. Der Vorteil: Niemand verlässt dafür die Seite, was gerade bei der Onepage-Variante gut ist.
Eine Erweiterung brauchst du dafür heute nicht mehr. Der Browser bringt das <dialog>-Element mit. Contao 5.3 bringt die Elementgruppe mit. Ein Twig-Template verbindet beides: Jede Elementgruppe zeigt ihren Inhalt dann in einem modalen Dialog an. Text, Bild, Video, Formular – was in die Gruppe passt, passt auch in den Dialog. Und er öffnet sich nur, wenn jemand klickt.
Entstanden ist die Lösung in einem Kundenprojekt. Dort öffnet ein Button auf der Leistungsseite ein mehrstufiges Anfrageformular im Dialog. Die Besucher:innen bleiben auf der Seite. Nach dem Absenden sehen sie die Bestätigung – im selben Fenster. Für diesen Artikel haben wir das Formular nachgestellt: als Anfrage an eine Oldtimer-Werkstatt.

Die Elementgruppe als Dialog: Überschrift, Schrittanzeige und der erste Formularschritt, dahinter die abgedunkelte Seite.
Und wenn doch einmal etwas von selbst erscheinen soll? Dann dezent am Rand – wie in unserem Corner-Popup-Tutorial.
Was der Dialog können muss
- Beliebige Inhalte aufnehmen. Ohne neues Inhaltselement, ohne DCA-Anpassung.
- Sich über jeden Link öffnen lassen, der auf die ID der Gruppe zeigt: im Text, im Hyperlink-Element, im Button.
- Sich direkt über die URL öffnen lassen.
/werkstatt#oldtimer-anfragezeigt die Seite mit geöffnetem Dialog. - Sich per Kreuz, Esc-Taste und Klick auf den Hintergrund schließen lassen.
- Die Seite dahinter festhalten. Kein Scrollen, kein Fokus im Hintergrund.
Den größten Teil erledigt der Browser
Öffnest du einen <dialog> mit showModal(), bekommst du ohne eigenen Code:
- Die oberste Ebene. Der Dialog liegt über allem. Kein
z-indexnötig – auch nicht gegen einen fixierten Header. - Einen Hintergrund. Das Pseudo-Element
::backdropdunkelt die Seite ab. - Eine inaktive Seite. Alles außerhalb des Dialogs ist
inert. Tab-Taste, Screenreader und Klicks bleiben im Dialog. - Die Esc-Taste. Sie schließt den Dialog.
- Den Fokus. Er springt beim Öffnen in den Dialog und beim Schließen zurück zum Link.
Selbst programmieren musst du nur drei Dinge: das Öffnen per Link, das Schließen per Kreuz und den Klick auf den Hintergrund.
Schritt 1: Ein Template macht aus der Gruppe einen Dialog
Lege die Datei templates/content_element/element_group/dialog.html.twig an:
{% trans_default_domain "contao_default" %}
{% extends "@Contao/content_element/element_group.html.twig" %}
{#
Elementgruppe als modaler Dialog. Geöffnet wird er über einen beliebigen Link
auf "#<ID der Elementgruppe>", z. B. in einem Text- oder Hyperlink-Element,
oder direkt über die URL mit diesem Anker.
#}
{% block wrapper_tag %}dialog{% endblock %}
{% block attributes %}
{{- attributes.addClass('dialog').setIfExists('aria-label', headline.text|default(null)) -}}
{% endblock %}
{% block inner %}
<button type="button" class="dialog__close" data-dialog-close aria-label="{{ 'MSC.close'|trans }}">×</button>
{{ parent() }}
{% endblock %}
{% block script %}
{% if not as_editor_view|default(false) %}
{% add "dialog_script" to body %}
<script{{ attrs().setIfExists('nonce', csp_nonce('script-src')) }}>
(() => {
const findDialog = hash => {
const el = hash.length > 1 ? document.getElementById(decodeURIComponent(hash.slice(1))) : null;
return el instanceof HTMLDialogElement ? el : null;
};
document.addEventListener('click', e => {
const opener = e.target.closest('a[href*="#"]');
const dialog = opener && findDialog(opener.hash);
if (dialog) {
e.preventDefault();
dialog.showModal();
return;
}
if (e.target.closest('[data-dialog-close]')) {
e.target.closest('dialog').close();
return;
}
// Klick auf den abgedunkelten Hintergrund schließt den Dialog
if (e.target instanceof HTMLDialogElement && e.target.open) {
const r = e.target.getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) {
e.target.close();
}
}
});
findDialog(location.hash)?.showModal();
})();
</script>
{% endadd %}
{% endif %}
{% endblock %}
Das Template erweitert die Elementgruppe von Contao und überschreibt vier Blöcke:
wrapper_tagmacht aus dem<div>der Gruppe ein<dialog>.attributesergänzt die Klassedialogund einaria-labelaus der Überschrift. So nennen Screenreader den Titel des Dialogs.innersetzt den Schließen-Button vor Überschrift und Inhalt. Die Beschriftung „Schließen“ kommt aus den Sprachdateien von Contao.scriptliefert das JavaScript.
3 Entscheidungen im Script
- Ein Listener für alle Links. Das Script hört auf Klicks im ganzen Dokument, nicht auf einzelne Links. Es spielt also keine Rolle, wie viele Dialoge auf der Seite stehen oder wo der Link sitzt. Zeigt der Link auf einen
<dialog>, öffnet er ihn. Alle anderen Sprunglinks bleiben Sprunglinks. - Der Hintergrund per Koordinaten. Der
::backdropist kein eigenes Element. Ein Klick darauf landet beim<dialog>selbst – genau wie ein Klick in den Innenabstand des Dialogs. Deshalb prüft das Script, wo geklickt wurde: Liegt der Punkt außerhalb des Dialogs, schließt er sich. - Das Script nur einmal.
{% add "dialog_script" to body %}gibt das Script am Ende des<body>aus. Der Namedialog_scriptsorgt dafür, dass es auch bei drei Dialogen auf einer Seite nur einmal erscheint.as_editor_viewhält es aus der Backend-Vorschau heraus.csp_nonce()macht es mit einer Content Security Policy verträglich.
Schritt 2: Die Elementgruppe einrichten
- Lege im Artikel ein Inhaltselement vom Typ Elementgruppe an.
- Trag eine Überschrift ein, z. B. „Anfrage für Ihren Oldtimer“. Sie steht oben im Dialog.
- Vergib in den Experten-Einstellungen eine CSS-ID, z. B.
oldtimer-anfrage. - Wähle in den Template-Einstellungen das Template
content_element/element_group/dialog. - Leg die Inhalte als Kind-Elemente in die Gruppe.
Wo die Gruppe im Artikel steht, spielt keine Rolle. Ein <dialog> ohne open-Attribut zeigt der Browser nicht an.
Schritt 3: Jeder Link öffnet den Dialog
<a href="#oldtimer-anfrage">Unverbindliche Anfrage stellen</a>
Ob im Text-Element, im Hyperlink-Element oder als Button: Jeder Link auf die CSS-ID öffnet den Dialog. Redakteur:innen brauchen dafür kein neues Feld, nur den Anker.
Mit Seitenadresse davor klappt es auch von anderen Seiten aus. /werkstatt#oldtimer-anfrage lädt die Seite und öffnet den Dialog sofort. Das funktioniert genauso aus der Navigation, einem Newsletter oder einer Anzeige.
Schritt 4: Etwas CSS für Hintergrund und Kreuz
// Die Seite dahinter scrollt nicht mit
html:has(.dialog[open]) {
overflow: hidden;
}
.dialog {
width: min(48rem, calc(100% - 2rem));
max-width: none;
max-height: calc(100dvh - 2rem);
padding: var(--base-spacing-unit);
border: 0;
border-radius: var(--base-border-radius);
background: #fff;
box-shadow: 0 1rem 3rem rgb(0 0 0 / 0.3);
overscroll-behavior: contain;
&::backdrop {
background: rgb(0 0 0 / 0.6);
}
// Platz für das Kreuz
> h2 {
padding-right: 2.5rem;
}
}
.dialog__close {
position: absolute;
top: var(--base-spacing-unit-sm);
right: var(--base-spacing-unit-sm);
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: 0;
background: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
}
Zwei Zeilen machen den Unterschied:
html:has(.dialog[open])sperrt das Scrollen der Seite, solange ein Dialog offen ist. Ganz ohne JavaScript.overscroll-behavior: containverhindert, dass das Scrollen am Ende des Dialogs auf die Seite übergeht.
Die Variablen stammen aus unserem Theme. Hast du sie nicht, setz feste Werte ein.
Formulare im Dialog brauchen Ajax
Ein Formular im Dialog hat ein Problem: Beim Absenden lädt die Seite neu. Der Dialog ist zu. Fehlermeldung und Bestätigung sieht niemand.
Die Lösung steckt im Formulargenerator: Aktiviere Per Ajax senden. Contao schickt das Formular dann im Hintergrund ab und tauscht nur das Formular aus. Der Dialog bleibt offen.
Zwei Ergänzungen im Script machen es rund. Setz sie direkt unter den Klick-Listener:
// Formularfehler: nach dem Absenden (Ajax) zum ersten fehlerhaften Feld springen …
document.addEventListener('ajax-form-onload', (e) => {
if (!e.target.closest('dialog')) return;
// Das Event kommt vor dem Austausch des Formulars
setTimeout(() => {
const field = document.querySelector(
'dialog[open] .widget.error :is(input:not([type="hidden"]), textarea, select)',
);
field?.focus({ preventScroll: true });
field
?.closest('.widget')
.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
});
// … und den Hinweis ausblenden, sobald das Feld bearbeitet wird
document.addEventListener('input', (e) => {
const widget = e.target.closest?.('dialog .widget.error');
if (!widget) return;
widget.querySelector('p.error')?.remove();
widget
.querySelectorAll('.error')
.forEach((el) => el.classList.remove('error'));
widget.classList.remove('error');
});
Contao löst ajax-form-onload aus, sobald die Antwort da ist – doch bevor es das Formular austauscht. Das setTimeout wartet den Austausch ab. Erst dann gibt es das fehlerhafte Feld, auf das der Fokus springen kann.
Im Beispiel steckt im Dialog übrigens ein mehrstufiges Formular. Die Schritte liefert die Erweiterung mp_forms von terminal42: Sie teilt ein Formular über Seitenumbrüche in mehrere Schritte. Mit „Per Ajax senden“ lädt jeder Schritt im Dialog nach. Wie wir Schrittanzeige, Foto-Upload und Icon-Auswahl gebaut haben, ist ein eigenes Thema.
Ohne JavaScript bleibt der Dialog zu
Eine Grenze hat die Lösung: Ist JavaScript aus, springt der Link nur zum Anker. Der Dialog öffnet sich nicht. Leg deshalb nichts ausschließlich in den Dialog, was alle sehen müssen – etwa die einzige Kontaktmöglichkeit.
Weniger JavaScript: Eine Funktion ist da, eine fehlt noch
Zwei neue Browser-Funktionen nehmen dem Script Arbeit ab. Stand Oktober 2026 laut caniuse:
- Invoker Commands steuern Dialoge ohne JavaScript.
<button command="close" commandfor="oldtimer-anfrage">schließt ihn. Das können Chrome und Edge ab Version 135, Firefox ab 144 und Safari ab 26.2 – also alle großen Browser. Nur ältere Safari-Versionen fallen raus. closedby="any"am<dialog>schließt ihn per Klick auf den Hintergrund. Die Prüfung der Koordinaten entfiele. Chrome und Edge können das ab Version 134, Firefox ab 141. Safari fehlt noch.
Bis Safari closedby beherrscht, bleibt die Koordinatenprüfung im Script. Und das Öffnen per Link bleibt ohnehin Sache des Scripts: Invoker Commands funktionieren nur mit Buttons. Redakteur:innen setzen im Editor nun mal Links.
Ein Template, ein paar Zeilen JavaScript, etwas CSS. Mehr braucht ein Dialog in Contao 5.3 nicht. Was hineinkommt, entscheidest du im Backend.
Du willst den Dialog in deinem Projekt einsetzen und brauchst Unterstützung? Schreib uns.