Zum Inhalt
← Hover

Karten-Spotlight (Glow folgt der Maus)

Ein kühles Licht folgt dem Zeiger über ein dunkles Kartenraster: als Lichtfleck, als leuchtender Rahmen oder als Zeichenrauschen.

Jede Karte kennt die Zeigerposition relativ zu sich selbst und speichert sie als CSS-Variablen --x und --y. Eine Deko-Ebene liegt in der Karte: ein Lichtfleck, ein Rahmen oder ein Muster. Ein radial-gradient macht sie nur im Umkreis des Zeigers sichtbar. Beim Betreten blendet die Ebene kurz ein, beim Verlassen aus. Statt Licht kann die Ebene auch eine Textur zeigen, etwa zufällige Zeichen wie bei Evervault.

Die Oberfläche reagiert wie Material unter einer Taschenlampe. Das gibt flachen Rastern Tiefe und zeigt ohne Worte, dass die Karten klickbar sind. Der Effekt ist dezenter als ein 3D-Tilt und wirkt hochwertig, typisch für SaaS- und Dev-Tool-Seiten.

Wann einsetzen

Feature-Raster, Bento-Kacheln, Preis-Karten, Projektkacheln und Kundenlogos auf dunklem Grund. Auch in Dashboards.

Wann nicht

Auf hellem Grund mit wenig Kontrast, dort verpufft das Licht. Nicht, wenn jede Karte schon ein Bild oder Video trägt. Nicht auf jeder Fläche der Seite, sonst wirkt es generisch. Nicht als Kernfunktion für ein reines Touch-Publikum.

Varianten

  • Lichtfleck auf der Fläche
  • Nur der Rahmen leuchtet (Gradient-Border per mask-composite: exclude)
  • Gemeinsames Licht über alle Karten: jede Karte rechnet die Position relativ zu sich, alle leuchten zugleich
  • Zeichen-Rauschen unter der Maske, bei jeder Bewegung neu gemischt (Evervault)
  • WebGL-Zeichenfeld mit Glyphen-Atlas statt DOM-Text (Codrops-Repo)
  • Kombiniert mit 3D-Tilt (rotateX/Y nach Zeiger)
  • Als Materiallicht auf Glas- und Dunkelflächen

Umsetzung

Ein pointermove-Listener am Raster. Die Rechtecke der Karten bei resize und scroll cachen, nicht bei jeder Bewegung getBoundingClientRect aufrufen. Pro Frame (requestAnimationFrame) für jede Karte --x und --y in px setzen, sonst nichts: kein Layout, nur Custom Properties. CSS für die Fläche: .card::before { content: ''; position: absolute; inset: 0; background: radial-gradient(360px circle at var(--x) var(--y), rgb(232 234 237 / .08), transparent 60%); opacity: 0; transition: opacity 420ms ease-out; } .card:hover::before { opacity: 1; }. Rahmen: ::after mit padding: 1px, dem Verlauf als Hintergrund und mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0). Achtung: overflow: hidden an der Karte schneidet den Rahmen ab, besser clip-path: inset(0 round <radius>). Zeichen-Variante: Text mit background-clip: text einfärben und per mask-image auf den Zeigerumkreis begrenzen.

Robust umsetzen

Telefon. Ohne Hover entfällt das Folgen. Entweder ein statisches Licht in einer Ecke oder ein kurzes Aufleuchten an der Tippstelle. Die Zeichen-Variante nicht dauernd neu mischen.

Ohne Bewegung. Der Effekt darf bleiben, weil der Nutzer ihn direkt steuert. Das Licht folgt ohne Nachlauf, das Ein- und Ausblenden entfällt oder wird sehr kurz. Zeichen nicht neu generieren.

Barrierefrei. Bei :focus-visible das Licht zentriert zeigen, damit Tastaturnutzer dasselbe Feedback bekommen. Die Zufallszeichen mit aria-hidden ausblenden, sonst liest der Screenreader hunderte Zeichen vor. Das Licht ist Dekoration: Text und Rand brauchen auch ohne Licht genug Kontrast.

Code

