Pattern-Library

Hier zeige ich die Bausteine dieser Website: Farben, Schrift, Layout und die wiederverwendbaren Komponenten. Der Quelltext wird beim Erstellen der Seite direkt aus den Dateien der Website gelesen, ist also immer der aktuelle Stand und nicht nachgebaut.

Farben

Ein dunkles und ein helles Theme mit genau einem Akzentton. Alle Farben sind CSS-Variablen in main.scss, die Tabelle wird aus dieser Datei gelesen und zeigt die Werte des gerade aktiven Themes (Schalter oben im Header).

  • --bg #1a1d26

    Hintergrund der Seite und der geraden Abschnitte.

  • --bg-alt #232937

    Hintergrund der abwechselnden Abschnitte (Über mich, Skills, Kontakt).

  • --text #fff

    Fließtext und Überschriften.

  • --on-blue #fff

    Schrift und Symbole auf blauen Flächen (Button, Scroll-Button).

  • --muted #aab1c0

    Zweitfarbe für Einleitungen, Hinweise und Menüpunkte im Ruhezustand.

  • --line rgb(255 255 255 / 0.14)

    Feine Trennlinien und Rahmen (Weiß mit 14 % Deckkraft).

  • --blue #1d63f0

    Akzent: Button, Logo-Symbol und der Schrägstrich vor Abschnittsüberschriften.

  • --blue-dark #1650c8

    Hover-Zustand des Buttons.

  • --blue-light #4f8cff

    Unterstrich und Rahmen im Hover (Text-Link, Drag-Chip) sowie die Punkte im Hero-Raster.

  • --focus #8db4ff

    Fokusrahmen für die Tastaturbedienung.

Kontraste

Berechnet aus den Variablen nach WCAG 2.x. Text braucht mindestens 4,5:1 (AA), Bedienelemente und große Schrift 3:1.

  • Fließtext auf dem Seitenhintergrund

    --text / --bg · 16,8:1 · AAA

  • Zweittext auf dem Seitenhintergrund

    --muted / --bg · 7,8:1 · AAA

  • Zweittext auf abwechselnden Abschnitten

    --muted / --bg-alt · 6,8:1 · AA

  • Beschriftung des Buttons

    --on-blue / --blue · 5,1:1 · AA

  • Beschriftung des Buttons im Hover

    --on-blue / --blue-dark · 7,0:1 · AA

  • Unterstrich und Rahmen im Hover

    --blue-light / --bg · 5,2:1 · AA

  • Fokusrahmen

    --focus / --bg · 8,1:1 · AAA

  • Akzent (Logo, Schrägstrich), rein dekorativ

    --blue / --bg · 3,3:1 · ab 3:1 (Bedienelemente, große Schrift)

Alle Variablen im Quelltext

Quelltext anzeigenassets/scss/main.scss
:root {
  --bg: #1a1d26;
  --bg-alt: #232937;
  --text: #fff;
  --on-blue: #fff; // Schrift und Symbole auf blue-Flächen
  --muted: #aab1c0;
  --line: rgb(255 255 255 / 0.14);
  --blue: #1d63f0;
  --blue-dark: #1650c8;
  --blue-light: #4f8cff;
  --focus: #8db4ff;
  --font: 'Space Grotesk', 'Helvetica Neue', Arial, sans-serif;
  --font-display: 'Roboto Slab', Georgia, 'Times New Roman', serif; // Logo und Überschriften
  --gutter: clamp(1.25rem, 5vw, 4rem);
  --header-height: 4.25rem;
  --section-height: calc((100svh - var(--header-height)) * 0.9); // 90 Prozent: der nächste Abschnitt lugt unten hervor
  color-scheme: dark;

  @include down($bp-nav) {
    --header-height: 6.8rem; // zweizeiliger Header, nicht fixiert
  }
}

:root[data-theme='light'] {
  --bg: #f7f8fb;
  --bg-alt: #eceff5;
  --text: #14171f;
  --muted: #4d5566;
  --line: rgb(20 23 31 / 0.16);
  --blue-light: #1650c8; // im hellen Theme dunkler als --blue, weil die Fläche heller ist
  --focus: #1650c8;
  color-scheme: light;
}

Typografie

Zwei Schriften, beide selbst ausgeliefert: Beim Aufruf der Seite findet keine Verbindung zu Google statt.

  • Frontend-Entwicklung mit Sorgfalt

    Roboto Slab

    Logo und Überschriften (h1 bis h3)

    --font-display 'Roboto Slab', Georgia, 'Times New Roman', serif

  • Komponenten, die man gern benutzt

    Space Grotesk

    Fließtext, Menü, Buttons und Titel von Einträgen (h4)

    --font 'Space Grotesk', 'Helvetica Neue', Arial, sans-serif

