Pattern library

This page shows the building blocks of this website: colors, type, layout and the reusable components. The source code is read straight from the website’s own files when the site is built, so it is always up to date and never a copy.

Colors

A dark and a light theme with exactly one accent color. All colors are CSS variables in main.scss, and the table is read from that file and shows the values of the theme that is active right now (switch in the header).

  • --bg #1a1d26

    Page background and the even sections.

  • --bg-alt #232937

    Background of the alternating sections (About, Skills, Contact).

  • --text #fff

    Body text and headings.

  • --on-blue #fff

    Text and icons on blue surfaces (button, scroll button).

  • --muted #aab1c0

    Secondary color for intros, hints and menu items at rest.

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

    Fine divider lines and borders (white at 14% opacity).

  • --blue #1d63f0

    Accent: button, logo symbol and the slash in front of section headings.

  • --blue-dark #1650c8

    Hover state of the button.

  • --blue-light #4f8cff

    Underline and border on hover (text link, drag chip) and the dots in the hero grid.

  • --focus #8db4ff

    Focus ring for keyboard use.

Contrast

Calculated from the variables according to WCAG 2.x. Text needs at least 4.5:1 (AA), controls and large text 3:1.

  • Body text on the page background

    --text / --bg · 16.8:1 · AAA

  • Secondary text on the page background

    --muted / --bg · 7.8:1 · AAA

  • Secondary text on alternating sections

    --muted / --bg-alt · 6.8:1 · AA

  • Button label

    --on-blue / --blue · 5.1:1 · AA

  • Button label on hover

    --on-blue / --blue-dark · 7.0:1 · AA

  • Underline and border on hover

    --blue-light / --bg · 5.2:1 · AA

  • Focus ring

    --focus / --bg · 8.1:1 · AAA

  • Accent (logo, slash), purely decorative

    --blue / --bg · 3.3:1 · 3:1 and up (controls, large text)

All variables in the source

Show source codeassets/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;
}

Typography

Two typefaces, both served from this site: loading the page makes no connection to Google.

  • Frontend development with care

    Roboto Slab

    Logo and headings (h1 to h3)

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

  • Components people enjoy using

    Space Grotesk

    Body text, menu, buttons and entry titles (h4)

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

Sizes

  • Section title · Roboto Slab 700 · 2 to 3.5 rem (fluid)

    Experience

  • Heading 3 · Roboto Slab 700 · 1.5 rem

    Professional experience

  • Heading 4 · Space Grotesk 500 · 1.15 rem

    Frontend Developer

  • Body text · Space Grotesk 400 · 1.0625 rem, line height 1.65

    I come from design and build interfaces with Vue, Nuxt and TypeScript.

  • The serif gives the headings character, the sans-serif grotesk keeps text and interface calm.
  • Text columns are limited to 65 characters so lines stay easy to read.
  • The body font is preloaded and uses font-display: optional. If it does not arrive in time, the fallback font stays instead of the text shifting later (layout shift).

Layout

A few fixed rules that apply everywhere.

Sections
Every section is at least 90% as tall as the window below the header. The next section peeks out at the bottom and shows that there is more. Alternating backgrounds (bg and bg-alt) separate sections without lines.
Page margin
A fluid margin (gutter) from 1.25 to 4 rem keeps content on the same edge at every width.
Header
From 1000 px the header is a single row and stays at the top. Below that it has two rows: logo and language switch on top, the menu items below, wrapping when needed.
Hero
From 1100 px the portrait sits as a background layer between the text on the left and the info blocks on the right. Below that it sits under the text.
Shapes
Angular, with no rounded corners and no glass or glow effects. The playful parts (dot grid in the hero, draggable tags) are purely decorative.
Usability
Click targets are at least 44 px tall. The focus ring is 3 px thick and visible everywhere. With “reduced motion”, transitions and animations are switched off or shortened.

Type and layout variables

  • --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 variables and mixins

Breakpoints, spacing, font sizes, minimum sizes and transitions live in their own SCSS file, because they are used in media queries or never change at runtime. Spacing follows a scale in steps of 0.25 rem, and with the mixins up(), down() and between() a breakpoint has the same name everywhere. Values that occur in only one place are local variables in their component. The file is loaded into every SCSS file at build time.

Show source codeassets/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;
  }
}

Components

The components are deliberately small. Each one comes with a preview to try out (mouse and keyboard), its interface, notes on accessibility and the real source code.

Button

The main action of a section, for example “Get in touch” in the hero. Technically a link that looks like a button, because it jumps to a section and does not trigger an action.

Interface

.button
Angular surface in blue with white text, at least 3 rem tall. Hover: blue-dark.

Accessibility

  • A real link: Enter opens the target and the focus ring is visible.
  • Hover only changes the color, nothing is reachable by mouse alone.
  • The contrast of the label is listed in the table under “Colors”.

Usage in the hero

Source codeHTML
<a class="button" href="#contact">Get in touch</a>
Show source codecomponents/_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);
  }
}

For all other links. The underline sits on the text and runs from left to right from white to blue on hover. The label lives in its own element, and only that carries the underline.

