Zum Inhalt springen
CASOON

Atlas Components in Astro: Headless UI mit Vanilla JavaScript

Wie eine moderne Component Library Web Components ersetzt – ohne Framework-Lock-in

15 Minuten
Atlas Components in Astro: Headless UI mit Vanilla JavaScript
#Atlas #Astro #Headless Components #JavaScript
SerieWeb Components
Teil 3 von 4

Im vorherigen Teil haben wir Web Components von Grund auf gebaut – Shadow DOM, Custom Events, State Management. Das funktioniert, erfordert aber viel Boilerplate.

Dieser Artikel zeigt einen anderen Ansatz: Headless Components. Statt eigene Custom Elements zu definieren, nutzen wir eine Library, die Verhalten an existierendes HTML bindet. Als Beispiel dient CASOON Atlas, eine SSR-sichere UI-Library für Tailwind v4.

Headless vs. Web Components

Der Unterschied ist fundamental:

AspektWeb ComponentsHeadless Components
HTMLEigene Custom Elements (<my-modal>)Standard-HTML (<div>, <dialog>)
StylingShadow DOM kapselt StylesEntwickler kontrolliert CSS vollständig
VerhaltenIn der Komponente gekapseltJavaScript-Funktionen binden an HTML
SSRProblematisch (kein DOM)SSR-safe by design

Headless Components sind keine Konkurrenz zu Web Components – sie lösen ein anderes Problem. Web Components eignen sich für portable, wiederverwendbare Widgets. Headless Components für Projekte, die volle Kontrolle über Markup und Styling brauchen.

Atlas installieren

npm install @casoon/atlas-components

Oder das komplette Paket mit Styles und Effects:

npm install @casoon/atlas

Ein Modal ist komplex: Fokus-Trapping, Escape-Taste, Backdrop-Klick, Scroll-Lock, ARIA-Attribute. Das selbst zu bauen ist aufwendig und fehleranfällig.

Das HTML

Atlas erwartet standard HTML – keine speziellen Attribute:

<button id="open-modal">Modal öffnen</button>

<div id="my-modal" style="display: none;">
  <div class="modal-content">
    <h2 id="modal-title">Bestätigung</h2>
    <p id="modal-desc">Möchten Sie diese Aktion wirklich durchführen?</p>
    <button id="close-modal">Abbrechen</button>
    <button id="confirm-modal">Bestätigen</button>
  </div>
</div>

Das JavaScript

import { createModal } from '@casoon/atlas-components';

const modalElement = document.getElementById('my-modal');
const openButton = document.getElementById('open-modal');
const closeButton = document.getElementById('close-modal');

const modal = createModal(modalElement, {
  backdrop: true,
  closeOnBackdrop: true,
  closeOnEscape: true,
  trapFocus: true,
  backdropBlur: true,
  ariaLabelledBy: 'modal-title',
  ariaDescribedBy: 'modal-desc',
  onOpen: () => console.log('Modal geöffnet'),
  onClose: () => console.log('Modal geschlossen'),
});

openButton.addEventListener('click', () => modal.open());
closeButton.addEventListener('click', () => modal.close());

Was Atlas automatisch macht

Mit diesen wenigen Zeilen bekommt man:

  • Fokus-Trapping: Tab-Navigation bleibt im Modal
  • Escape-Taste: Schließt das Modal
  • Backdrop-Klick: Schließt das Modal
  • Scroll-Lock: Body scrollt nicht mehr
  • ARIA-Attribute: aria-modal, aria-hidden, aria-labelledby, aria-describedby
  • Screen-Reader-Announcements: “Dialog opened”, “Dialog closed”
  • Animationen: Fade + Scale mit konfigurierbarem Timing

Das ist der Vorteil von Headless: Die Library kümmert sich um das Verhalten, das Styling bleibt beim Entwickler.

Integration in Astro

Als Astro-Komponente

---
// src/components/Modal.astro
interface Props {
  id: string;
  title: string;
  description?: string;
}

const { id, title, description } = Astro.props;
---

<div id={id} class="modal" style="display: none;">
  <div class="modal-content bg-white rounded-xl shadow-2xl p-6 max-w-md">
    <h2 id={`${id}-title`} class="text-xl font-semibold mb-4">{title}</h2>
    {description && <p id={`${id}-desc`} class="text-gray-600 mb-6">{description}</p>}
    <slot />
    <button data-modal-close class="mt-4 px-4 py-2 bg-gray-200 rounded hover:bg-gray-300">
      Schließen
    </button>
  </div>