Größen

  • Abschnittstitel · Roboto Slab 700 · 2 bis 3,5 rem (fließend)

    Erfahrung

  • Überschrift 3 · Roboto Slab 700 · 1,5 rem

    Berufliche Erfahrung

  • Überschrift 4 · Space Grotesk 500 · 1,15 rem

    Frontend Developer

  • Fließtext · Space Grotesk 400 · 1,0625 rem, Zeilenhöhe 1,65

    Ich komme aus dem Design und baue Oberflächen mit Vue, Nuxt und TypeScript.

  • Die Serifenschrift gibt den Überschriften Charakter, die serifenlose Grotesk hält Text und Oberfläche ruhig.
  • Textspalten sind auf 65 Zeichen begrenzt, damit Zeilen gut lesbar bleiben.
  • Die Textschrift lädt vorab und mit font-display: optional. Kommt sie nicht rechtzeitig an, bleibt die Ersatzschrift stehen, statt dass sich der Text später verschiebt (Layout Shift).

Layout

Wenige feste Regeln, die überall gelten.

Abschnitte
Jeder Abschnitt ist mindestens 90 % so hoch wie das Fenster unter dem Header. So lugt der nächste Abschnitt unten hervor und zeigt, dass es weitergeht. Abwechselnde Hintergründe (bg und bg-alt) trennen die Abschnitte ohne Linien.
Seitenrand
Ein fließender Rand (gutter) von 1,25 bis 4 rem hält Inhalte auf jeder Breite am gleichen Rand.
Header
Ab 1000 px ist der Header einzeilig und bleibt oben stehen. Darunter besteht er aus zwei Zeilen: oben Logo und Sprachschalter, darunter die Menüpunkte, die bei Bedarf umbrechen.
Hero
Ab 1100 px liegt das Porträt als Hintergrundebene zwischen dem Text links und den Infoblöcken rechts. Darunter steht es unter dem Text.
Formen
Eckig, ohne Rundungen, ohne Glas- oder Leuchteffekte. Die Spielereien (Punktraster im Hero, verschiebbare Tags) sind rein dekorativ.
Bedienbarkeit
Klickflächen sind mindestens 44 px hoch. Der Fokusrahmen ist 3 px stark und überall sichtbar. Bei „reduzierte Bewegung“ werden Übergänge und Animationen abgeschaltet oder verkürzt.

Variablen für Schrift und Layout

  • --font

    'Space Grotesk', 'Helvetica Neue', Arial, sans-serif

  • --font-display

    'Roboto Slab', Georgia, 'Times New Roman', serif

  • --gutter

    clamp(1.25rem, 5vw, 4rem)

  • --header-height

    4.25rem

  • --section-height

    calc((100svh - var(--header-height)) * 0.9)

SCSS-Variablen und Mixins

Breakpoints, Abstände, Schriftgrößen, Mindestgrößen und Übergänge liegen in einer eigenen SCSS-Datei, weil sie in Media Queries stehen oder sich zur Laufzeit nicht ändern. Die Abstände folgen einer Skala in 0,25-rem-Schritten, mit den Mixins up(), down() und between() heißt ein Breakpoint überall gleich. Werte, die nur an einer Stelle vorkommen, stehen als lokale Variable in der jeweiligen Komponente. Die Datei wird beim Build in jede SCSS-Datei geladen.

Quelltext anzeigenassets/scss/_variables.scss
// SCSS-Variablen und Mixins: Werte, die sich nicht als CSS-Variable (:root) eignen, weil sie in Media Queries stehen
// oder nur beim Kompilieren gebraucht werden. Die Datei wird über nuxt.config.ts in jede SCSS-Datei und jeden <style lang="scss"> geladen.
// Farben, Schriften und Abstände, die sich zur Laufzeit ändern können, bleiben CSS-Variablen in main.scss.

// Breakpoints (Mobile First, jeweils ab dieser Breite; in rem, damit sie auch bei größerer Browser-Schrift mitgehen)
$bp-compact: 23.75rem; // 380 px: sehr schmale Handys, Header-Logo und Sprachschalter müssen in eine Zeile
$bp-tablet: 43.75rem; // 700 px: zweispaltige Raster, Tablet-Layout im Hero
$bp-footer: 48rem; // 768 px: Footer in einer Zeile
$bp-about: 53.75rem; // 860 px: Über mich, Porträt und Text untereinander
$bp-nav: 62.5rem; // 1000 px: Header einzeilig und fixiert
$bp-hero: 68.75rem; // 1100 px: Hero mit Porträt als Hintergrund
$vh-short: 45rem; // 720 px: niedrige Fenster, Scroll-Button entfällt
$bp-step: 0.0625rem; // 1 px: Abstand zwischen up() und down(), damit sich beide nie überlappen

// Bedienbarkeit
$tap-target: 2.75rem; // 44 px: Mindesthöhe für alles Klickbare
$button-height: 3rem;

// Breiten
$measure-text: 48rem; // Lesebreite der Rechtstexte
$measure-page: 60rem; // Breite der Pattern-Library