Interface

.text-link
Block: link at least 2.75 rem tall, optionally with an icon (0.5 rem gap).
.text-link__label
Element: the text with the animated underline.

Accessibility

  • The underline is visible at rest, so links are not recognizable by color alone.
  • Hover and keyboard focus trigger the same animation.
  • With “reduced motion” the underline changes without a transition.

Usage in the contact section

Source codeHTML
<a class="text-link" href="/cv/CV-Dustin-Clever.pdf" download>
  <span class="text-link__label">Resume (EN)</span>
</a>
Show source codecomponents/_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

The round arrow below the hero actions that leads to the next section. Technically an anchor link; after the click, focus lands on the heading of the target.

Interface

href
Target anchor, for example #about.
label
Accessible name, since the button only shows an icon.

Accessibility

  • A real link with an aria-label; the arrow itself is hidden from screen readers.
  • The click target is 4 rem across and the focus ring is visible.
  • Hover only changes the color; with “reduced motion” the transition is removed.

Usage in the hero

Source codeHTML
<ScrollButton href="#about" label="Scroll down" />
Show source codeScrollButton.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>

Language switch

Switches between German and English, styled like a light switch: the knob jumps to the active language and the track turns blue for EN. The component knows nothing about routes, everything comes in through props. The preview shows both states; the links stay on this page.

Interface

lang
Active language: de or en. Drives the knob and the color.
paths
Target path per language, for example { de: "/", en: "/en" }.
label
Label of the navigation for screen readers.

Accessibility

  • Two ordinary links with lang, hreflang and a spelled-out name (“English (EN)”); the active language carries aria-current="page".
  • The state is not shown by the knob position alone, but also by the inverted text color.
  • With “reduced motion” the knob moves without a transition.

Usage in the header

Source codeHTML
<LangSwitch lang="en" :paths="{ de: '/', en: '/en' }" label="Language" />
Show source codeLangSwitch.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 switch

Switches between the light and the dark design. On the first visit the system setting applies, after that the choice the browser remembers. A small script in the head sets the theme before the page is painted, so nothing flashes. The preview really switches, this page included.

Interface

label
Label for screen readers, for example “Light theme”. The state is in aria-pressed.
useTheme()
Shared state (theme, isLight, toggle). The colors themselves hang on data-theme on <html> (main.scss), the logic lives in utils/theme.ts.

Accessibility

  • A real button with aria-label and aria-pressed (pressed = light design), operable by keyboard.
  • Both icons are in the HTML, the CSS decides which one is visible. The icon alone carries no meaning, the name comes from the label.
  • Only the choice (dark or light) is stored in localStorage, without a cookie. If storage is blocked, the choice lasts until reload. With “reduced motion” there are no transitions.

Usage in the header

Source codeHTML
<ThemeSwitch label="Light theme" />
Show source codeThemeSwitch.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

The small tag for the technologies in the experience section. You can grab it with mouse or finger, move it, and it springs back when released. Purely decorative: the content stays a normal list item. On the 404 page a physics simulation controls its position.

  • Vue
  • Nuxt
  • TypeScript

Interface

Slot (default)
The label of the tag, for example “Vue”.
Props
All optional. offset: a position {x, y} set by the caller (no springing back then). still: cannot be grabbed.
Events
grab, drag and release with the pointer event, so the caller can work out the position.

Accessibility

  • For screen readers an ordinary list item, it needs no focus and no interaction.
  • With “reduced motion” the tag jumps back without springing.
  • touch-action: none stops the page from scrolling while you drag the tag.

Usage in the experience section

Source codeHTML
<ul class="chips">
  <DragChip>Vue</DragChip>
  <DragChip>Nuxt</DragChip>
</ul>
Show source codeDragChip.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 page

Anyone who opens an address that does not exist first sees ordinary text with a few tags above it. Then the tags fall onto the footer, bounce and stay there, and “404” lights up in the dot grid from the hero. You can grab the tags and throw them, and the grid moves away from the pointer. The text is the actual content, everything else is a bonus.

Interface

FallingStage
Prop labels (the tags), event fall (the tags start to fall) and a slot for the content. Renders the tags as DragChip and runs the physics (utils/physics.ts). The floor is the bottom edge of the stage.
HeroBackdrop
The same dot grid as in the hero, here across the whole stage. Props sign (text made of the digits 0 and 4) and lit: the lettering lights up as soon as lit is true. If it does not fit into the grid (narrow windows) it is left out.

Accessibility

  • Grid and tags are purely decorative (aria-hidden). Heading, text and links are the real content.
  • With “reduced motion” the tags lie on the floor right away, nothing falls and nothing can be thrown. “404” shows in the grid without a transition.
  • The page is set to noindex and is not in the sitemap. Its language follows the address (/en/… is English).

Usage on the 404 page

Source codeHTML
<FallingStage :labels="labels" @fall="fallen = true">
  <HeroBackdrop sign="404" :lit="fallen" />
  <h1>This page does not exist</h1>
</FallingStage>
Show source codeFallingStage.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>
Back to the home page