</div>

<script>
  import { createModal } from '@casoon/atlas-components';

  document.querySelectorAll('.modal').forEach((el) => {
    const id = el.id;
    const modal = createModal(el as HTMLElement, {
      trapFocus: true,
      closeOnEscape: true,
      ariaLabelledBy: `${id}-title`,
      ariaDescribedBy: `${id}-desc`,
    });

    // Trigger-Buttons finden
    document.querySelectorAll(`[data-modal-trigger="${id}"]`).forEach((trigger) => {
      trigger.addEventListener('click', () => modal.open());
    });

    // Close-Button im Modal
    el.querySelector('[data-modal-close]')?.addEventListener('click', () => modal.close());
  });
</script>

<style>
  .modal {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 50;
  }
</style>

Nutzung

---
import Modal from '../components/Modal.astro';
---

<button data-modal-trigger="confirm-modal">Löschen</button>

<Modal id="confirm-modal" title="Wirklich löschen?" description="Diese Aktion kann nicht rückgängig gemacht werden.">
  <div class="flex gap-4">
    <button data-modal-close class="px-4 py-2 bg-gray-200 rounded">Abbrechen</button>
    <button class="px-4 py-2 bg-red-600 text-white rounded">Löschen</button>
  </div>
</Modal>

Tabs: Komplexere Interaktionen

Tabs benötigen Keyboard-Navigation (Pfeiltasten), ARIA-Rollen und synchronisierten State.

HTML-Struktur

<div id="product-tabs">
  <div role="tablist" class="flex border-b">
    <button role="tab" aria-selected="true" aria-controls="panel-details">Details</button>
    <button role="tab" aria-selected="false" aria-controls="panel-specs">Spezifikationen</button>
    <button role="tab" aria-selected="false" aria-controls="panel-reviews">Bewertungen</button>
  </div>
  
  <div id="panel-details" role="tabpanel">
    <p>Produktbeschreibung hier...</p>
  </div>
  
  <div id="panel-specs" role="tabpanel" hidden>
    <table>...</table>
  </div>
  
  <div id="panel-reviews" role="tabpanel" hidden>
    <div class="reviews">...</div>
  </div>
</div>

JavaScript

import { createTabs } from '@casoon/atlas-components';

const tabs = createTabs(document.getElementById('product-tabs'), {
  orientation: 'horizontal',
  activateOnFocus: true,
  onChange: (index) => {
    console.log(`Tab ${index} aktiviert`);
    // Analytics, URL-Update etc.
  },
});

// Programmatisch wechseln
tabs.setActiveTab(1);

Was Atlas übernimmt

  • Keyboard-Navigation: Pfeiltasten wechseln Tabs
  • ARIA-Updates: aria-selected, Panel-Visibility
  • Focus-Management: Fokus folgt der Auswahl
  • Orientation: Horizontal oder vertikal

Toast-Notifications

Toasts sind schwierig: Stacking, Auto-Dismiss, Screen-Reader-Announcements. Atlas bietet einen Toast-Manager.

import { createToastManager } from '@casoon/atlas-components';

const toasts = createToastManager({
  position: 'top-right',
  maxToasts: 5,
  defaultDuration: 5000,
});

// Toast anzeigen
toasts.show({
  title: 'Gespeichert',
  message: 'Ihre Änderungen wurden übernommen.',
  type: 'success',
});

// Fehler-Toast
toasts.show({
  title: 'Fehler',
  message: 'Verbindung zum Server fehlgeschlagen.',
  type: 'error',
  duration: 0, // Bleibt bis manuell geschlossen
});

Der Manager kümmert sich um:

  • Stacking: Mehrere Toasts übereinander
  • Animation: Ein-/Ausblenden
  • Auto-Dismiss: Nach konfigurierter Zeit
  • Screen-Reader: aria-live="polite" Announcements

Accordion

import { createAccordion } from '@casoon/atlas-components';

const accordion = createAccordion(document.getElementById('faq'), {
  allowMultiple: false, // Nur ein Panel offen
  animation: 'normal',
});

// Programmatisch öffnen/schließen
accordion.open(0);
accordion.closeAll();

Vergleich: Eigene Web Component vs. Atlas

Selbst gebaut (aus Teil 1)

class MyModal extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    // 200+ Zeilen für Fokus-Trapping, ARIA, Animation...
  }
}
customElements.define('my-modal', MyModal);

