Filter für Contao 5 mit Rocksolid Custom Elements

Schritt‑für‑Schritt‑Anleitung für Contao

Referenzen nach Branche, Team nach Standort, Downloads nach Thema – sobald eine Übersicht wächst, wünscht sich die Redaktion Filterbuttons. Der Reflex führt oft zu einer JavaScript-Bibliothek wie Filterizr; nötig ist die nicht: Contao 5 und die Rocksolid Custom Elements reichen völlig aus.

Der Ansatz kombiniert drei Zutaten: ein zusätzliches Feld im DCA, in dem Redakteure Kategorien vergeben, Sprachdateien für die Labels und einen RSCE-Wrapper, dessen Skript die Filterbuttons automatisch erzeugt. Schritt für Schritt sieht das so aus – und wer ganz ohne Erweiterung arbeiten will, findet im Update am Ende zwei Bordmittel-Varianten.

Neues Datenfeld im DCA anlegen

Zuerst wird im DCA der Inhaltselemente ein Feld data_category definiert. Dieses Feld dient als Datenattribut, mit dem später die Filterung funktioniert.

// contao/dca/tl_content.php
declare(strict_types=1);

use Contao\CoreBundle\DataContainer\PaletteManipulator;

// ----------------------------
// Data-Attribut
// ----------------------------
$GLOBALS['TL_DCA']['tl_content']['fields']['data_category'] = [
    'label'     => &$GLOBALS['TL_LANG']['tl_content']['data_category'],
    'exclude'   => true,
    'inputType' => 'text',
    'eval'      => [
        'maxlength'      => 255,
        'decodeEntities' => true,
        'tl_class'       => 'w50',
    ],
    'sql'       => "varchar(255) NOT NULL default ''",
];

// Feld in alle Paletten einschleusen
foreach (array_keys($GLOBALS['TL_DCA']['tl_content']['palettes']) as $palette) {
    if ('__selector__' === $palette) {
        continue;
    }

    $current = $GLOBALS['TL_DCA']['tl_content']['palettes'][$palette];

    if (is_string($current) && str_contains($current, '{expert_legend')) {
        PaletteManipulator::create()
            ->addField('data_category', 'expert_legend', PaletteManipulator::POSITION_APPEND)
            ->applyToPalette($palette, 'tl_content');
    } elseif (is_string($current)) {
        $GLOBALS['TL_DCA']['tl_content']['palettes'][$palette] = rtrim($current, ';')
            . ';{expert_legend:hide},data_category';
    }
}

Damit wird das Eingabefeld Daten-Kategorie automatisch allen Inhaltselementen hinzugefügt.

Sprachdateien erweitern

Das Label für das Feld wird über die Sprachdateien definiert:

// contao/languages/de/default.php
$GLOBALS['TL_LANG']['tl_content']['data_category'] = [
    'Daten-Kategorie',
    'Wert für die Filter-Funktion <code>data-category</code> dieses Inhaltselements (kommasepariert).'
];

Dadurch erscheint das Feld mit einer passenden Beschreibung im Backend.

Rocksolid Custom Element: Filter-Wrapper

Nun wird ein RSCE-Wrapper angelegt, der als Container für alle zu filternden Inhalte dient.

rsce_filter_wrapper_start.config.php

<?php

return array(
	'label' => array('Filter-Wrapper (Start)', 'Umschließt die zu filternden Elemente.'),
	'types' => array('content'),
	'contentCategory' => 'includes',
	'wrapper' => array('type' => 'start')
);

rsce_filter_wrapper_start.html5

<div id="filter-wrapper" class="content-filter-wrapper">
  <ul class="filter-controls" aria-label="Filter"></ul>
  <div class="filter-container js-filter">

rsce_filter_wrapper_end.config.php

<?php

return array(
	'label' => array('Filter-Wrapper (Ende)', 'Schließt den Filter-Wrapper.'),
	'types' => array('content'),
	'contentCategory' => 'includes',
	'wrapper' => array('type' => 'stop')
);

rsce_filter_wrapper_end.html5

</div>
</div>

<script>
  (function ($) {
    $(function () {
      var $container = $('.js-filter').last();
      if (!$container.length) return;

      var $items = $container.children().addClass('js-filter-item');

      var cats = {};
      $items.each(function () {
        var raw = ($(this).attr('data-category') || '');
        raw.split(',').map(function (s) { return $.trim(s); })
          .filter(Boolean).forEach(function (c) { cats[c] = true; });
      });
      var list = Object.keys(cats).sort();

      var $controls = $container.prevAll('.filter-controls').first();
      if ($controls.length) {
        $controls.empty().append('<li data-filter="all" class="is-active">Alle</li>');
        list.forEach(function (c) {
          var safe = $('<div>').text(c).html();
          $controls.append('<li data-filter="'+safe+'">'+safe+'</li>');
        });
      }

      $controls.on('click', '[data-filter]', function (e) {
        e.preventDefault();
        var val = $(this).data('filter');
        $(this).addClass('is-active').siblings().removeClass('is-active');

        if (val === 'all') {
          $items.show();
          return;
        }

        $items.each(function () {
          var arr = ($(this).attr('data-category') || '')
            .split(',').map(function (s){ return $.trim(s); }).filter(Boolean);
          $(this).toggle(arr.indexOf(val) !== -1);
        });
      });
    });
  })(jQuery);
</script>