// Abstände: Schritte von 0,25 rem (Zahl = Wert in rem × 4, also $space-4 = 1 rem). Für padding, margin und gap.
$space-1: 0.25rem;
$space-2: 0.5rem;
$space-3: 0.75rem;
$space-4: 1rem;
$space-5: 1.25rem;
$space-6: 1.5rem;
$space-8: 2rem;
$space-10: 2.5rem;
$space-12: 3rem;

// Abstände, die auf mehreren Seiten gleich sind
$page-padding-y: clamp(3rem, 8vw, 5rem); // oben und unten bei Impressum, Datenschutz und Pattern-Library
$list-indent: $space-5; // Einzug von Aufzählungen

// Schrift
$font-mono: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; // Quelltext und Variablennamen
$font-mono-size: 0.9em; // Quelltext im Fließtext, etwas kleiner als die Umgebung

// Schriftgrößen, die mehrfach vorkommen (Einzelwerte stehen als lokale Variable in der jeweiligen Komponente)
$font-sm: 0.9rem; // Beschriftungen, Nebentext, Quelltext
$font-lg: 1.15rem; // Einleitungen und Einträge
$font-xl: 1.35rem;
$font-2xl: 1.5rem; // Überschrift der Ebene 3
$font-title: clamp(2rem, 5vw, 3.25rem); // Seitentitel (Impressum, Datenschutz, Pattern-Library)
$font-title-lg: clamp(2rem, 5vw, 3.5rem); // Abschnittsüberschriften

// Schriftschnitt, Zeilenhöhe, Laufweite
$weight-medium: 500;
$weight-bold: 700;
$leading-tight: 1.15; // Überschriften
$leading-snug: 1.2;
$leading-normal: 1.5; // kleiner Fließtext
$tracking-caps: 0.08em; // Großbuchstaben-Labels
$tracking-tight: -0.01em; // große Überschriften
$tracking-tighter: -0.02em; // sehr große Überschriften

// Linien
$hairline: 0.0625rem; // 1 px: feinste Linie
$border-line: $hairline solid var(--line); // feine Trennlinie und Rahmen

// Schatten
$shadow-strong: rgb(0 0 0 / 0.4); // Pager-Button und angehobener Drag-Chip

// Stapelreihenfolge (z-index)
$z-behind: 0;
$z-above: 1;
$z-lifted: 2; // angehobener Drag-Chip
$z-pager: 9;
$z-header: 10;
$z-skip-link: 20; // immer ganz oben, damit er beim Tabben sichtbar ist

// Spalten
$columns-1: 1fr;
$columns-2: 1fr 1fr;

// Übergänge
$duration-fast: 0.2s;
$duration-base: 0.3s;
$duration-slow: 0.4s;
$duration-spring: 0.55s;
$duration-spring-long: 0.6s;
$transition-fast: $duration-fast ease;
$transition-base: $duration-base ease;
$transition-slow: $duration-slow ease;
// Federn mit leichtem Überschwingen (die Kurve geht über 1)
$ease-spring-soft: cubic-bezier(0.3, 1.4, 0.5, 1);
$ease-spring: cubic-bezier(0.3, 1.5, 0.5, 1);
$ease-spring-strong: cubic-bezier(0.3, 1.6, 0.5, 1);

@mixin up($breakpoint) {
  @media (min-width: $breakpoint) {
    @content;
  }
}

// Bis eine Breite davor: down($bp-hero) gilt bis 1099 px und ist das Gegenstück zu up($bp-hero)
@mixin down($breakpoint) {
  @media (max-width: $breakpoint - $bp-step) {
    @content;
  }
}

@mixin between($min, $max) {
  @media (min-width: $min) and (max-width: $max - $bp-step) {
    @content;
  }
}

Komponenten

Die Komponenten sind bewusst klein. Jede zeigt eine Vorschau zum Ausprobieren (Maus und Tastatur), die Schnittstelle, Hinweise zur Barrierefreiheit und den echten Quelltext.

Button

Die Hauptaktion eines Bereichs, zum Beispiel „Kontakt aufnehmen“ im Hero. Technisch ein Link, der wie ein Button aussieht, weil er zu einem Abschnitt springt und keine Aktion auslöst.

Schnittstelle

.button
Eckige Fläche in blue mit weißer Schrift, mindestens 3 rem hoch. Hover: blue-dark.

Barrierefreiheit

  • Ein echter Link: Enter öffnet das Ziel, der Fokusrahmen ist sichtbar.
  • Der Hover ändert nur die Farbe, nichts ist ausschließlich per Maus erreichbar.
  • Der Kontrast der Beschriftung steht in der Tabelle unter „Farben“.

Verwendung im Hero

QuelltextHTML
<a class="button" href="#contact">Kontakt aufnehmen</a>
Quelltext anzeigencomponents/_button.scss
// Block: button – Hauptaktion (z. B. „Kontakt aufnehmen“). Eckig, ein Akzentton, Hover etwas dunkler.
@use '../variables' as *; // additionalData gilt nur für Einstiegsdateien, eingebundene Teildateien laden die Variablen selbst

