Atlas Admin: Ein Dashboard mit HTMX Partial Loading

Wie der AHA-Stack und die Atlas UI-Bibliothek zusammenwirken – am Beispiel eines Admin-Dashboards mit nahtloser Navigation

12 Minuten
Atlas Admin: Ein Dashboard mit HTMX Partial Loading
#Astro #HTMX #Alpine.js #AHA-Stack

Nach dem Kanban-Board und der Rechenkiste folgt das dritte Praxisbeispiel für den AHA-Stack: Ein Admin-Dashboard, das zeigt, wie HTMX Partial Loading mit einer UI-Effekt-Bibliothek zusammenspielt.

Live-Demo: atlas-demo.casoon.dev

GitHub: github.com/casoon/atlas-demo

Das Konzept: SPA-Feeling ohne SPA-Komplexität

Admin-Dashboards sind klassische SPA-Kandidaten: Viele Unterseiten, häufige Navigation, komplexe UI-Komponenten. React mit React Router, Vue mit Vue Router – das ist der Standard.

Das Problem: Ein React-Admin-Dashboard bringt schnell 300-500 KB JavaScript mit. Dazu kommen State-Management, Build-Konfiguration, Bundle-Optimierung. Für ein internes Tool oft überdimensioniert.

Atlas Demo zeigt einen anderen Weg:

  • HTMX Partial Loading ersetzt den Client-seitigen Router
  • Astro View Transitions sorgen für flüssige Übergänge
  • Atlas UI liefert Glassmorphism-Effekte, Ripples und Hover-Animationen
  • Alpine.js handhabt lokale UI-Zustände (Sidebar, Modals)

Das Ergebnis fühlt sich an wie eine SPA – ist aber Server-rendered mit ~35 KB JavaScript.

HTMX Partial Loading: Der Kern der Navigation

Der entscheidende Unterschied zu klassischen Multi-Page-Apps: Die Navigation lädt nur den Content-Bereich neu, nicht die gesamte Seite.

Wie es funktioniert

Jede Admin-Seite (Users, Orders, Analytics, …) existiert zweimal:

  1. Als vollständige Seite mit Layout, Sidebar, Header
  2. Als Fragment – nur der Content ohne Layout

Die Entscheidung trifft ein URL-Parameter:

---
// src/pages/admin/users.astro
import PartialWrapper from "@components/PartialWrapper.astro";

const isPartial = Astro.url.searchParams.get("partial") === "true";
---

<PartialWrapper title="Users" currentPage="users" isPartial={isPartial}>
  <!-- User-Liste, Statistiken, Suchfeld -->
</PartialWrapper>

Der PartialWrapper entscheidet:

---
// src/components/PartialWrapper.astro
import AdminLayout from "@layouts/AdminLayout.astro";

interface Props {
  title: string;
  currentPage: string;
  isPartial: boolean;
}

const { title, currentPage, isPartial } = Astro.props;
---

{isPartial ? (
  <Fragment>
    <script is:inline define:vars={{ title }}>
      document.title = title + " - Atlas Admin";
      const headerTitle = document.querySelector("header h1");
      if (headerTitle) headerTitle.textContent = title;
    </script>
    <slot />
  </Fragment>
) : (
  <AdminLayout title={title} currentPage={currentPage}>
    <slot />
  </AdminLayout>
)}

Bei ?partial=true: Nur der Content wird gerendert, plus ein kleines Script für den Seitentitel.

Ohne Parameter: Das komplette Layout wird gerendert – Sidebar, Header, Footer, alles.

Die Navigation mit HTMX

Die Sidebar-Links nutzen HTMX-Attribute:

<a
  href="/admin/users"
  hx-get="/admin/users?partial=true"
  hx-target="#admin-content"
  hx-swap="innerHTML"
  hx-push-url="/admin/users"
  data-nav-id="users"
  class="nav-link"
>
  Users
</a>

