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:
- Als vollständige Seite mit Layout, Sidebar, Header
- 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:
hx-get="/admin/users?partial=true"– HTMX lädt das Fragmenthx-target="#admin-content"– Das Fragment ersetzt den Content-Bereichhx-push-url="/admin/users"– Die Browser-URL wird aktualisiert (ohne?partial)- Der normale
hrefist 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
| Aspekt | Full Page Reload | HTMX Partial |
|---|---|---|
| Übertragene Daten | ~50-100 KB (HTML + Assets) | ~2-5 KB (nur Content) |
| Sidebar-State | Geht verloren | Bleibt erhalten |
| Scroll-Position | Springt nach oben | Bleibt (im Content) |
| Animations | Abrupter Wechsel | Flüssige Transition |
| Browser-History | Funktioniert | Funktioniert (hx-push-url) |
Vorteile gegenüber SPA-Router
| Aspekt | React Router | HTMX Partial |
|---|---|---|
| Bundle-Größe | +50-100 KB | +14 KB (HTMX) |
| Server-Rendering | Erfordert SSR-Setup | Native |
| SEO | Braucht Hydration | Sofort indexierbar |
| Komplexität | Hoch | Niedrig |
| Deep-Links | Funktioniert | Funktioniert |
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:
| Package | Inhalt | Größe |
|---|---|---|
@casoon/atlas-styles | CSS (Glass, Gradients, Utilities) | Tailwind-purged |
@casoon/atlas-effects | JS-Effekte (Ripple, Tilt, Parallax) | ~2.2 KB |
@casoon/atlas-components | Headless 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-Effektedata-ripple="true"– Material-Design-artiger Ripple beim Klickdata-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-Effekteclass:listfür bedingte Klassen (Astro-Feature)animate-pulsefür den aktiven Status-Punkt- Farbige Badges basierend auf der Rolle
Navigation-State nach HTMX-Swap
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
| Komponente | Größ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)
| Metrik | Full Page Load | Partial 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