.button {
  display: inline-flex;
  align-items: center;
  min-height: $button-height;
  padding: $space-3 $space-6;
  background: var(--blue);
  color: var(--on-blue);
  font-weight: $weight-medium;
  text-decoration: none;

  &:hover {
    background: var(--blue-dark);
  }
}

Für alle weiteren Links. Der Unterstrich liegt am Text und läuft beim Hover von links nach rechts von Weiß zu Blau durch. Das Label sitzt in einem eigenen Element, nur dort liegt der Unterstrich.

Schnittstelle

.text-link
Block: Link mit mindestens 2,75 rem Höhe, optional mit Symbol (Abstand 0,5 rem).
.text-link__label
Element: der Text mit dem animierten Unterstrich.

Barrierefreiheit

  • Der Unterstrich ist auch im Ruhezustand sichtbar, Links erkennt man also nicht nur an der Farbe.
  • Hover und Tastaturfokus lösen dieselbe Animation aus.
  • Bei „reduzierte Bewegung“ springt der Unterstrich ohne Übergang.

Verwendung im Kontaktbereich

QuelltextHTML
<a class="text-link" href="/cv/Lebenslauf-Dustin-Clever.pdf" download>
  <span class="text-link__label">Lebenslauf (DE)</span>
</a>
Quelltext anzeigencomponents/_text-link.scss
// Block: text-link
// Der Unterstrich liegt am Label und läuft beim Hover von links nach rechts von Weiß zu Blau durch.
@use '../variables' as *; // additionalData gilt nur für Einstiegsdateien, eingebundene Teildateien laden die Variablen selbst

$text-link-label-padding-bottom: 0.15em;

.text-link {
  --underline: 0.125rem;

  display: inline-flex;
  align-items: center;
  gap: $space-2;
  min-height: $tap-target;
  font-weight: $weight-medium;
  text-decoration: none;

  &__label {
    padding-bottom: $text-link-label-padding-bottom;
    background-image: linear-gradient(var(--blue-light), var(--blue-light)), linear-gradient(currentcolor, currentcolor);
    background-repeat: no-repeat;
    background-position: 0 100%, 0 100%;
    background-size: 0 var(--underline), 100% var(--underline);
    transition: background-size $transition-slow;

    @media (prefers-reduced-motion: reduce) {
      transition: none;
    }
  }

  &:hover &__label,
  &:focus-visible &__label {
    background-size: 100% var(--underline), 100% var(--underline);
  }
}

Scroll-Button

Der runde Pfeil unter den Hero-Aktionen, der zum nächsten Abschnitt führt. Technisch ein Anker-Link; nach dem Klick landet der Fokus auf der Überschrift des Ziels.

Schnittstelle

href
Ziel-Anker, zum Beispiel #about.
label
Zugänglicher Name, da der Button nur ein Symbol zeigt.

Barrierefreiheit

  • Ein echter Link mit aria-label, der Pfeil selbst ist für Screenreader ausgeblendet.
  • Die Klickfläche ist 4 rem groß, der Fokusrahmen ist sichtbar.
  • Der Hover ändert nur die Farbe; bei „reduzierte Bewegung“ entfällt der Übergang.

Verwendung im Hero

QuelltextHTML
<ScrollButton href="#about" label="Nach unten scrollen" />
Quelltext anzeigenScrollButton.vue
<script setup lang="ts">
// Runder Pfeil-Button, der zum nächsten Abschnitt springt (im Hero unter den Aktionen).
// Technisch ein Anker-Link; nach dem Klick landet der Fokus auf der Überschrift des Ziels.
defineProps<{ href: string; label: string }>()
const onNavigate = focusSection // siehe utils/focusSection.ts
</script>

<template>
  <a class="scroll-button" :href="href" :aria-label="label" @click="onNavigate">
    <svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" focusable="false">
      <path d="M5 9l7 7 7-7" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
    </svg>
  </a>
</template>

<style lang="scss" scoped>
$scroll-button-size: 4rem;
.scroll-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: $scroll-button-size;
  height: $scroll-button-size;
  border-radius: 50%;
  background: var(--blue);
  color: var(--on-blue);
  transition: background-color $transition-fast;

  &:hover {
    background: var(--blue-dark);
  }

  @media (prefers-reduced-motion: reduce) {
    transition: none;
  }
}
</style>

Sprachschalter

Wechselt zwischen Deutsch und Englisch, gestaltet wie ein Lichtschalter: der Knopf springt zur aktiven Sprache, die Spur leuchtet bei EN blau. Die Komponente kennt keine Routen, alles kommt über Props. Die Vorschau zeigt beide Zustände; die Links bleiben hier auf der Seite.

Schnittstelle

lang
Aktive Sprache: de oder en. Steuert Knopf und Farbe.
paths
Zielpfad je Sprache, zum Beispiel { de: "/", en: "/en" }.
label
Beschriftung der Navigation für Screenreader.

Barrierefreiheit

  • Zwei normale Links mit lang, hreflang und ausgeschriebenem Namen („Deutsch (DE)“), die aktive Sprache trägt aria-current="page".
  • Der Zustand wird nicht nur durch die Position des Knopfes gezeigt, sondern auch durch die invertierte Schriftfarbe.
  • Bei „reduzierte Bewegung“ springt der Knopf ohne Übergang.