Mit Atlas

import { createModal } from '@casoon/atlas-components';
const modal = createModal(element, options);

Der Unterschied:

  • Web Component: Volle Kontrolle, aber mehr Arbeit
  • Atlas: Bewährtes Verhalten, weniger Code

SSR-Sicherheit

Atlas ist für Server-Side Rendering designed. Alle Funktionen prüfen auf Browser-Umgebung:

// Intern in jeder Komponente
if (!isBrowser()) {
  return createNoopState(element);
}

Bei SSR wird ein No-Op-Objekt zurückgegeben – keine Fehler, keine DOM-Zugriffe. Die Initialisierung erfolgt erst im Browser.

In Astro funktioniert das nahtlos:

<script>
  // Wird nur im Browser ausgeführt
  import { createModal } from '@casoon/atlas-components';
  
  const modal = createModal(document.getElementById('my-modal'));
</script>

Wann Web Components, wann Headless?

Web Components wählen wenn:

  • Die Komponente in verschiedenen Projekten/Frameworks genutzt wird
  • Style-Kapselung wichtig ist
  • Ein eigenständiges Widget benötigt wird (Embed, Widget)

Headless Components wählen wenn:

  • Volle Kontrolle über Markup und Styling gewünscht ist
  • Das Projekt ein Design-System mit eigenem CSS hat
  • SSR kritisch ist
  • Accessibility wichtig ist und man sich auf eine Library verlassen will

Praktisches Beispiel: Product Page

Eine typische E-Commerce-Produktseite kombiniert mehrere Komponenten:

---
import Layout from '../layouts/Layout.astro';
import { getProduct } from '../lib/products';

const product = await getProduct(Astro.params.id);
---

<Layout title={product.name}>
  <div class="product-page">
    <!-- Image Gallery mit Carousel -->
    <div id="gallery">
      {product.images.map((img, i) => (
        <img src={img} alt={`${product.name} Bild ${i + 1}`} />
      ))}
    </div>
    
    <!-- Produkt-Info -->
    <div class="product-info">
      <h1>{product.name}</h1>
      <p class="price">{product.price} €</p>
      
      <!-- Varianten-Auswahl mit Tabs -->
      <div id="variant-tabs">
        <div role="tablist">
          {product.variants.map((v) => (
            <button role="tab">{v.name}</button>
          ))}
        </div>
        {product.variants.map((v) => (
          <div role="tabpanel">
            <p>{v.description}</p>
            <span>{v.price} €</span>
          </div>
        ))}
      </div>
      
      <button id="add-to-cart">In den Warenkorb</button>
    </div>
    
    <!-- Details Accordion -->
    <div id="product-accordion">
      <div data-accordion-item>
        <button data-accordion-trigger>Beschreibung</button>
        <div data-accordion-content>{product.description}</div>
      </div>
      <div data-accordion-item>
        <button data-accordion-trigger>Spezifikationen</button>
        <div data-accordion-content>
          <table>...</table>
        </div>
      </div>
    </div>
  </div>
  
  <!-- Bestätigungs-Modal -->
  <div id="cart-modal" style="display: none;">
    <h2>Zum Warenkorb hinzugefügt</h2>
    <p>{product.name} wurde hinzugefügt.</p>
    <button data-modal-close>Weiter einkaufen</button>
    <a href="/cart">Zum Warenkorb</a>
  </div>
</Layout>

<script>
  import { createTabs, createAccordion, createModal, createCarousel } from '@casoon/atlas-components';
  
  // Gallery Carousel
  const gallery = createCarousel(document.getElementById('gallery'), {
    autoplay: false,
    loop: true,
  });
  
  // Varianten Tabs
  const tabs = createTabs(document.getElementById('variant-tabs'));
  
  // Accordion
  const accordion = createAccordion(document.getElementById('product-accordion'), {
    allowMultiple: true,
  });
  
  // Cart Modal
  const modal = createModal(document.getElementById('cart-modal'));
  
  document.getElementById('add-to-cart')?.addEventListener('click', () => {
    // Cart-Logik...
    modal.open();
  });
</script>

Repository

Der vollständige Code zu diesem Artikel findet sich im Repository:

GitHub: github.com/casoon/astro-webcomponents-tutorial

astro-webcomponents-tutorial/
├── part1/     # Vanilla Web Components (Teil 1)
└── part2/     # Atlas Components (dieser Artikel)

Ressourcen