<!DOCTYPE html>
<!--
  Karten-Spotlight (Glow folgt der Maus)
  Fundus · Hover · https://cecodesign.de/lab/fundus/hover/karten-spotlight-glow/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Karten-Spotlight (Glow folgt der Maus) · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  /* Rezept saas-produkt-praezise: Graustufen tragen alles, ein Markenton */
  --grund: #08090a; --karte: #161718; --tief: #0e0f10;
  --text: #f7f8f8; --zweit: #d0d6e0; --leise: #8a8f98; --still: #838993;
  --rand: #242728;
  --marke: 124 131 255;                      /* #7c83ff, einziger Markenton: Fokus, aktiv, Rahmenlicht */
  --licht: 232 234 237;                      /* kühles Weiß wie bei Inngest */
  --ok: #59d499;                             /* Statusfarbe, nur im UI-Ausschnitt */
  --kurve: cubic-bezier(0, 0, 0.58, 1);      /* ease-out, gemessen bei Inngest und Liveblocks */
  --ein-flaeche: 420ms;                      /* Inngest: Einblenden 420 ms */
  --ein-rahmen: 150ms;                       /* Liveblocks: 150 ms */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Geist', system-ui, sans-serif; font-weight: 400; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(18px, 4vw, 52px); display: grid; align-content: center; justify-items: center; overflow-x: hidden; background: var(--grund); }
.seite { width: min(100%, 1160px); }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.zif { font-variant-numeric: tabular-nums; }

/* Kopf */
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-bottom: clamp(26px, 4.4vw, 52px); }
.marke { display: flex; align-items: center; gap: 10px; white-space: nowrap; font: 600 15px/1 'Geist', system-ui, sans-serif; letter-spacing: -.01em; color: var(--text); }
.marke svg { width: 20px; height: 20px; }
.nav { display: flex; align-items: center; gap: 22px; }
.status { display: inline-flex; align-items: center; gap: 8px; text-transform: none; letter-spacing: .02em; color: var(--leise); border: 1px solid var(--rand); border-radius: 99px; padding: 6px 11px 6px 9px; }
.status i { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px rgb(89 212 153 / .14); }

.intro { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 22px 56px; align-items: end; padding-bottom: clamp(26px, 3.6vw, 44px); }
.intro .mono { margin-bottom: 18px; color: rgb(var(--marke)); }
h1 { font-size: clamp(2.25rem, 1.5rem + 3.2vw, 4rem); font-weight: 580; line-height: 1; letter-spacing: -.022em; text-wrap: balance; }
h1 span { color: var(--leise); }
.intro p { font-size: 1rem; line-height: 1.6; letter-spacing: -.006em; color: var(--leise); max-width: 46ch; }
.intro code { display: inline-flex; align-items: center; gap: 10px; margin-top: 18px; font: 400 12.5px/1 'Geist Mono', ui-monospace, monospace; color: var(--zweit); background: var(--karte); border: 1px solid var(--rand); border-radius: 8px; padding: 10px 12px; }
.intro code b { font-weight: 400; color: var(--still); }