Verwendung im Header

QuelltextHTML
<LangSwitch lang="de" :paths="{ de: '/', en: '/en' }" label="Sprache" />
Quelltext anzeigenLangSwitch.vue
<script setup lang="ts">
// Sprachwahl als Lichtschalter: der Knopf springt zur aktiven Sprache, die Spur leuchtet bei EN blau.
// Die Komponente kennt keine Routen: aktive Sprache, Zielpfade und Beschriftung kommen von außen.
type Lang = 'de' | 'en'
defineProps<{ lang: Lang; paths: Record<Lang, string>; label: string }>()

const languages = [
  { code: 'de', label: 'Deutsch (DE)' },
  { code: 'en', label: 'English (EN)' }
] as const
</script>

<template>
  <nav class="lang-switch" :class="`lang-switch--${lang}`" :aria-label="label">
    <span class="lang-switch__knob" aria-hidden="true" />
    <NuxtLink
      v-for="l in languages"
      :key="l.code"
      class="lang-switch__link"
      :to="paths[l.code]"
      :lang="l.code"
      :hreflang="l.code"
      :aria-label="l.label"
      :aria-current="l.code === lang ? 'page' : undefined"
    >
      {{ l.code }}
    </NuxtLink>
  </nav>
</template>

<style lang="scss" scoped>
$switch-border: 0.125rem;
$switch-link-height: 2.5rem;
$switch-focus-inset: -0.25rem;
$knob-shadow-color: rgb(0 0 0 / 0.35);
$knob-shadow: 0 0.125rem 0.25rem $knob-shadow-color;
$switch-fade: 0.35s ease; // Spur wechselt die Farbe etwas langsamer als der Knopf springt

.lang-switch {
  position: relative;
  display: inline-grid;
  grid-template-columns: $columns-2;
  border: $switch-border solid var(--text);
  background: var(--bg);
  transition:
    background-color $switch-fade,
    border-color $switch-fade;

  &--en {
    border-color: var(--blue);
    background: var(--blue);
  }

  &__knob {
    position: absolute;
    top: $switch-border;
    bottom: $switch-border;
    left: $switch-border;
    width: calc(50% - $switch-border);
    background: var(--text);
    box-shadow: $knob-shadow;
    transition: transform $duration-slow $ease-spring; // leichtes Überschwingen wie ein Kippschalter
  }

  &--en &__knob {
    transform: translateX(100%);
  }

  &__link {
    position: relative;
    z-index: $z-above;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: $tap-target;
    min-height: $switch-link-height;
    color: var(--text);
    font-size: $font-sm;
    font-weight: $weight-bold;
    text-decoration: none;
    text-transform: uppercase; // im HTML steht de/en, angezeigt wird DE/EN
    transition: color $transition-base;

    &:focus-visible {
      outline-offset: $switch-focus-inset;
    }

    &[aria-current='page'] {
      color: var(--bg);
    }
  }

  @media (prefers-reduced-motion: reduce) {
    &,
    &__knob,
    &__link {
      transition: none;
    }
  }
}
</style>

Theme-Schalter

Wechselt zwischen hellem und dunklem Design. Beim ersten Besuch gilt die Systemeinstellung, danach die Wahl, die der Browser sich merkt. Ein kleines Skript im Head setzt das Theme, bevor die Seite gezeichnet wird, damit nichts aufblitzt. Die Vorschau schaltet wirklich um, auch diese Seite.

Schnittstelle

label
Beschriftung für Screenreader, zum Beispiel „Helles Design“. Der Zustand steht in aria-pressed.
useTheme()
Gemeinsamer Zustand (theme, isLight, toggle). Die Farben selbst hängen an data-theme auf <html> (main.scss), die Logik steckt in utils/theme.ts.

Barrierefreiheit

  • Ein echter Button mit aria-label und aria-pressed (gedrückt = helles Design), per Tastatur bedienbar.
  • Beide Symbole stehen im HTML, welches sichtbar ist, entscheidet das CSS. Das Symbol allein trägt keine Bedeutung, der Name kommt aus dem Label.
  • Gespeichert wird nur die Wahl (dark oder light) im localStorage, ohne Cookie. Ist der Speicher gesperrt, gilt die Wahl bis zum Neuladen. Bei „reduzierte Bewegung“ gibt es keine Übergänge.

Verwendung im Header

QuelltextHTML
<ThemeSwitch label="Helles Design" />
Quelltext anzeigenThemeSwitch.vue
<script setup lang="ts">
// Wechselt zwischen hellem und dunklem Theme. Beide Symbole stehen im HTML, welches zu sehen ist, entscheidet das CSS über
// data-theme auf <html>. So gibt es kein Aufblitzen und keinen Unterschied zwischen Server und Browser.
defineProps<{ label: string }>()
const { isLight, sync, toggle } = useTheme()