Funktionsweise

  • Backend: Redakteure können jedem Inhaltselement eine oder mehrere Kategorien (kommasepariert) zuweisen.
  • Frontend: Die JavaScript-Routine liest diese Kategorien aus, generiert automatisch die Filterbuttons und blendet Inhalte dynamisch ein oder aus.
  • Vorteil: Keine zusätzliche Erweiterung (außer RSCE) notwendig, einfache Integration in bestehende Contao-Projekte.

Ohne Erweiterung: derselbe Filter mit Bordmitteln

Update 2026: Die gezeigte Lösung setzt die Rocksolid Custom Elements voraus. Wer diese Erweiterung nicht einsetzen möchte – etwa um Abhängigkeiten beim nächsten LTS-Sprung zu vermeiden –, kommt mit Bordmitteln zum selben Ergebnis. Der Aufwand ist vergleichbar, die Wartung günstiger.

Kurz gesagt: Statt eines RSCE-Wrappers dienen zwei einfache Contao-Inhaltselemente als Klammer – oder, noch schlanker, ein eigener Elementtyp. Das Datenfeld data_category aus dem ersten Teil bleibt unverändert; nur der Container wird ausgetauscht.

Variante A: Bordmittel – zwei eigene Elementtypen

// contao/dca/tl_content.php
$GLOBALS['TL_DCA']['tl_content']['palettes']['filter_start'] =
    '{type_legend},type;{filter_legend},filterCategories;{invisible_legend:hide},invisible';
$GLOBALS['TL_DCA']['tl_content']['palettes']['filter_stop'] =
    '{type_legend},type;{invisible_legend:hide},invisible';

$GLOBALS['TL_DCA']['tl_content']['fields']['filterCategories'] = [
    'inputType' => 'text',
    'eval'      => ['tl_class' => 'w50', 'maxlength' => 255],
    'sql'       => "varchar(255) NOT NULL default ''",
];
// config/config.yaml – Elementtypen registrieren
contao:
  content_elements:
    filter_start: { category: filter }
    filter_stop:  { category: filter }
{# templates/content_element/filter_start.html.twig #}
<div class="filter" data-filter>
  <div class="filter__buttons">
    {% for kategorie in content.filterCategories|split(',') %}
      <button type="button" data-filter-btn="{{ kategorie|trim }}">
        {{ kategorie|trim }}
      </button>
    {% endfor %}
    <button type="button" data-filter-btn="*" aria-pressed="true">Alle</button>
  </div>
  <div class="filter__items">

{# templates/content_element/filter_stop.html.twig #}
  </div>
</div>

Das Filter-Skript – ohne Bibliothek

document.querySelectorAll("[data-filter]").forEach((box) => {
  const items   = box.querySelectorAll("[data-category]");
  const buttons = box.querySelectorAll("[data-filter-btn]");

  buttons.forEach((btn) => {
    btn.addEventListener("click", () => {
      const wahl = btn.dataset.filterBtn;

      buttons.forEach((b) => b.setAttribute("aria-pressed", b === btn));

      items.forEach((el) => {
        const kategorien = (el.dataset.category || "")
          .split(",").map((s) => s.trim());
        const sichtbar = wahl === "*" || kategorien.includes(wahl);
        el.hidden = !sichtbar;   // hidden statt display:none – Screenreader folgen
      });
    });
  });
});

Variante B: ganz ohne JavaScript

Für einfache Fälle genügt sogar reines CSS: Die Kategorien werden zu Ankern, die Auswahl steuert ein :target-Selektor oder ein verstecktes Radio-Element. Vorteil: funktioniert auch ohne JavaScript und ist barrierefrei per Tastatur bedienbar. Nachteil: keine Mehrfachauswahl.

/* Radio-basierte Filterung, komplett ohne Skript */
.filter input[type="radio"] { position: absolute; opacity: 0; }
.filter__item { display: none; }

#f-alle:checked    ~ .filter__items .filter__item,
#f-webdesign:checked ~ .filter__items [data-category~="webdesign"],
#f-seo:checked     ~ .filter__items [data-category~="seo"] {
  display: block;
}

Welche Variante wann?

  • RSCE-Wrapper: sinnvoll, wenn die Erweiterung ohnehin im Projekt steckt und die Redaktion sie kennt.
  • Eigene Elementtypen (Variante A): die beste Wahl für langlebige Projekte – keine zusätzliche Abhängigkeit, volle Kontrolle über das Template, updatesicher über LTS-Sprünge hinweg.
  • CSS-Lösung (Variante B): ideal für kleine Übersichten mit wenigen Kategorien, etwa Referenzen oder Teamseiten.

Und ein Hinweis, der in keiner der Varianten fehlen darf: Elemente per hidden oder display: none auszublenden, entfernt sie auch für Screenreader – das ist beim Filtern gewollt. Nicht gewollt ist es, wenn die aktive Auswahl nirgends angesagt wird. Ein aria-live="polite"-Bereich mit „12 Einträge angezeigt" löst das Problem in einer Zeile.

Fazit

Ein Filter ist am Ende ein Datenattribut und eine Handvoll Zeilen Skript – keine Aufgabe, für die es eine externe Bibliothek braucht. Die RSCE-Variante ist schnell eingerichtet und für Redaktionen angenehm, die eigenen Elementtypen aus dem Update sind die langlebigere Wahl ohne zusätzliche Abhängigkeit, und für kleine Übersichten genügt sogar reines CSS.

Welcher Weg auch gewählt wird: Das Prinzip bleibt gleich, der Austausch fällt später leicht. Wie sich das Ergebnis anfühlt, zeigt die Referenzen-Übersicht auf der Beispiel-Website des Theme Ultimate – dort läuft genau dieser Filter im Alltag.

Zurück zur Blog-Übersicht