Was passiert beim Klick:

  1. hx-get="/admin/users?partial=true" – HTMX lädt das Fragment
  2. hx-target="#admin-content" – Das Fragment ersetzt den Content-Bereich
  3. hx-push-url="/admin/users" – Die Browser-URL wird aktualisiert (ohne ?partial)
  4. Der normale href ist Fallback für Nicht-JS-Browser

Der Content-Bereich ist ein einfaches <main>:

<main id="admin-content" class="flex-1 p-4 sm:p-6" transition:animate="fade">
  <slot />
</main>

Vorteile gegenüber Full-Page-Reload

AspektFull Page ReloadHTMX Partial
Übertragene Daten~50-100 KB (HTML + Assets)~2-5 KB (nur Content)
Sidebar-StateGeht verlorenBleibt erhalten
Scroll-PositionSpringt nach obenBleibt (im Content)
AnimationsAbrupter WechselFlüssige Transition
Browser-HistoryFunktioniertFunktioniert (hx-push-url)

Vorteile gegenüber SPA-Router

AspektReact RouterHTMX Partial
Bundle-Größe+50-100 KB+14 KB (HTMX)
Server-RenderingErfordert SSR-SetupNative
SEOBraucht HydrationSofort indexierbar
KomplexitätHochNiedrig
Deep-LinksFunktioniertFunktioniert

Atlas UI: Effekte ohne JavaScript-Framework

Das Dashboard nutzt die Atlas UI-Bibliothek für visuelle Effekte. Atlas ist speziell für den AHA-Stack gebaut: SSR-sicher, Framework-agnostisch, Tailwind v4-kompatibel.

Die Architektur von Atlas

Atlas besteht aus drei npm-Packages:

PackageInhaltGröße
@casoon/atlas-stylesCSS (Glass, Gradients, Utilities)Tailwind-purged
@casoon/atlas-effectsJS-Effekte (Ripple, Tilt, Parallax)~2.2 KB
@casoon/atlas-componentsHeadless Components (Modal, Tabs)~1.8 KB

Im Admin-Dashboard werden vor allem die Styles und Effekte genutzt.

Glass-Effekte: Moderne Ästhetik

Der charakteristische Look des Dashboards kommt von Glassmorphism-Effekten:

/* Aus @casoon/atlas-styles */
.cs-glass-card {
  background: rgba(255, 255, 255, 0.05);
  backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 1rem;
}