onMounted(sync)
</script>

<template>
  <button class="theme-switch" type="button" :aria-label="label" :aria-pressed="isLight" @click="toggle">
    <svg class="theme-switch__icon theme-switch__icon--moon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
      <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5z" fill="currentColor" />
    </svg>
    <svg class="theme-switch__icon theme-switch__icon--sun" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
      <circle cx="12" cy="12" r="4.5" fill="currentColor" />
      <path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
    </svg>
  </button>
</template>

<style lang="scss" scoped>
$switch-border: 0.125rem;
$switch-focus-inset: -0.25rem;

.theme-switch {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: $tap-target;
  height: $tap-target;
  padding: 0;
  border: $switch-border solid var(--text);
  background: var(--bg);
  color: var(--text);
  cursor: pointer;
  transition:
    background-color $transition-base,
    border-color $transition-base,
    color $transition-base;

  &:hover {
    border-color: var(--blue-light);
    color: var(--blue-light);
  }

  &:focus-visible {
    outline-offset: $switch-focus-inset;
  }

  // Dunkel: das Mond-Symbol steht für den aktuellen Stand, ein Klick führt zur Sonne. Hell andersherum.
  &__icon--sun {
    display: none;
  }

  :root[data-theme='light'] & &__icon--moon {
    display: none;
  }

  :root[data-theme='light'] & &__icon--sun {
    display: block;
  }

  @media (prefers-reduced-motion: reduce) {
    transition: none;
  }
}
</style>

Drag-Chip

Der kleine Tag bei den Technologien in der Erfahrung. Er lässt sich mit Maus oder Finger greifen, verschieben und springt beim Loslassen federnd zurück. Rein dekorativ: Inhalt bleibt ein normales Listenelement. Auf der 404-Seite steuert eine Physik seine Position.

  • Vue
  • Nuxt
  • TypeScript

Schnittstelle

Slot (default)
Die Beschriftung des Tags, zum Beispiel „Vue“.
Props
Alle optional. offset: Position {x, y}, die der Aufrufer bestimmt (dann kein Zurückfedern). still: nicht greifbar.
Events
grab, drag und release mit dem Pointer-Ereignis, damit der Aufrufer die Position berechnen kann.

Barrierefreiheit

  • Für Screenreader ein gewöhnliches Listenelement, es braucht keinen Fokus und keine Bedienung.
  • Bei „reduzierte Bewegung“ springt der Tag ohne Federn zurück.
  • touch-action: none verhindert, dass beim Ziehen am Tag die Seite scrollt.

Verwendung in der Erfahrung

QuelltextHTML
<ul class="chips">
  <DragChip>Vue</DragChip>
  <DragChip>Nuxt</DragChip>
</ul>
Quelltext anzeigenDragChip.vue
<script setup lang="ts">
// Kleine Spielerei: der Tag lässt sich mit der Maus (oder dem Finger) greifen und verschieben.
// Allein springt er beim Loslassen zurück. Mit `offset` bestimmt der Aufrufer die Position (zum Beispiel eine Physik) und
// bekommt dafür die Ereignisse grab, drag und release. Rein dekorativ, der Inhalt bleibt ein normales Listenelement.
const props = defineProps<{
  offset?: { x: number; y: number }
  still?: boolean
}>()
const emit = defineEmits<{ grab: [event: PointerEvent]; drag: [event: PointerEvent]; release: [event: PointerEvent] }>()

const own = ref({ x: 0, y: 0 })
const position = computed(() => props.offset ?? own.value)
const dragging = ref(false)

let startX = 0
let startY = 0
let reduced = false

onMounted(() => {
  reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
})

function onDown(event: PointerEvent) {
  if (props.still || (event.button !== undefined && event.button > 0)) return
  dragging.value = true
  startX = event.clientX - position.value.x
  startY = event.clientY - position.value.y
  emit('grab', event)
  try {
    ;(event.currentTarget as HTMLElement | null)?.setPointerCapture?.(event.pointerId)
  } catch {
    // Pointer Capture nicht verfügbar: der Tag folgt dann nur, solange die Maus über ihm bleibt
  }
}
function onMove(event: PointerEvent) {
  if (!dragging.value) return
  if (!props.offset) own.value = { x: event.clientX - startX, y: event.clientY - startY }
  emit('drag', event)
}
function onUp(event: PointerEvent) {
  if (!dragging.value) return
  dragging.value = false
  if (!props.offset) own.value = { x: 0, y: 0 }
  emit('release', event)
}
</script>

<template>
  <li
    class="drag-chip"
    :class="{
      'drag-chip--dragging': dragging,
      'drag-chip--instant': reduced,
      'drag-chip--controlled': offset,
      'drag-chip--still': still
    }"
    :style="{ '--x': `${position.x}px`, '--y': `${position.y}px` }"
    @pointerdown="onDown"
    @pointermove="onMove"
    @pointerup="onUp"
    @pointercancel="onUp"
    @lostpointercapture="onUp"
  >
    <slot />
  </li>
</template>