/* Leiste über dem Raster: Umschalter und Werte */
.leiste { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; padding-bottom: 12px; }
.umschalter { display: inline-flex; gap: 2px; padding: 3px; background: var(--tief); border: 1px solid var(--rand); border-radius: 10px; }
.umschalter button {
  font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase;
  color: var(--still); background: none; border: 0; border-radius: 7px; padding: 8px 12px; cursor: pointer;
  transition: color 150ms var(--kurve), background-color 150ms var(--kurve);
}
.umschalter button:hover { color: var(--text); }
.umschalter button[aria-pressed="true"] { color: var(--text); background: #232527; box-shadow: inset 0 0 0 1px #34373a; }
.umschalter button:focus-visible { outline: 1px solid rgb(var(--marke)); outline-offset: 2px; }
.werte { color: var(--still); text-transform: none; letter-spacing: .03em; font-size: 10.5px; text-align: right; }

/* ===== Das Raster (Bento) ===== */
.raster { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.karte {
  --x: var(--mx, 50%); --y: var(--my, 50%);
  --pad: clamp(20px, 2.2vw, 28px);
  position: relative; isolation: isolate; display: flex; flex-direction: column; justify-content: space-between; gap: 22px;
  min-height: 236px; padding: var(--pad); border-radius: 16px;
  background: var(--karte); border: 1px solid var(--rand); color: inherit; text-decoration: none; outline: none;
  clip-path: inset(0 round 16px);   /* schneidet Panel und Zeichen ab, lässt den Rahmen frei */
}
.karte.gross { grid-column: span 2; min-height: 300px; }
.karte > * { position: relative; z-index: 2; }
.karte .oben { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.karte h2 { font-size: clamp(1.25rem, 1.05rem + .5vw, 1.5rem); font-weight: 560; letter-spacing: -.016em; line-height: 1.15; margin-bottom: 8px; }
.karte p { font-size: 14.5px; line-height: 1.55; letter-spacing: -.004em; color: var(--leise); max-width: 46ch; }
.karte .fuss { display: flex; justify-content: space-between; gap: 12px; color: var(--still); }
.karte .fuss span:last-child { color: var(--leise); }
.karte .zahl { color: var(--text); font-size: clamp(3rem, 2.2rem + 2.6vw, 4.5rem); font-weight: 520; line-height: .9; letter-spacing: -.04em; margin-bottom: 16px; font-variant-numeric: tabular-nums; }
.karte .zahl small { font: 500 .24em/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .08em; color: var(--leise); margin-left: .5em; vertical-align: .9em; }
.gross .text, .gross .fuss { max-width: 44%; }
.ikon { flex: none; color: #4a4e55; }

/* Produkt-Ausschnitt in der großen Karte: echte Suchanfrage als HTML */
.karte > .panel {
  position: absolute; z-index: 2; top: 62px; right: 0; bottom: 0; width: 52%;
  background: rgb(14 15 16 / .88); border: 1px solid var(--rand); border-right: 0; border-bottom: 0; border-radius: 12px 0 0 0;
  font: 400 12px/1.4 'Geist Mono', ui-monospace, monospace; color: var(--zweit);
}
.panel .pk { display: flex; justify-content: space-between; gap: 12px; padding: 11px 16px; border-bottom: 1px solid var(--rand); color: var(--still); font-size: 11px; }
.panel .pk b { font-weight: 400; color: var(--ok); }
.panel .pk em { font-style: normal; color: var(--zweit); }
.panel .frage { display: flex; align-items: center; gap: 10px; padding: 14px 16px 12px; font: 450 14px/1.3 'Geist', system-ui, sans-serif; color: var(--text); letter-spacing: -.006em; }
.panel .frage svg { width: 14px; height: 14px; color: var(--still); flex: none; }
.panel .frage i { width: 1.5px; height: 16px; background: rgb(var(--marke)); margin-left: -6px; }
.panel ol { list-style: none; padding: 0 8px 8px; }
.panel li { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 2px 10px; align-items: baseline; padding: 9px 8px; border-radius: 8px; }
.panel li:first-child { background: #1b1c1e; }
.panel .s { color: var(--text); font-variant-numeric: tabular-nums; }
.panel .t { font: 450 13px/1.35 'Geist', system-ui, sans-serif; color: var(--zweit); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.panel .q { grid-column: 2; font-size: 10.5px; color: var(--still); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.panel .bal { grid-column: 1 / -1; height: 2px; border-radius: 2px; background: #232527; margin-top: 6px; overflow: hidden; }
.panel .bal::before { content: ''; display: block; height: 100%; width: var(--w); background: #4a4e55; }
.panel li:first-child .bal::before { background: rgb(var(--marke)); }

/* Vektorraum in Karte 02 */
.raum { width: 100%; height: auto; max-height: 112px; color: #3a3d42; }
.raum .nah { color: var(--zweit); }
.raum .anfrage { color: rgb(var(--marke)); }

/* Ebene 1: Lichtfleck auf der Fläche (Inngest: 360 px, 8 %, 420 ms ease-out) */
.karte::before {
  content: ''; position: absolute; inset: 0; z-index: 0; pointer-events: none; border-radius: inherit;
  background: radial-gradient(360px circle at var(--x) var(--y), rgb(var(--licht) / .08), transparent 60%);
  opacity: 0; transition: opacity var(--ein-flaeche) var(--kurve);
}
/* Ebene 2: nur der Rahmen leuchtet (Gradient-Border per mask-composite) */
.karte::after {
  content: ''; position: absolute; inset: -1px; z-index: 3; pointer-events: none; border-radius: inherit; padding: 1px;
  background: radial-gradient(300px circle at var(--x) var(--y), rgb(var(--licht)), rgb(var(--marke) / .6) 50%, transparent 100%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor;
          mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
  opacity: 0; transition: opacity var(--ein-rahmen) var(--kurve);
}
/* Ebene 3: Zeichen-Rauschen unter der Maske */
.zeichen {
  position: absolute !important; inset: 0; z-index: 1 !important; pointer-events: none; overflow: hidden; border-radius: inherit;
  font: 500 11px/14px 'Geist Mono', ui-monospace, monospace; word-break: break-all; padding: 6px 4px; user-select: none;
  color: transparent; background: linear-gradient(125deg, #f7f8f8 0%, #b8bcff 40%, #7c83ff 72%, #8a8f98 100%); -webkit-background-clip: text; background-clip: text;
  -webkit-mask-image: radial-gradient(200px circle at var(--x) var(--y), #000 15%, transparent 100%);
          mask-image: radial-gradient(200px circle at var(--x) var(--y), #000 15%, transparent 100%);
  opacity: 0; transition: opacity var(--ein-flaeche) var(--kurve);
}
.raster:not([data-variante="zeichen"]) .zeichen { display: none; }

/* Wer leuchtet wann */
[data-variante="flaeche"] .karte.an::before,
[data-variante="flaeche"] .karte:focus-visible::before { opacity: 1; }
[data-variante="rahmen"].an .karte::after,
[data-variante="rahmen"] .karte:focus-visible::after { opacity: 1; }
[data-variante="rahmen"].an .karte::before { opacity: .55; }
[data-variante="zeichen"] .karte.an .zeichen,
[data-variante="zeichen"] .karte.an::before,
[data-variante="zeichen"] .karte:focus-visible .zeichen { opacity: 1; }
[data-variante="zeichen"] .karte.an > :not(.zeichen):not(.panel) { text-shadow: 0 0 2px var(--karte), 0 0 6px var(--karte), 0 0 12px var(--karte); }
/* Tastatur: Licht zentriert */
.karte:focus-visible { --x: 50%; --y: 50%; border-color: rgb(var(--marke) / .6); }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 10; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }

/* Touch: kein Hover. Statisches Eckenlicht, beim Tippen kurz an der Tippstelle */
@media (hover: none) {
  .karte {
    background:
      radial-gradient(320px circle at 0 0, rgb(var(--licht) / .07), transparent 60%) padding-box,
      linear-gradient(var(--karte), var(--karte)) padding-box,
      radial-gradient(220px circle at 0 0, rgb(var(--marke) / .55), var(--rand) 70%) border-box;
    border-color: transparent;
  }
  .raster .karte.blitz::before, .raster .karte.blitz .zeichen { opacity: 1; }
  [data-variante="rahmen"] .karte.blitz::after { opacity: 1; }
}

/* Reduzierte Bewegung: Licht folgt direkt, ohne Überblenden; Zeichen werden nicht neu gemischt */
.reduziert .karte::before, .reduziert .karte::after, .reduziert .zeichen { transition: none; }

/* Mittlere Breite: Panel schmaler */
@media (max-width: 1020px) and (min-width: 761px) {
  .gross .text, .gross .fuss { max-width: 46%; }
  .karte > .panel { width: 50%; }
  .panel .q { display: none; }
  .gross .fuss span:last-child { display: none; }
}
/* Telefon */
@media (max-width: 760px) {
  .intro { grid-template-columns: 1fr; }
  .raster { grid-template-columns: 1fr; }
  .karte.gross { grid-column: auto; min-height: 0; padding-bottom: 0; }
  .gross .text, .gross .fuss { max-width: none; }
  .karte > .panel { order: 2; position: relative; top: auto; width: auto; margin: 0 calc(-1 * var(--pad)) 0 0; }
  .gross .fuss { order: 1; }
  .werte { text-align: left; }
  .karte { min-height: 0; }
  .nav .doku { display: none; }
}
@media (max-width: 420px) { .status span { display: none; } .status { padding: 6px; } }
/* Zugang: bei 320 px brechen Treffer und Quellen um statt Auslassungspunkte */
@media (max-width: 360px) { .panel .t, .panel .q { white-space: normal; overflow-wrap: anywhere; } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; padding: 34px 40px; }
.vorschau .intro { display: none; }
.vorschau .kopf { padding-bottom: 22px; }
.vorschau .karte p:not(.zahl) { display: none; }
.vorschau .karte h2 { margin-bottom: 0; }
</style>
</head>
<body>
<div class="seite">
  <header class="kopf">
    <p class="marke">
      <svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M3 17.5V8.6L10 2.5l7 6.1v8.9Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6.5 14.5 13 8" stroke="rgb(124 131 255)" stroke-width="1.6" stroke-linecap="round"/><path d="M9.6 8h3.4v3.4" stroke="rgb(124 131 255)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
      Vektorhaus
    </p>
    <div class="nav">
      <span class="mono doku">Doku · API v3</span>
      <span class="mono status"><i></i><span>Alle Systeme normal</span></span>
    </div>
  </header>

  <section class="intro">
    <div>
      <p class="mono">Plattform · Funktionen (05)</p>
      <h1>Suche, die versteht, <span>was gemeint ist.</span></h1>
    </div>
    <div>
      <p>Vektorhaus speichert Embeddings, führt Funktionen neben den Daten aus und betreibt Agenten mit klaren Grenzen. Eine API, gehostet in Frankfurt, abgerechnet nach Nutzung.</p>
      <code><b>$</b> npm i @vektorhaus/sdk</code>
    </div>
  </section>

  <div class="leiste">
    <div class="umschalter" role="group" aria-label="Variante" data-umschalter>
      <button type="button" data-v="flaeche" aria-pressed="true">Fläche</button>
      <button type="button" data-v="rahmen" aria-pressed="false">Rahmen</button>
      <button type="button" data-v="zeichen" aria-pressed="false">Zeichen</button>
    </div>
    <p class="mono werte" data-werte aria-live="polite"></p>
  </div>

  <div class="raster" data-raster data-variante="flaeche">
    <a class="karte gross" href="#suche">
      <div class="oben"><span class="mono">01 · Semantische Suche</span></div>
      <div class="text">
        <p class="zahl">38<small>ms p95</small></p>
        <h2>Nach Bedeutung suchen, nicht nach Wörtern</h2>
        <p>Hybride Suche aus Vektoren und Volltext in einer Anfrage. Jeder Treffer kommt mit Quelle und Ähnlichkeitswert.</p>
      </div>
      <div class="panel" aria-hidden="true">
        <div class="pk"><span><em>POST</em> /v3/suche</span><span><b>200</b> · 38 ms</span></div>
        <div class="frage"><svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="6" cy="6" r="4.5"/><path d="m9.5 9.5 3 3" stroke-linecap="round"/></svg>Kündigungsfrist fürs Ladenlokal?<i></i></div>
        <ol>
          <li><span class="s">0,91</span><span class="t">Mietvertrag Ladenlokal, § 9 Laufzeit</span><span class="q">vertraege/ladenlokal-2024.pdf · S. 4</span><span class="bal" style="--w:91%"></span></li>
          <li><span class="s">0,86</span><span class="t">Nachtrag 2 zur Gewerbemiete</span><span class="q">vertraege/nachtrag-02.pdf · S. 1</span><span class="bal" style="--w:86%"></span></li>
          <li><span class="s">0,72</span><span class="t">Protokoll Eigentümerversammlung</span><span class="q">intern/protokoll-03-2025.md</span><span class="bal" style="--w:72%"></span></li>
        </ol>
      </div>
      <div class="fuss mono"><span>10 Mio. Vektoren pro Index</span><span>Doku →</span></div>
    </a>

    <a class="karte" href="#embeddings">
      <div class="oben"><span class="mono">02 · Embeddings</span></div>
      <svg class="raum" viewBox="0 0 220 92" aria-hidden="true">
        <g fill="currentColor"><circle cx="49.9" cy="63.1" r="1.6"/><circle cx="50.1" cy="58.1" r="1.6"/><circle cx="44.2" cy="58.7" r="1.6"/><circle cx="61.3" cy="62.5" r="1.6"/><circle cx="60.7" cy="68.5" r="1.6"/><circle cx="55.3" cy="61.1" r="1.6"/><circle cx="38" cy="65.1" r="1.6"/><circle cx="56.3" cy="70" r="1.6"/><circle cx="37.8" cy="49.5" r="1.6"/><circle cx="44.5" cy="52.2" r="1.6"/><circle cx="54.6" cy="53.7" r="1.6"/><circle cx="66.4" cy="56.1" r="1.6"/><circle cx="153.3" cy="22.6" r="1.6"/><circle cx="160" cy="31.9" r="1.6"/><circle cx="148.9" cy="16.6" r="1.6"/><circle cx="166.8" cy="20.6" r="1.6"/><circle cx="159.4" cy="12.2" r="1.6"/><circle cx="174.4" cy="27.3" r="1.6"/><circle cx="164.6" cy="76.5" r="1.6"/><circle cx="181.2" cy="77.1" r="1.6"/><circle cx="187.2" cy="74" r="1.6"/><circle cx="176.9" cy="65" r="1.6"/><circle cx="180.8" cy="68.7" r="1.6"/><circle cx="172.5" cy="65.2" r="1.6"/><circle cx="168.5" cy="69.1" r="1.6"/><circle cx="196.1" cy="61" r="1.6"/><circle cx="164.6" cy="83.3" r="1.6"/><circle cx="22.1" cy="65.5" r="1.6"/><circle cx="87.2" cy="78.8" r="1.6"/><circle cx="14.5" cy="45.9" r="1.6"/><circle cx="43.6" cy="17.6" r="1.6"/><circle cx="21.8" cy="75" r="1.6"/><circle cx="35.9" cy="28.3" r="1.6"/><circle cx="206" cy="40" r="1.6"/><circle cx="92" cy="20" r="1.6"/></g>
        <g class="nah" stroke="currentColor" stroke-width=".8" stroke-dasharray="2 2.5" opacity=".7"><path d="M118 46 128.2 29.9"/><path d="M118 46 141.3 36.6"/><path d="M118 46 105.2 34"/><path d="M118 46 132.4 60.4"/></g>
        <g class="nah" fill="currentColor"><circle cx="128.2" cy="29.9" r="2.2"/><circle cx="141.3" cy="36.6" r="2.2"/><circle cx="105.2" cy="34" r="2.2"/><circle cx="132.4" cy="60.4" r="2.2"/></g>
        <g class="anfrage"><circle cx="118" cy="46" r="10" fill="none" stroke="currentColor" stroke-width=".8" opacity=".5"/><circle cx="118" cy="46" r="3.2" fill="currentColor"/></g>
      </svg>
      <div>
        <h2>Text, Bild und Code als Vektoren</h2>
        <p>Eigenes Modell oder unseres. Modellwechsel mit Neuindex im Hintergrund, ohne Ausfall.</p>
      </div>
      <div class="fuss mono"><span>1.024 Dim. · Kosinus</span><span>Doku →</span></div>
    </a>

    <a class="karte" href="#funktionen">
      <div class="oben"><span class="mono">03 · Funktionen</span>
        <svg class="ikon" width="72" height="24" viewBox="0 0 72 24" fill="currentColor" aria-hidden="true"><rect x="0" y="16" width="5" height="8" rx="1"/><rect x="8" y="10" width="5" height="14" rx="1"/><rect x="16" y="4" width="5" height="20" rx="1" fill="rgb(124 131 255)"/><rect x="24" y="7" width="5" height="17" rx="1"/><rect x="32" y="13" width="5" height="11" rx="1"/><rect x="40" y="17" width="5" height="7" rx="1"/><rect x="48" y="19" width="5" height="5" rx="1"/><rect x="56" y="21" width="5" height="3" rx="1"/><rect x="64" y="22" width="5" height="2" rx="1"/></svg>
      </div>
      <div>
        <h2>Code direkt neben dem Index</h2>
        <p>Serverlose Funktionen in derselben Region wie die Daten. Abgerechnet pro Millisekunde.</p>
      </div>
      <div class="fuss mono"><span>Node 22 · Python 3.13</span><span>Doku →</span></div>
    </a>

    <a class="karte" href="#agenten">
      <div class="oben"><span class="mono">04 · Agenten</span>
        <svg class="ikon" width="72" height="24" viewBox="0 0 72 24" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><circle cx="6" cy="12" r="4.5"/><path d="M11 12h14"/><rect x="25.5" y="6.5" width="11" height="11" rx="2.5"/><path d="M37 12h14" stroke-dasharray="2 2"/><circle cx="62" cy="12" r="5.5" stroke="rgb(124 131 255)"/><path d="m59.6 12 1.7 1.7 3.2-3.4" stroke="rgb(124 131 255)" stroke-linecap="round" stroke-linejoin="round"/></svg>
      </div>
      <div>
        <h2>Werkzeuge mit Leitplanken</h2>
        <p>Agenten rufen nur freigegebene Funktionen auf. Jeder Schritt steht im Protokoll, jede Aufgabe hat ein Kostenlimit.</p>
      </div>
      <div class="fuss mono"><span>Protokoll · 30 Tage</span><span>Doku →</span></div>
    </a>

    <a class="karte" href="#region">
      <div class="oben"><span class="mono">05 · Region</span>
        <svg class="ikon" width="72" height="16" viewBox="0 0 72 16" fill="#2f5f48" aria-hidden="true"><rect x="0" y="0" width="2.6" height="16" rx="1"/><rect x="4" y="0" width="2.6" height="16" rx="1"/><rect x="8" y="0" width="2.6" height="16" rx="1"/><rect x="12" y="0" width="2.6" height="16" rx="1"/><rect x="16" y="0" width="2.6" height="16" rx="1"/><rect x="20" y="0" width="2.6" height="16" rx="1"/><rect x="24" y="0" width="2.6" height="16" rx="1"/><rect x="28" y="0" width="2.6" height="16" rx="1" fill="#8a7a3c"/><rect x="32" y="0" width="2.6" height="16" rx="1"/><rect x="36" y="0" width="2.6" height="16" rx="1"/><rect x="40" y="0" width="2.6" height="16" rx="1"/><rect x="44" y="0" width="2.6" height="16" rx="1"/><rect x="48" y="0" width="2.6" height="16" rx="1"/><rect x="52" y="0" width="2.6" height="16" rx="1"/><rect x="56" y="0" width="2.6" height="16" rx="1"/><rect x="60" y="0" width="2.6" height="16" rx="1"/><rect x="64" y="0" width="2.6" height="16" rx="1"/><rect x="68" y="0" width="2.6" height="16" rx="1" fill="#59d499"/></svg>
      </div>
      <div>
        <h2>Daten bleiben in Frankfurt</h2>
        <p>Nur deutsche Rechenzentren, AV-Vertrag im Dashboard, Schlüssel auf Wunsch bei Ihnen.</p>
      </div>
      <div class="fuss mono"><span class="zif">99,99 % · DSGVO</span><span>Doku →</span></div>
    </a>
  </div>
</div>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#f7f8f8" stroke="#08090a" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const raster = document.querySelector('[data-raster]');
  const karten = [...raster.querySelectorAll('.karte')];
  const knoepfe = [...document.querySelectorAll('[data-umschalter] button')];
  const werteEl = document.querySelector('[data-werte]');
  const zeiger = document.querySelector('[data-zeiger]');

  const WERTE = {
    flaeche: 'radial-gradient 360 px · Licht 8 % · ein/aus 420 ms cubic-bezier(0, 0, .58, 1)',
    rahmen:  'Rahmen 1 px · mask-composite exclude · Glow 300 px · 150 ms ease-out · alle Karten',
    zeichen: 'Maske 200 px · neue Zeichen je Frame · 420 ms ease-out · aria-hidden',
  };
  let variante = 'flaeche';
  function setzeVariante(v) {
    variante = v;
    raster.dataset.variante = v;
    knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
    werteEl.textContent = WERTE[v];
    if (v === 'zeichen') karten.forEach(fuellen);
  }

  // Zeichen-Ebene: so viele Zeichen, wie in die Karte passen
  const ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  const probe = document.createElement('span');
  probe.style.cssText = "position:absolute;visibility:hidden;font:500 11px/14px 'Geist Mono',monospace;white-space:pre";
  probe.textContent = 'M'.repeat(40);
  document.body.appendChild(probe);
  let zeichenBreite = 6.6;
  const ebenen = karten.map(k => {
    const e = document.createElement('span');
    e.className = 'zeichen'; e.setAttribute('aria-hidden', 'true');
    k.prepend(e);
    return e;
  });
  const anzahl = karten.map(() => 0);
  function zufall(n) { let s = ''; for (let i = 0; i < n; i++) s += ZEICHEN[(Math.random() * 36) | 0]; return s; }
  function fuellen(k) { const i = karten.indexOf(k); if (anzahl[i]) ebenen[i].textContent = zufall(anzahl[i]); }

  // Rechtecke cachen: kein getBoundingClientRect pro Mausbewegung
  let rects = [], rasterRect = null;
  function messen() {
    zeichenBreite = probe.getBoundingClientRect().width / 40 || 6.6;
    rects = karten.map(k => k.getBoundingClientRect());
    rasterRect = raster.getBoundingClientRect();
    rects.forEach((r, i) => {
      const n = Math.ceil((r.width - 8) / zeichenBreite) * Math.ceil((r.height - 12) / 14);
      if (n !== anzahl[i]) { anzahl[i] = n; ebenen[i].textContent = zufall(n); }
    });
  }
  addEventListener('resize', messen);
  addEventListener('scroll', messen, { passive: true });
  document.fonts.ready.then(messen);
  messen();

  // Kern: Zeigerposition relativ zu jeder Karte als --mx/--my
  let aktiv = -1, frame = 0, px = 0, py = 0;
  function bewegen(x, y) {
    px = x; py = y;
    if (!frame) frame = requestAnimationFrame(malen);
  }
  function malen() {
    frame = 0;
    let treffer = -1;
    rects.forEach((r, i) => {
      karten[i].style.setProperty('--mx', (px - r.left).toFixed(1) + 'px');
      karten[i].style.setProperty('--my', (py - r.top).toFixed(1) + 'px');
      if (px >= r.left && px <= r.right && py >= r.top && py <= r.bottom) treffer = i;
    });
    if (treffer !== aktiv) {
      if (aktiv >= 0) karten[aktiv].classList.remove('an');
      if (treffer >= 0) karten[treffer].classList.add('an');
      aktiv = treffer;
    }
    raster.classList.add('an');
    if (variante === 'zeichen' && treffer >= 0 && !reduziert()) fuellen(karten[treffer]);
  }
  function verlassen() {
    cancelAnimationFrame(frame); frame = 0;
    if (aktiv >= 0) karten[aktiv].classList.remove('an');
    aktiv = -1; raster.classList.remove('an');
  }

  raster.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') messen(); });
  raster.addEventListener('pointermove', e => { if (e.pointerType === 'mouse') bewegen(e.clientX, e.clientY); });
  raster.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') verlassen(); });
  raster.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); });

  // Touch: Licht blitzt an der Tippstelle kurz auf
  raster.addEventListener('pointerdown', e => {
    if (e.pointerType === 'mouse') return;
    const k = e.target.closest('.karte'); if (!k) return;
    const r = k.getBoundingClientRect();
    k.style.setProperty('--mx', (e.clientX - r.left) + 'px');
    k.style.setProperty('--my', (e.clientY - r.top) + 'px');
    k.classList.add('blitz');
    clearTimeout(k._blitz);
    k._blitz = setTimeout(() => k.classList.remove('blitz'), 700);
  });

  knoepfe.forEach(b => b.addEventListener('click', () => { neustart(); setzeVariante(b.dataset.v); }));
  setzeVariante('flaeche');

  // Vorführung: ein gedachter Zeiger fährt durchs Raster, alle 6 s die nächste Variante
  const RUNDE = 6000, FOLGE = ['rahmen', 'zeichen', 'flaeche'];
  let uhr = null, start = 0, phase = -1, versatz = 0;
  function vorfuehren() {
    pause();
    html.classList.add('vorfuehrung');
    start = performance.now() - versatz;
    const schritt = t => {
      const z = t - start; versatz = z;
      const n = Math.floor(z / RUNDE), p = (z % RUNDE) / RUNDE;
      if (n !== phase) { phase = n; setzeVariante(FOLGE[n % 3]); messen(); }
      const g = rasterRect;
      let x, y;
      if (p < 0.06 || p > 0.93) {                       // kurz draußen: Licht blendet aus
        x = g.left + g.width * 0.72; y = Math.min(innerHeight - 14, g.bottom + 26);
        if (aktiv >= 0 || raster.classList.contains('an')) verlassen();
      } else if (reduziert()) {                         // ohne Gleiten: springt von Karte zu Karte
        const i = Math.floor((p - 0.06) / 0.87 * karten.length) % karten.length, r = rects[i];
        x = r.left + r.width * 0.5; y = r.top + r.height * 0.42; bewegen(x, y);
      } else {
        const q = (z % RUNDE) / 1000;
        x = g.left + g.width * (0.5 + 0.44 * Math.sin(q * 1.05 + n));
        y = g.top + g.height * (0.5 + 0.40 * Math.sin(q * 1.9 + 0.6 + n * 2));
        bewegen(x, y);
      }
      zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; }
  function neustart() {
    pause(); versatz = 0; phase = -1;
    html.classList.remove('vorfuehrung'); verlassen(); setzeVariante(variante);
  }

})();
</script>
</body>
</html>