.cs-glass-xs {
  background: rgba(255, 255, 255, 0.02);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

Die Klassen werden direkt im HTML verwendet:

<div class="cs-glass-card p-5 cursor-pointer group">
  <h3 class="font-semibold text-white">Max Mustermann</h3>
  <p class="text-sm text-white/40">[email protected]</p>
</div>

Interaktive Effekte: Ripple, Hover, Shine

Atlas bringt JavaScript-Effekte mit, die über data-* Attribute aktiviert werden:

<button 
  data-atlas="button" 
  data-ripple="true" 
  data-hover="glow"
  class="cs-glass-button"
>
  Add User
</button>

Was die Attribute bewirken:

  • data-atlas="button" – Registriert das Element für Atlas-Effekte
  • data-ripple="true" – Material-Design-artiger Ripple beim Klick
  • data-hover="glow" – Subtiler Glow-Effekt beim Hover

Für Cards gibt es weitere Optionen:

<div 
  data-atlas="card" 
  data-hover="lift" 
  data-shine="true"
  class="cs-glass-card"
>
  <!-- Card Content -->
</div>
  • data-hover="lift" – Leichtes Anheben beim Hover (translateY + Shadow)
  • data-shine="true" – Reflektions-Effekt der Mausbewegung folgt

Stagger-Animationen für Listen

Wenn mehrere Cards erscheinen, animieren sie nacheinander:

<div 
  class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" 
  data-atlas="grid" 
  data-stagger="100"
>
  {users.map((user) => (
    <div data-atlas="card" data-hover="lift" class="cs-glass-card">
      <!-- User Card -->
    </div>
  ))}
</div>

data-stagger="100" verzögert jede Card um 100ms – ein subtiler Effekt, der die UI lebendiger macht.

SSR-Sicherheit

Atlas-Effekte sind SSR-sicher – sie greifen erst nach dem DOM-Load auf Elemente zu:

// Aus @casoon/atlas-effects
export function ripple(selector: string, options?: RippleOptions) {
  // Kein DOM-Zugriff auf Modul-Ebene
  if (typeof window === 'undefined') return () => {};
  
  const elements = document.querySelectorAll(selector);
  // ... Event-Listener hinzufügen
  
  return () => {
    // Cleanup-Funktion
  };
}

Die Initialisierung passiert im Layout:

<script>
  import { atlasInit } from "@casoon/atlas-components";
  
  // Initial
  document.addEventListener("DOMContentLoaded", atlasInit);
  
  // Nach View Transitions
  document.addEventListener("astro:page-load", atlasInit);
  
  // Nach HTMX Content Swap
  document.body.addEventListener("htmx:afterSwap", (event) => {
    if (event.detail.target.id === "admin-content") {
      atlasInit();
    }
  });
</script>

Wichtig: Nach jedem HTMX-Swap muss atlasInit() aufgerufen werden, damit neue Elemente ihre Effekte bekommen.

Die User-Verwaltung im Detail

Die /admin/users Seite zeigt typische Dashboard-Patterns:

Statistik-Cards mit Stagger

<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6" data-atlas="grid" data-stagger="75">
  <div data-atlas="card" data-hover="lift" class="cs-glass-card p-4">
    <p class="text-2xl font-bold text-white">6</p>
    <p class="text-xs text-white/50">Total Users</p>
  </div>
  <div data-atlas="card" data-hover="lift" class="cs-glass-card p-4">
    <p class="text-2xl font-bold text-emerald-400">5</p>
    <p class="text-xs text-white/50">Active</p>
  </div>
  <!-- ... -->
</div>

Such- und Filterleiste

<div class="cs-glass-card p-4 mb-6">
  <div class="flex flex-col sm:flex-row gap-3">
    <div class="flex-1 relative">
      <input 
        data-atlas="input" 
        data-focus-glow="true" 
        type="text" 
        placeholder="Search users..." 
        class="cs-input-glass w-full pl-10" 
      />
      <svg class="w-4 h-4 text-white/30 absolute left-3.5 top-1/2 -translate-y-1/2">
        <!-- Search Icon -->
      </svg>
    </div>
    <select data-atlas="input" data-focus-glow="true" class="cs-input-glass">
      <option>All Roles</option>
      <option>Admin</option>
      <option>Editor</option>
      <option>User</option>
    </select>
  </div>
</div>

data-focus-glow="true" aktiviert einen subtilen Glow um das Input-Feld bei Focus.

User-Cards mit Status-Indikatoren

{users.map((user, i) => (
  <div 
    data-atlas="card" 
    data-hover="lift" 
    data-shine="true" 
    class="cs-glass-card p-5 cursor-pointer group"
  >
    <div class="flex items-start justify-between mb-4">
      <div class="flex items-center gap-3">
        <!-- Avatar mit Gradient -->
        <div class={`w-12 h-12 rounded-full bg-gradient-to-br ${avatarColors[i]} 
                     flex items-center justify-center`}>
          <span class="text-white font-semibold">{user.avatar}</span>
        </div>
        <div>
          <h3 class="font-semibold text-white group-hover:text-violet-300 
                     transition-colors">
            {user.name}
          </h3>
          <p class="text-sm text-white/40">{user.email}</p>
        </div>
      </div>
      
      <!-- Aktions-Button erscheint bei Hover -->
      <button 
        data-atlas="button" 
        data-ripple="true" 
        class="cs-glass-button p-2 opacity-0 group-hover:opacity-100 
               transition-opacity"
      >
        <!-- More Icon -->
      </button>
    </div>
    
    <!-- Role Badge + Status -->
    <div class="flex items-center justify-between">
      <div class="flex items-center gap-2">
        <span class:list={[
          "px-2.5 py-1 text-xs font-medium rounded-full",
          user.role === "Admin" && "bg-violet-500/20 text-violet-400",
          user.role === "Editor" && "bg-blue-500/20 text-blue-400",
          user.role === "User" && "bg-white/10 text-white/60"
        ]}>
          {user.role}
        </span>
        <span class:list={[
          "flex items-center gap-1.5 text-xs",
          user.status === "active" ? "text-emerald-400" : "text-white/40"
        ]}>
          <span class:list={[
            "w-1.5 h-1.5 rounded-full",
            user.status === "active" 
              ? "bg-emerald-400 animate-pulse" 
              : "bg-white/30"
          ]}></span>
          {user.status === "active" ? "Active" : "Inactive"}
        </span>
      </div>
      <span class="text-xs text-white/30">{user.lastActive}</span>
    </div>
  </div>
))}

Interessante Details:

  • group + group-hover: für koordinierte Hover-Effekte
  • class:list für bedingte Klassen (Astro-Feature)
  • animate-pulse für den aktiven Status-Punkt
  • Farbige Badges basierend auf der Rolle

Ein Problem bei HTMX Partial Loading: Der aktive Navigations-State muss manuell aktualisiert werden. Das Layout wird nicht neu gerendert, nur der Content.

Die Lösung ist ein Event-Listener:

document.body.addEventListener("htmx:afterSwap", (event) => {
  if (event.detail.target.id === "admin-content") {
    // Atlas-Effekte initialisieren
    atlasInit();
    
    // Aktiven Nav-State ermitteln
    const path = window.location.pathname;
    const navId = path === "/admin" || path === "/admin/" 
      ? "dashboard" 
      : path.replace("/admin/", "").replace("/", "");
    
    // Desktop Sidebar aktualisieren
    document.querySelectorAll("aside nav a[data-nav-id]").forEach((link) => {
      const isActive = link.dataset.navId === navId;
      
      // Klassen togglen
      link.classList.toggle("active", isActive);
      link.classList.toggle("bg-gradient-to-r", isActive);
      link.classList.toggle("from-violet-500/20", isActive);
      link.classList.toggle("text-white", isActive);
      link.classList.toggle("text-white/50", !isActive);
      
      // Icon-Farbe
      const icon = link.querySelector("span:first-child");
      if (icon) {
        icon.classList.toggle("text-violet-400", isActive);
        icon.classList.toggle("text-white/30", !isActive);
      }
      
      // Active-Indicator (Punkt)
      let indicator = link.querySelector(".active-indicator");
      if (isActive && !indicator) {
        indicator = document.createElement("span");
        indicator.className = "ml-auto w-1.5 h-1.5 rounded-full 
                               bg-violet-400 active-indicator";
        link.appendChild(indicator);
      } else if (!isActive && indicator) {
        indicator.remove();
      }
    });
  }
});

Das ist mehr Code als bei einem SPA-Router – aber es ist explizit und nachvollziehbar. Keine versteckten State-Updates, keine Magic.

Responsive Design: Mobile-First

Das Dashboard ist für alle Bildschirmgrößen optimiert:

Mobile Sidebar (Off-Canvas)

<body x-data="{ sidebarOpen: false }">
  <!-- Mobile Overlay -->
  <div
    x-show="sidebarOpen"
    @click="sidebarOpen = false"
    class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
  ></div>
  
  <!-- Mobile Sidebar -->
  <aside
    x-show="sidebarOpen"
    x-transition:enter="transition-transform duration-300"
    x-transition:enter-start="-translate-x-full"
    x-transition:enter-end="translate-x-0"
    class="fixed inset-y-0 left-0 w-72 z-50 lg:hidden"
  >
    <!-- Navigation -->
  </aside>
  
  <!-- Desktop Sidebar -->
  <aside class="hidden lg:flex lg:w-72 lg:fixed lg:inset-y-0">
    <!-- Navigation -->
  </aside>
</body>

Alpine.js handhabt den Open/Close-State – keine Server-Requests nötig.

Mobile Bottom Navigation

Für schnellen Zugriff auf häufige Seiten:

<nav class="fixed bottom-0 inset-x-0 z-40 lg:hidden">
  <div class="cs-glass-dark border-t">
    <div class="flex items-center justify-around h-16">
      {mobileNavItems.map((item) => (
        <a
          href={item.href}
          hx-get={`${item.href}?partial=true`}
          hx-target="#admin-content"
          hx-swap="innerHTML"
          hx-push-url={item.href}
          class:list={[
            currentPage === item.id ? "text-violet-400" : "text-white/40"
          ]}
        >
          <span set:html={icons[item.icon]} />
          <span class="text-[10px] font-medium">{item.label}</span>
        </a>
      ))}
    </div>
  </div>
</nav>

Die Bottom-Nav zeigt nur 5 wichtige Items – nicht alle 12 aus der Sidebar.

Performance-Analyse

JavaScript-Bundle

KomponenteGröße (gzipped)
HTMX~14 KB
Alpine.js~15 KB
Atlas Effects~2 KB
Atlas Components~2 KB
Gesamt~33 KB

Zum Vergleich: Ein React-Admin-Dashboard mit Material-UI startet bei ~200 KB.

Ladezeiten (Lighthouse)

MetrikFull Page LoadPartial Load
First Contentful Paint~0.8s-
Time to Interactive~1.2s-
Content Swap-~50-100ms
Übertragene Daten~80 KB~3-5 KB

Partial Loading ist 10-20x schneller als Full Page Reloads.

Erweiterungsmöglichkeiten

Das Demo-Dashboard ist statisch – die User-Daten sind hardcoded. Für ein echtes Projekt würde man:

1. API-Endpoints für CRUD

// src/pages/api/users/index.ts
import type { APIRoute } from 'astro';
import { renderUserList } from '@lib/render';

export const GET: APIRoute = async ({ request }) => {
  const url = new URL(request.url);
  const search = url.searchParams.get('search') || '';
  const role = url.searchParams.get('role') || '';
  
  const users = await db.users.findMany({
    where: {
      name: { contains: search },
      role: role || undefined,
    },
  });
  
  return new Response(renderUserList(users), {
    headers: { 'Content-Type': 'text/html' }
  });
};

2. Live-Suche mit HTMX

<input 
  type="text" 
  name="search"
  hx-get="/api/users"
  hx-trigger="keyup changed delay:300ms"
  hx-target="#user-list"
  hx-include="[name='role']"
  placeholder="Search users..."
/>

3. Inline-Editing

<span 
  hx-get="/api/users/{id}/edit"
  hx-trigger="dblclick"
  hx-swap="outerHTML"
>
  {user.name}
</span>

Fazit: Admin-Dashboards ohne Framework-Overhead

Atlas Demo beweist: Für Admin-Dashboards und interne Tools ist der AHA-Stack eine ernst zu nehmende Alternative zu React/Vue.

Was funktioniert gut:

  • HTMX Partial Loading für flüssige Navigation
  • Glassmorphism-Effekte für moderne Ästhetik
  • Responsive Design mit Alpine.js für UI-State
  • ~33 KB JavaScript statt ~300 KB

Wo man aufpassen muss:

  • Navigation-State manuell synchronisieren
  • Atlas-Effekte nach HTMX-Swap neu initialisieren
  • Mehr expliziter Code, weniger Framework-Magic

Wann ist der Ansatz richtig?

  • Interne Tools und Admin-Panels
  • Dashboards mit viel Content, wenig Echtzeit-Interaktion
  • Teams mit Backend-Fokus
  • Performance-kritische Anwendungen

Wann eher nicht?

  • Hochinteraktive Echtzeit-Apps (Chat, Collaboration)
  • Apps mit komplexem Client-State (Canvas-Editoren)
  • Teams, die bereits React/Vue-Expertise haben