<style lang="scss" scoped>
$chip-lift-shadow: 0 0.375rem 0.875rem $shadow-strong;
$drag-chip-padding: 0.1rem 0.6rem;
$chip-border: color-mix(in srgb, var(--text) 40%, transparent); // Textfarbe, blass: passt zu beiden Themes

.drag-chip {
  position: relative;
  padding: $drag-chip-padding;
  border: $hairline solid $chip-border;
  background: var(--bg);
  font-size: $font-sm;
  cursor: grab;
  touch-action: none; // sonst scrollt der Finger statt den Tag zu ziehen
  user-select: none;
  transform: translate(var(--x, 0), var(--y, 0)); // die Position setzt das Skript als Variablen, hier steht nur, was daraus wird
  transition:
    transform $duration-spring $ease-spring-strong, // federt beim Zurückspringen leicht über
    border-color $transition-fast;

  &:hover {
    border-color: var(--blue-light);
  }

  &--dragging {
    z-index: $z-lifted;
    border-color: var(--blue-light);
    box-shadow: $chip-lift-shadow;
    cursor: grabbing;
    transition: border-color $transition-fast; // beim Ziehen folgt der Tag ohne Verzögerung
  }

  &--instant {
    transition: none;
  }

  // Die Position kommt vom Aufrufer (jedes Bild neu), also kein Federn
  &--controlled {
    transition: border-color $transition-fast;
  }

  // Bei "reduzierter Bewegung" bleibt alles liegen
  &--still {
    cursor: default;
    pointer-events: none;
  }
}
</style>

404-Seite

Wer eine Adresse aufruft, die es nicht gibt, sieht erst ganz normal Text und darüber ein paar Tags. Dann fallen die Tags auf den Footer, prallen ab und bleiben liegen, und im Punktraster aus dem Hero leuchtet „404“ auf. Die Tags lassen sich greifen und wegwerfen, das Raster weicht dem Mauszeiger aus. Der Text ist der eigentliche Inhalt, alles andere ist Beigabe.

Schnittstelle

FallingStage
Prop labels (die Tags), Event fall (die Tags beginnen zu fallen) und ein Slot für den Inhalt. Rendert die Tags als DragChip und rechnet die Physik (utils/physics.ts). Der Boden ist die Unterkante der Bühne.
HeroBackdrop
Dasselbe Punktraster wie im Hero, hier über die ganze Bühne. Props sign (Text aus den Ziffern 0 und 4) und lit: die Schrift leuchtet auf, sobald lit true ist. Passt sie nicht ins Raster (schmale Fenster), bleibt sie weg.

Barrierefreiheit

  • Raster und Tags sind rein dekorativ (aria-hidden). Überschrift, Text und Links sind der echte Inhalt.
  • Bei „reduzierte Bewegung“ liegen die Tags sofort am Boden, nichts fällt und nichts lässt sich werfen. „404“ steht ohne Übergang im Raster.
  • Die Seite steht auf noindex und nicht in der Sitemap. Die Sprache folgt der Adresse (/en/… ist Englisch).

Verwendung auf der 404-Seite

QuelltextHTML
<FallingStage :labels="labels" @fall="fallen = true">
  <HeroBackdrop sign="404" :lit="fallen" />
  <h1>Diese Seite gibt es nicht</h1>
</FallingStage>
Quelltext anzeigenFallingStage.vue
<script setup lang="ts">
import { clampBody, isResting, settleBody, stepBody, type Body, type World } from '~/utils/physics'

// Bühne mit Boden: Der Inhalt (Slot) steht erst ganz normal da, dazwischen eine Reihe Tags (DragChip). Nach kurzer Zeit
// fallen sie auf den Boden der Bühne, prallen ab und bleiben liegen. Man kann sie greifen, wegwerfen und wieder fallen lassen.
// Die Tags sind rein dekorativ (aria-hidden). Bei "reduzierter Bewegung" liegen sie sofort am Boden und bewegen sich nicht.
// Das Event `fall` meldet, dass die Tags fallen (oder, bei reduzierter Bewegung, schon liegen).
const props = defineProps<{ labels: string[] }>()
const emit = defineEmits<{ fall: [] }>()

const DELAY = 1400 // ms, bis der erste Tag fällt
const STAGGER = 140 // ms zwischen den einzelnen Tags
const MAX_SPEED = 1800 // px/s, damit ein Wurf nicht aus dem Bild schießt
const STALE = 80 // ms: war die letzte Bewegung länger her, wird nicht geworfen
const MAX_STEP = 0.032 // s, größter Zeitschritt pro Bild

const root = ref<HTMLElement | null>(null)
const dragging = ref(-1)
const still = ref(false)
const offsets = ref(props.labels.map(() => ({ x: 0, y: 0 })))

let bodies: Body[] = []
let homes: { x: number; y: number }[] = []
let sizes: { w: number; h: number }[] = []
let world: World = { width: 0, height: 0, gravity: 2400, bounce: 0.42, friction: 0.88 }
let fallAt: number[] = []
let frame = 0
let last = 0
let clock = 0
let timer: ReturnType<typeof setTimeout> | undefined
let observer: ResizeObserver | null = null
let started = false // hat das Fallen begonnen?
let grab = { px: 0, py: 0, bx: 0, by: 0, t: 0 }

const speed = (value: number) => Math.max(-MAX_SPEED, Math.min(MAX_SPEED, value))

/** Liest Größe der Bühne und Position der Tags (Layoutwerte, die vom Verschieben unberührt bleiben). */
function readLayout() {
  const el = root.value!
  const items = Array.from(el.querySelectorAll<HTMLElement>('.drag-chip'))
  world = { ...world, width: el.clientWidth, height: el.clientHeight }
  homes = items.map((item) => ({ x: item.offsetLeft, y: item.offsetTop }))
  sizes = items.map((item) => ({ w: item.offsetWidth, h: item.offsetHeight }))
}

function sync() {
  offsets.value = bodies.map((b, i) => ({ x: b.x - homes[i]!.x, y: b.y - homes[i]!.y }))
}

function loop(time: number) {
  frame = 0
  const dt = last ? Math.min(MAX_STEP, (time - last) / 1000) : 0
  last = time
  clock += dt * 1000
  bodies = bodies.map((b, i) => (i === dragging.value || clock < fallAt[i]! ? b : stepBody(b, dt, world)))
  sync()
  const busy = dragging.value >= 0 || bodies.some((b, i) => clock < fallAt[i]! || !isResting(b, world))
  if (busy) frame = requestAnimationFrame(loop)
  else last = 0
}
function start() {
  if (!frame) frame = requestAnimationFrame(loop)
}

function startFalling() {
  started = true
  emit('fall')
  start()
}

function onGrab(event: PointerEvent, index: number) {
  const body = bodies[index]!
  dragging.value = index
  grab = { px: event.clientX, py: event.clientY, bx: body.x, by: body.y, t: event.timeStamp }
}

function onDrag(event: PointerEvent) {
  const index = dragging.value
  const dt = Math.max(1, event.timeStamp - grab.t) / 1000
  grab.t = event.timeStamp
  const body = bodies[index]!
  const next = clampBody({ ...body, x: grab.bx + event.clientX - grab.px, y: grab.by + event.clientY - grab.py }, world)
  bodies[index] = { ...next, vx: speed((next.x - body.x) / dt), vy: speed((next.y - body.y) / dt) }
  sync()
}

function onRelease(event: PointerEvent) {
  const index = dragging.value
  dragging.value = -1
  if (event.timeStamp - grab.t > STALE) bodies[index] = { ...bodies[index]!, vx: 0, vy: 0 }
  start()
}

// Ändert sich die Größe der Bühne (Fenster, nachgeladene Schrift, Umbruch), werden die Maße neu gelesen, sonst liegen die Tags
// nicht auf dem Boden. Vor dem Fallen bleiben sie einfach an ihrem neuen Platz.
function onResize() {
  readLayout()
  bodies = bodies.map((b, i) => {
    const sized = { ...b, ...sizes[i]! }
    if (still.value) return settleBody(sized, world)
    return started ? clampBody(sized, world) : { ...sized, ...homes[i]! }
  })
  sync()
  if (started) start()
}

onMounted(() => {
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  readLayout()
  bodies = homes.map((home, i) => ({ x: home.x, y: home.y, vx: 0, vy: 0, ...sizes[i]! }))
  fallAt = bodies.map((_, i) => i * STAGGER)
  window.addEventListener('resize', onResize)
  if (typeof ResizeObserver !== 'undefined') {
    observer = new ResizeObserver(onResize)
    observer.observe(root.value!)
  }
  if (reduced) {
    still.value = true
    bodies = bodies.map((b) => settleBody(b, world))
    sync()
    emit('fall')
  } else {
    timer = setTimeout(startFalling, DELAY)
  }
})

onBeforeUnmount(() => {
  clearTimeout(timer)
  if (frame) cancelAnimationFrame(frame)
  observer?.disconnect()
  window.removeEventListener('resize', onResize)
})
</script>

<template>
  <div ref="root" class="falling-stage">
    <slot name="before" />
    <ul class="falling-stage__list" aria-hidden="true">
      <DragChip
        v-for="(label, index) in labels"
        :key="label"
        :offset="offsets[index]!"
        :still="still"
        @grab="onGrab($event, index)"
        @drag="onDrag"
        @release="onRelease"
      >
        {{ label }}
      </DragChip>
    </ul>
    <slot />
  </div>
</template>

<style lang="scss" scoped>
.falling-stage {
  position: relative; // Bezugspunkt für die Maße (offsetLeft, clientHeight) und damit der Boden der Bühne
  isolation: isolate; // das Punktraster (z-index -1) bleibt innerhalb der Bühne

  &__list {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: center;
    gap: $space-2;
    margin: $space-2 0 $space-6;
    padding: 0;
    list-style: none;
  }
}
</style>
Zurück zur Startseite