Glas-Button
Ein Glas-Button für die eine Buchungshandlung über dem Foto: unscharfer Saum mit Lichtkante, dichter Kern unter dem Text, in Chromium mit echter Randbrechung.
Ein Button aus Glas für die eine Handlung über einem Foto oder Film. Ein schmaler Saum lässt den Hintergrund unscharf durchscheinen, eine Lichtkante zeichnet den Rand, ein Glanzpunkt folgt dem Zeiger, beim Drücken gibt das Glas leicht nach. Unter dem Text liegt ein dichter Kern, damit die Schrift auf jedem Bildausschnitt lesbar bleibt. In der Variante liquid bricht der Rand den Hintergrund wie eine Linse; das kann nur Chromium, die anderen Browser zeigen Frost.
Das Bild bleibt sichtbar, und trotzdem liegt der Button klar auf einer eigenen Ebene. Apple hat das Material 2025 als Liquid Glass bis hinunter zu Buttons und Schaltern eingeführt, viele kennen es also aus dem Betriebssystem. Lichtkante und Druckzustand geben dem Glas Gewicht, ohne dass eine Signalfarbe das Foto übertönt.
Wann einsetzen
Für eine einzelne Haupthandlung über Fotografie oder Film: „Verfügbarkeit prüfen“ im Foto-Hero eines Hotels, „Kollektion ansehen“ über einem Kampagnenbild, eine Aktion über einem Produktbild. Gut, wenn eine deckende Signalfläche das Bild zerschneiden würde.
Wann nicht
Auf einfarbigem hellem Grund, dort ist Glas unsichtbar. Nie mehr als ein Glas-Element pro Ansicht, also nicht zusammen mit Glas-Navigation. Nicht, wenn der Text im ungünstigsten Bildausschnitt unter 4,5:1 fällt. Nicht für Kanzlei, Praxis, Pflege, Handwerk. Nicht für Formular-Absenden in langen Formularen (dort button-zustaende).
Varianten
- frost: Unschärfe 12 px und Sättigung 1,6 hinter dem Glas, Lichtkante; läuft in allen aktuellen Browsern
- liquid: Rand bricht den Hintergrund per SVG-Displacement als backdrop-filter; nur Chromium, sonst Frost
- getoent: dunkler Markenton im Kern, heller Text, für die hervorgehobene Handlung (wie .glassProminent bei Apple)
- glanz: Lichtkante dreht zum Zeiger, ein Glanzband läuft einmal bei Hover oder Fokus über die Fläche
Umsetzung
Echter <button> oder <a>, Pille 52 bis 56 px hoch. Zwei Zonen: ein 6-px-Saum mit wenig Füllung (border transparent, Hintergrund border-box) und ein Kern als eigene Schicht unter dem Text, dessen Kante per box-shadow weich ausläuft. Die Kern-Deckkraft rechnet man so aus, dass der Text auch über reinem Schwarz bzw. Weiß 4,5:1 hält (hier .62 hell, .78 getönt). frost: backdrop-filter blur(12px) saturate(1.6). liquid: Displacement-Map per Canvas aus einem Squircle-Profil berechnen (wie liquid-glass-refraktion), als feImage in einen SVG-Filter (Blur, feDisplacementMap, saturate, Rim-Light), per backdrop-filter: url(#id) einbinden; Map je Größe cachen und per ResizeObserver neu bauen; Chromium per navigator.userAgentData erkennen. Fallback-Kette: liquid nur in Chromium, sonst frost; @supports not (backdrop-filter) Füllung .85; prefers-reduced-transparency deckend; forced-colors Systemfarben mit Rahmen. Glanzpunkt per radial-gradient an --mx/--my aus pointermove; glanz über @property --winkel und einen conic-gradient-Rand.
Robust umsetzen
Telefon. 52 px hoch, auf schmalen Bildschirmen volle Breite im Bild. Ohne Hover kein Glanzpunkt; beim Tippen sitzt er an der Fingerstelle und der Druckzustand gibt Rückmeldung. Auf schwachen Geräten frost statt liquid.
Ohne Bewegung. Kein Glanzband, kein Nachgeben beim Druck, kein wandernder Glanzpunkt. Zustände wechseln über Helligkeit und Lichtkante ohne Bewegung.
Barrierefrei. WCAG 1.4.3: Text mindestens 4,5:1 auch im ungünstigsten Bildausschnitt, gesichert über den dichten Kern statt über Unschärfe (hier rechnerisch 5,2:1 bzw. 5,1:1). 1.4.11: Fokus als Doppelring hell und dunkel, damit er auf hellem und dunklem Bild 3:1 hält. 2.5.8: Trefferfläche 52 bis 56 px. 2.4.7: Fokus sichtbar. prefers-reduced-transparency: deckende Fläche. forced-colors: Systemfarben und Rahmen. Deaktiviert über aria-disabled, das Label nennt den Grund; Laden über aria-busy, das Ergebnis kommt in einer role=status-Zeile.
Code
<!DOCTYPE html>
<!--
Glas-Button
Fundus · Buttons · https://cecodesign.de/lab/fundus/buttons/glas-button/
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>Glas-Button · 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: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; font-display: swap; }
@font-face { font-family: 'Mona Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/mona-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; 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; }
@property --winkel { syntax: '<angle>'; inherits: true; initial-value: -50deg; }
:root {
/* Haus Elsterhang · Rezept boutique-hotel-gastgeber */
--sand: #f3eee7; --creme: #fef2e3; --text: #3e3a37; --leise: #6b6560; --linie: #d5d1c8; --holz: #504032;
--ui: cubic-bezier(0.4, 0, 0.2, 1); /* UI 150–250 ms (Rezept) */
--weich: cubic-bezier(0.37, 0, 0.63, 1); /* token-luxus-weich: Bilder, Glanzband */
--rand: clamp(16px, 3.2vw, 44px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--sand); color: var(--text); -webkit-font-smoothing: antialiased; }
:root:has(:focus-visible) { scroll-behavior: auto; }
body { font: 400 1.0625rem/1.6 'Mona Sans', system-ui, sans-serif; font-stretch: 100%; overflow-x: hidden; }
img { display: block; max-width: 100%; }
a { color: inherit; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.label { font: 600 0.75rem/1.2 'Mona Sans', system-ui, sans-serif; font-stretch: 118%; letter-spacing: .06em; text-transform: uppercase; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
/* ---------- Demo-Leiste (im Fluss, nicht fest) ---------- */
.pult { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; padding: 10px var(--rand); border-bottom: 1px solid var(--linie); background: #ebe5dc; color: #57514c; }
.pult__gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.pult__gruppe > span { margin-right: 6px; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: #57514c; background: none; border: 1px solid transparent; border-radius: 999px; padding: 6px 10px; cursor: pointer; transition: background-color 150ms var(--ui), color 150ms var(--ui); }
.pult button:hover { background: rgb(62 58 55 / .08); }
.pult button[aria-pressed="true"] { background: var(--text); color: var(--creme); }
.pult output { margin-left: auto; color: #57514c; }
.pult output b { font-weight: 600; color: var(--text); }
/* ---------- Kopf ---------- */
.kopf { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; padding: 18px var(--rand); }
.marke { display: flex; align-items: baseline; gap: 12px; text-decoration: none; }
.marke b { font: 400 1.65rem/1 'Newsreader', Georgia, serif; font-optical-sizing: auto; letter-spacing: -.015em; }
.marke span { color: var(--leise); }
.kopf nav { display: flex; gap: 26px; }
.kopf nav a { text-decoration: none; padding: 12px 0; }
.kopf nav a:hover { text-decoration: underline; text-underline-offset: .35em; text-decoration-thickness: 1px; }
.kopf__rechts { justify-self: end; display: flex; gap: 14px; align-items: center; color: var(--leise); }
.kopf__rechts a { text-decoration: none; padding: 12px 2px; }
.kopf__rechts a[aria-current] { color: var(--text); }
/* ---------- Hero ---------- */
.hero { padding: 0 var(--rand); }
.hero__text { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 4fr); gap: 24px 48px; align-items: end; padding: clamp(10px, 2.5vw, 34px) 0 clamp(18px, 2.4vw, 32px); }
h1 { font: 300 clamp(2.5rem, 1.5rem + 4vw, 5.5rem)/1.02 'Newsreader', Georgia, serif; font-optical-sizing: auto; letter-spacing: -.015em; text-wrap: balance; }
h1 em { font-style: italic; font-weight: 300; }
.hero__intro { max-width: 36ch; padding-bottom: .6em; }
.hero__intro .label { color: var(--leise); display: block; margin-bottom: 10px; }
.buehne { position: relative; border-radius: 3px; overflow: hidden; height: clamp(380px, 58svh, 640px); background: #d4bfba; }
.buehne picture { display: block; height: 100%; }
.buehne img { width: 100%; height: 100%; object-fit: cover; object-position: 50% var(--bild-y, 62%); scale: 1.06; transition: scale 1200ms var(--weich); }
.geladen .buehne img { scale: 1; }
.buehne__unten { position: absolute; left: clamp(12px, 1.6vw, 22px); right: clamp(12px, 1.6vw, 22px); bottom: clamp(12px, 1.6vw, 22px); display: flex; align-items: center; justify-content: flex-start; gap: 12px; flex-wrap: wrap; }
/* Buchungsleiste: deckende Creme, kein Glas (nur ein Glas-Element pro Ansicht) */
.leiste { display: grid; grid-template-columns: repeat(3, minmax(0, auto)); background: var(--creme); border-radius: 3px; box-shadow: 0 8px 26px rgb(43 36 33 / .14); }
.feld { display: grid; gap: 2px; padding: 10px 18px 10px; border-left: 1px solid var(--linie); min-width: 0; }
.feld:first-child { border-left: 0; }
.feld .label { color: var(--leise); }
.feld select { font: 500 1rem/1.3 'Mona Sans', system-ui, sans-serif; color: var(--text); background: transparent; border: 0; padding: 4px 22px 4px 0; min-height: 32px; appearance: none; cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 9px) 52%, calc(100% - 4px) 52%; background-size: 5px 5px; background-repeat: no-repeat; max-width: 100%; }
.feld select:focus-visible { outline-offset: 2px; border-radius: 2px; }
.status { min-height: 1.6em; padding: 12px 0 0; color: var(--text); max-width: 72ch; }
.status[data-art="voll"] { color: #7a2e1c; }
.unterzeile { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0 0; color: var(--leise); }
/* ---------- Der Glas-Button ---------- */
.glas {
/* Kern unter dem Text: Deckkraft so gewählt, dass der Text auch über reinem Schwarz 4,5:1 hält */
--kern: rgb(254 242 227 / .62); --saum: rgb(254 242 227 / .16); --schrift: #2b2421;
--kante-hell: rgb(255 255 255 / .78); --kante-matt: rgb(255 255 255 / .28);
--mx: 50%; --my: 0%;
position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: 10px;
height: 56px; min-width: 44px; padding: 0 22px; border: 6px solid transparent; border-radius: 999px;
font: 650 0.8125rem/1 'Mona Sans', system-ui, sans-serif; font-stretch: 118%; letter-spacing: .06em; text-transform: uppercase; text-decoration: none; white-space: nowrap;
color: var(--schrift); cursor: pointer;
background: linear-gradient(var(--saum), var(--saum)) border-box;
box-shadow: 0 0 0 1px rgb(43 36 33 / .2), 0 10px 30px rgb(30 20 15 / .2);
transition: transform 250ms var(--ui), box-shadow 150ms var(--ui), background-color 150ms var(--ui);
-webkit-tap-highlight-color: transparent; touch-action: manipulation;
}
.glas svg { width: 18px; height: 18px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.glas__text { position: relative; }
/* Kern: dichte Füllung unter dem Text, Kante weich auslaufend in den Saum */
.glas__kern { position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 0; background: var(--kern); box-shadow: 0 0 5px 2px var(--kern); transition: background-color 150ms var(--ui), box-shadow 150ms var(--ui); }
/* Lichtkante am äußeren Rand (::before deckt die Border-Box) */
.glas::before { content: ''; position: absolute; inset: -6px; border-radius: inherit; pointer-events: none; z-index: 1;
box-shadow: inset 1.5px 1.5px 0 var(--kante-hell), inset -1px -1px 0 var(--kante-matt), inset 0 0 0 1px rgb(255 255 255 / .16); }
/* Glanzpunkt, folgt dem Zeiger */
.glas__licht { position: absolute; inset: -6px; border-radius: inherit; pointer-events: none; overflow: hidden; z-index: 0; opacity: 0; transition: opacity 150ms var(--ui);
background: radial-gradient(90px 70px at var(--mx) var(--my), rgb(255 255 255 / .5), rgb(255 255 255 / 0) 70%); }
.glas__licht::after { content: ''; position: absolute; inset: 0; translate: -110% 0; opacity: 0;
background: linear-gradient(100deg, transparent 30%, rgb(255 255 255 / .62) 48%, rgb(255 255 255 / .2) 54%, transparent 70%); }
.glas > :not(.glas__licht):not(.glas__kern) { position: relative; z-index: 2; }
@media (hover: hover) {
.glas:hover:not([aria-disabled="true"]) .glas__licht { opacity: 1; }
.glas:hover:not([aria-disabled="true"]) { box-shadow: 0 0 0 1px rgb(43 36 33 / .24), 0 14px 38px rgb(30 20 15 / .26); }
}
.glas.ist-hover .glas__licht { opacity: 1; }
.glas.ist-hover { box-shadow: 0 0 0 1px rgb(43 36 33 / .24), 0 14px 38px rgb(30 20 15 / .26); }
/* Druck: das Glas gibt nach (ohne Federn, Rezept) */
.glas:active:not([aria-disabled="true"]), .glas.ist-druck { transform: scale(1.025, .97); transition-duration: 120ms; --kern: rgb(254 242 227 / .7); }
/* Fokus: Doppelring hell/dunkel, hält auf hellem und dunklem Bild */
.glas:focus-visible { outline: 2px solid #2b2421; outline-offset: 2px; box-shadow: 0 0 0 2px #fef2e3, 0 10px 30px rgb(30 20 15 / .2); }
.glas:focus-visible .glas__licht { opacity: 1; }
/* Deaktiviert: Label sagt warum, Text behält vollen Kontrast */
.glas[aria-disabled="true"] { cursor: not-allowed; box-shadow: 0 0 0 1px rgb(43 36 33 / .2); }
.glas[aria-disabled="true"]::before { box-shadow: inset 0 0 0 1px rgb(255 255 255 / .2); }
.glas[aria-disabled="true"] .glas__text { text-decoration: line-through; text-decoration-thickness: 1px; }
/* Laden */
.glas[aria-busy="true"] { cursor: progress; }
.glas .dreh { display: none; }
.glas[aria-busy="true"] .dreh { display: block; animation: dreh 900ms linear infinite; }
.glas[aria-busy="true"] .icon { display: none; }
@keyframes dreh { to { rotate: 360deg; } }
/* Variante getönt: Markenton in der Fläche, heller Text, kein Glanzpunkt im Kern */
[data-v="getoent"] .glas { --kern: rgb(80 64 50 / .78); --saum: rgb(80 64 50 / .34); --schrift: #fffdf9; --kante-hell: rgb(255 255 255 / .5); --kante-matt: rgb(255 255 255 / .16); }
[data-v="getoent"] .glas__licht { -webkit-mask: linear-gradient(#000 0 0) exclude, linear-gradient(#000 0 0) content-box; mask: linear-gradient(#000 0 0) exclude, linear-gradient(#000 0 0) content-box; padding: 6px; }
[data-v="getoent"] .glas:active:not([aria-disabled="true"]), [data-v="getoent"] .glas.ist-druck { --kern: rgb(80 64 50 / .86); }
[data-v="getoent"] .glas:focus-visible { outline-color: #fef2e3; box-shadow: 0 0 0 2px #2b2421, 0 10px 30px rgb(30 20 15 / .2); }
/* Variante Glanz: Lichtkante dreht zum Zeiger (@property --winkel), Glanzband einmal bei Hover/Fokus */
[data-v="glanz"] .glas { transition: transform 250ms var(--ui), box-shadow 150ms var(--ui), --winkel 250ms var(--ui); }
[data-v="glanz"] .glas::before { box-shadow: inset 0 0 0 1px rgb(255 255 255 / .2); padding: 1.5px;
background: conic-gradient(from var(--winkel), rgb(255 255 255 / .95), rgb(255 255 255 / .12) 22%, rgb(255 255 255 / .05) 50%, rgb(255 255 255 / .12) 78%, rgb(255 255 255 / .95));
-webkit-mask: linear-gradient(#000 0 0) exclude, linear-gradient(#000 0 0) content-box; mask: linear-gradient(#000 0 0) exclude, linear-gradient(#000 0 0) content-box; }
@media (hover: hover) { [data-v="glanz"] .glas:hover:not([aria-disabled="true"]) .glas__licht::after { animation: glanzband 900ms var(--weich) 1 both; } }
[data-v="glanz"] .glas.ist-hover .glas__licht::after, [data-v="glanz"] .glas:focus-visible .glas__licht::after { animation: glanzband 900ms var(--weich) 1 both; }
@keyframes glanzband { 0% { translate: -110% 0; opacity: 1; } 100% { translate: 110% 0; opacity: 1; } }
/* ---------- Fallback-Kette ---------- */
/* 1 liquid: SVG-Refraktion als backdrop-filter (nur Chromium) */
[data-modus="liquid"] .glas { backdrop-filter: var(--filter-url); }
/* 2 frost: alle Browser mit backdrop-filter */
[data-modus="frost"] .glas { -webkit-backdrop-filter: blur(12px) saturate(1.6); backdrop-filter: blur(12px) saturate(1.6); }
/* 3 ohne backdrop-filter: fast deckende Fläche */
[data-modus="ohne"] .glas { --kern: rgb(254 242 227 / .85); --saum: rgb(254 242 227 / .85); }
[data-modus="ohne"][data-v="getoent"] .glas { --kern: rgb(80 64 50 / .9); --saum: rgb(80 64 50 / .9); }
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glas { --kern: rgb(254 242 227 / .85); --saum: rgb(254 242 227 / .85); }
[data-v="getoent"] .glas { --kern: rgb(80 64 50 / .9); --saum: rgb(80 64 50 / .9); }
}
/* 4 Transparenz reduziert: deckend, ohne Blur */
[data-modus="deckend"] .glas { -webkit-backdrop-filter: none; backdrop-filter: none; --kern: #fef2e3; --saum: #fef2e3; }
[data-modus="deckend"][data-v="getoent"] .glas { --kern: #504032; --saum: #504032; }
@media (prefers-reduced-transparency: reduce) {
.glas { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; --kern: #fef2e3 !important; --saum: #fef2e3 !important; }
[data-v="getoent"] .glas { --kern: #504032 !important; --saum: #504032 !important; }
}
.filter { position: absolute; width: 0; height: 0; overflow: hidden; }
/* simulierter Zeiger für die Vorführung */
.zeiger { position: fixed; left: 0; top: 0; width: 22px; height: 22px; pointer-events: none; z-index: 30; opacity: 0; transition: opacity 250ms var(--ui); filter: drop-shadow(0 2px 3px rgb(0 0 0 / .3)); }
.vorfuehrung .zeiger { opacity: 1; }
/* ---------- Zimmer ---------- */
.zimmer { display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 7fr); gap: clamp(20px, 4vw, 64px); align-items: end; padding: clamp(56px, 8vw, 120px) var(--rand) clamp(40px, 6vw, 80px); }
.zimmer h2 { font: 300 clamp(2rem, 1.3rem + 2.4vw, 3.4rem)/1.05 'Newsreader', Georgia, serif; letter-spacing: -.015em; margin: 10px 0 18px; }
.zimmer h2 em { font-style: italic; }
.zimmer .label { color: var(--leise); }
.zimmer dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 18px; margin: 22px 0 0; padding-top: 18px; border-top: 1px solid var(--linie); }
.zimmer dt { color: var(--leise); }
.zimmer__bild { position: relative; border-radius: 3px; overflow: hidden; aspect-ratio: 3 / 2; }
.zimmer__bild img { width: 100%; height: 100%; object-fit: cover; }
.zimmer__bild .glas { position: absolute; left: clamp(12px, 1.6vw, 22px); bottom: clamp(12px, 1.6vw, 22px); }
.fuss { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 24px; padding: 22px var(--rand) 34px; border-top: 1px solid var(--linie); color: var(--leise); }
/* ---------- Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .pult, .vorschau .zimmer, .vorschau .fuss, .vorschau .unterzeile { display: none; }
.vorschau .buehne { height: calc(100svh - 300px); min-height: 360px; }
/* ---------- Telefon ---------- */
@media (max-width: 860px) {
.kopf { grid-template-columns: minmax(0, 1fr) auto; }
.kopf nav { display: none; }
.hero__text { grid-template-columns: minmax(0, 1fr); }
.zimmer { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 720px) {
.marke span { display: none; }
.buehne { height: auto; min-height: 0; display: grid; }
.buehne picture, .buehne > img { grid-area: 1 / 1; }
.buehne picture { grid-area: 1 / 1; height: 100%; min-height: 520px; }
.buehne__unten { position: relative; grid-area: 1 / 1; align-self: end; left: auto; right: auto; bottom: auto; margin: 12px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
.leiste { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.feld { padding: 9px 10px; }
.feld select { font-size: .9375rem; }
.glas { width: 100%; height: 52px; }
.pult output { margin-left: 0; }
}
@media (max-width: 360px) {
.feld { padding: 8px 8px; }
.feld .label { letter-spacing: .02em; }
.glas { font-size: .75rem; letter-spacing: .04em; padding: 0 12px; }
}
@media (hover: none) { .pult button { padding: 10px 12px; } }
/* ---------- Reduzierte Bewegung ---------- */
.reduziert .buehne img { scale: 1; transition: none; }
.reduziert .glas, .reduziert .glas__licht, .reduziert .pult button { transition: none; }
.reduziert .glas:active, .reduziert .glas.ist-druck { transform: none; }
.reduziert .glas__licht::after { animation: none !important; }
.reduziert .glas[aria-busy="true"] .dreh { animation: none; }
.reduziert .zeiger { transition: none; }
/* Kontrastmodus: Systemfarben, Rahmen */
@media (forced-colors: active) {
button, [role=button], [role=tab], select, .glas { border: 1px solid ButtonText !important; }
.glas { background: ButtonFace; color: ButtonText; forced-color-adjust: none; }
.glas::before, .glas__licht, .glas__kern { display: none; }
}
</style>
</head>
<body>
<div class="pult mono" role="group" aria-label="Demo-Steuerung">
<div class="pult__gruppe" role="group" aria-label="Variante">
<span>Variante</span>
<button type="button" data-variante="frost" aria-pressed="true">Frost</button>
<button type="button" data-variante="liquid" aria-pressed="false">Liquid</button>
<button type="button" data-variante="getoent" aria-pressed="false">Getönt</button>
<button type="button" data-variante="glanz" aria-pressed="false">Glanz</button>
</div>
<div class="pult__gruppe" role="group" aria-label="Fallback simulieren">
<span>Browser</span>
<button type="button" data-stufe="auto" aria-pressed="true">Dieser</button>
<button type="button" data-stufe="ohne" aria-pressed="false">Ohne Blur</button>
<button type="button" data-stufe="deckend" aria-pressed="false">Transparenz aus</button>
</div>
<output data-werte aria-live="polite"></output>
</div>
<header class="kopf">
<a class="marke" href="#start"><b>Elsterhang</b><span class="label">Gästehaus · Plauen</span></a>
<nav aria-label="Hauptmenü"><a href="#zimmer">Zimmer</a><a href="#start">Frühstück</a><a href="#start">Anreise</a><a href="#start">Gutscheine</a></nav>
<div class="kopf__rechts label"><a href="#start" aria-current="true" lang="de">DE</a><a href="#start" lang="en">EN</a></div>
</header>
<main id="start">
<section class="hero" aria-labelledby="titel">
<div class="hero__text">
<h1 id="titel">Zwölf Zimmer über dem <em>Elstertal</em>.</h1>
<p class="hero__intro"><span class="label">Seit 1911 · am Stadtrand</span>Ein altes Spitzenfabrikanten-Haus am Hang, zwanzig Minuten zu Fuß bis zur Altstadt. Frühstück mit Brot aus dem Ort, abends Stille.</p>
</div>
<div class="buehne" data-buehne>
<picture>
<source media="(max-width: 720px)" srcset="bilder/huegel-nebel-hoch.jpg">
<img src="bilder/huegel-nebel.jpg" alt="Blick vom Haus: gestaffelte bewaldete Hügelketten im Morgendunst, darüber eine blasse tiefe Sonne" width="1200" height="800" fetchpriority="high">
</picture>
<form class="buehne__unten" data-form aria-label="Verfügbarkeit">
<div class="leiste">
<label class="feld"><span class="label">Anreise</span>
<select data-anreise>
<option value="14.11.">Fr 14. Nov</option>
<option value="21.11.">Fr 21. Nov</option>
<option value="05.12.">Fr 5. Dez</option>
<option value="31.12.">Mi 31. Dez</option>
</select>
</label>
<label class="feld"><span class="label">Nächte</span>
<select data-naechte><option value="1">1</option><option value="2" selected>2</option><option value="3">3</option><option value="4">4</option></select>
</label>
<label class="feld"><span class="label">Gäste</span>
<select data-gaeste><option value="1">1</option><option value="2" selected>2</option><option value="3">3</option></select>
</label>
</div>
<button class="glas" type="submit" data-glas data-cta>
<span class="glas__kern" aria-hidden="true"></span><span class="glas__licht" aria-hidden="true"></span>
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" data-icon><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
<svg class="dreh" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a9 9 0 1 0 9 9"/></svg>
<span class="glas__text" data-cta-text>Verfügbarkeit prüfen</span>
</button>
</form>
</div>
<p class="status" data-status role="status" aria-live="polite"></p>
<p class="unterzeile label"><span>Blick vom Haus über das Elstertal, Oktober</span><span>Gesamtpreise inkl. MwSt. und Frühstück</span></p>
</section>
<section class="zimmer" id="zimmer" aria-labelledby="zimmer-titel">
<div>
<p class="label">Zimmer</p>
<h2 id="zimmer-titel">Nachmittags wird das <em>Hangzimmer</em> still</h2>
<p>Gekalkte Wände, Bänke und Betten aus Eiche von der Tischlerei im Ort. Das Haus steht am Südhang über der Weißen Elster, aus jedem zweiten Zimmer sieht man die Hügel des Vogtlands.</p>
<dl>
<dt class="label">Altstadt</dt><dd>20 Minuten zu Fuß</dd>
<dt class="label">Bahnhof</dt><dd>10 Minuten mit dem Rad, Leihräder im Haus</dd>
<dt class="label">Parken</dt><dd>am Haus, ohne Gebühr</dd>
</dl>
</div>
<figure class="zimmer__bild">
<img src="bilder/fenster-licht.jpg" alt="Hangzimmer am Nachmittag: salbeigrüne Kalkputzwand mit dem Lichtbild des Fensters, davor eine Eichenbank mit dunkler Vase und trockenem Zweig" width="1200" height="800" loading="lazy">
<a class="glas" href="#zimmer" data-glas>
<span class="glas__kern" aria-hidden="true"></span><span class="glas__licht" aria-hidden="true"></span>
<span class="glas__text">Anreise planen</span>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
</figure>
</section>
<footer class="fuss label"><span>Elsterhang · Gästehaus · Am Elsterhang 4 · 08523 Plauen</span><span>gaeste@elsterhang.example</span></footer>
</main>
<svg class="zeiger" viewBox="0 0 24 24" aria-hidden="true" data-zeiger><path d="M5 3l14 8-6.2 1.6L10 19z" fill="#fffdf9" stroke="#2b2421" stroke-width="1.4" stroke-linejoin="round"/></svg>
<svg class="filter" aria-hidden="true" color-interpolation-filters="sRGB"><defs data-defs></defs></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const glaeser = $$('[data-glas]'), cta = $('[data-cta]'), ctaText = $('[data-cta-text]');
const form = $('[data-form]'), status = $('[data-status]'), werte = $('[data-werte]'), defs = $('[data-defs]');
const anreise = $('[data-anreise]'), naechte = $('[data-naechte]'), gaeste = $('[data-gaeste]');
const bild = $('[data-buehne] img'), zeiger = $('[data-zeiger]');
/* ---------- Varianten und Fallback-Stufe ---------- */
const VARIANTEN = ['frost', 'liquid', 'getoent', 'glanz'];
const ausUrl = new URLSearchParams(location.search).get('v');
let variante = VARIANTEN.includes(ausUrl) ? ausUrl : 'frost';
let stufe = 'auto';
const ua = navigator.userAgent;
const chromium = (navigator.userAgentData && navigator.userAgentData.brands.some(b => /Chromium/.test(b.brand)))
|| (/Chrom(e|ium)\//.test(ua) && !/Edg\/|OPR\/|CriOS|FxiOS|Firefox/.test(ua)) || /Edg\//.test(ua);
const kannBlur = CSS.supports('backdrop-filter', 'blur(1px)') || CSS.supports('-webkit-backdrop-filter', 'blur(1px)');
const transparenzAus = matchMedia('(prefers-reduced-transparency: reduce)');
function modus() {
if (stufe === 'deckend' || transparenzAus.matches) return 'deckend';
if (stufe === 'ohne' || !kannBlur) return 'ohne';
if (variante === 'liquid' && chromium) return 'liquid';
return 'frost';
}
/* ---------- Kontrast im ungünstigsten Fall (Text über reinem Schwarz bzw. Weiß hinter dem Glas) ---------- */
const lin = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const kontrast = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
const mischen = (o, a, u) => o.map((c, i) => a * c + (1 - a) * u[i]);
function schlechtester() {
const m = modus(), getoent = variante === 'getoent';
const ton = getoent ? [80, 64, 50] : [254, 242, 227], schrift = getoent ? [255, 253, 249] : [43, 36, 33];
const a = m === 'deckend' ? 1 : m === 'ohne' ? (getoent ? .9 : .85) : (getoent ? .78 : .62);
const w = [[0, 0, 0], [255, 255, 255]].map(u => kontrast(schrift, mischen(ton, a, u)));
return { a, k: Math.min(...w) };
}
/* ---------- Refraktion: Höhenprofil → Snellius → Displacement-Map (wie liquid-glass-refraktion) ---------- */
const N_GLAS = 1.5, STRAHLEN = 127, SCALE = 34, SCALE_DRUCK = 46, VORBLUR = 3, SAETTIGUNG = 1.6;
const squircle = x => Math.pow(1 - Math.pow(1 - x, 4), 0.25);
const tabelle = (() => {
const t = new Float32Array(STRAHLEN); let max = 0;
for (let i = 0; i < STRAHLEN; i++) {
const x = i / (STRAHLEN - 1), e = 1e-3, a = Math.max(0, x - e), b = Math.min(1, x + e);
const s = (squircle(b) - squircle(a)) / (b - a), ein = Math.atan(Math.abs(s)), aus = Math.asin(Math.sin(ein) / N_GLAS);
t[i] = Math.tan(ein - aus) * (squircle(x) + 0.5); max = Math.max(max, Math.abs(t[i]));
}
return t.map(v => v / max);
})();
const LICHT = -60 * Math.PI / 180, LX = Math.cos(LICHT), LY = Math.sin(LICHT);
const cache = new Map();
function karten(w, h, rand) {
const key = w + 'x' + h;
if (cache.has(key)) return cache.get(key);
const r = h / 2, c = document.createElement('canvas'), s = document.createElement('canvas');
c.width = s.width = w; c.height = s.height = h;
const cx = c.getContext('2d'), sx = s.getContext('2d'), d1 = cx.createImageData(w, h), d2 = sx.createImageData(w, h);
const hw = w / 2 - r, hh = h / 2 - r;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4, px = x + .5 - w / 2, py = y + .5 - h / 2;
const qx = Math.abs(px) - hw, qy = Math.abs(py) - hh;
const abst = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
d1.data[i] = 128; d1.data[i + 1] = 128; d1.data[i + 2] = 128; d1.data[i + 3] = 255;
if (abst > 0) continue;
let nx, ny;
if (qx > 0 && qy > 0) { const l = Math.hypot(qx, qy) || 1; nx = qx / l * Math.sign(px); ny = qy / l * Math.sign(py); }
else if (qx > qy) { nx = Math.sign(px); ny = 0; } else { nx = 0; ny = Math.sign(py); }
const t = Math.min(1, -abst / rand), m = tabelle[Math.round(t * (STRAHLEN - 1))];
d1.data[i] = 128 - nx * m * 127; d1.data[i + 1] = 128 - ny * m * 127;
const kante = Math.pow(Math.max(0, 1 - (-abst) / (rand * .4)), 1.6), dot = nx * LX + ny * LY;
d2.data[i] = d2.data[i + 1] = d2.data[i + 2] = 255; d2.data[i + 3] = 255 * .5 * kante * (.2 + .8 * (Math.max(0, dot) + Math.max(0, -dot) * .45));
}
cx.putImageData(d1, 0, 0); sx.putImageData(d2, 0, 0);
const out = { disp: c.toDataURL(), licht: s.toDataURL() };
cache.set(key, out); return out;
}
function filterFuer(el, i) {
const w = Math.round(el.offsetWidth), h = Math.round(el.offsetHeight);
if (w < 2 || h < 2) return '';
const k = karten(w, h, Math.min(h / 2, 18)), id = 'glas-' + i;
const fb = (href, res) => `<feImage href="${href}" x="0" y="0" width="${w}" height="${h}" preserveAspectRatio="none" result="${res}"/>`;
el.style.setProperty('--filter-url', `url(#${id})`);
return `<filter id="${id}" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x="0" y="0" width="${w}" height="${h}" color-interpolation-filters="sRGB">`
+ `<feGaussianBlur in="SourceGraphic" stdDeviation="${VORBLUR}" edgeMode="duplicate" result="frost"/>`
+ fb(k.disp, 'karte') + `<feDisplacementMap in="frost" in2="karte" scale="${SCALE}" xChannelSelector="R" yChannelSelector="G" result="gebrochen"/>`
+ `<feColorMatrix in="gebrochen" type="saturate" values="${SAETTIGUNG}" result="satt"/>`
+ fb(k.licht, 'licht') + `<feBlend in="licht" in2="satt" mode="screen"/></filter>`;
}
let gebaut = '';
function filterBauen() {
if (html.dataset.modus !== 'liquid') return;
const sig = glaeser.map(g => g.offsetWidth + 'x' + g.offsetHeight).join('|');
if (sig === gebaut) return;
gebaut = sig;
defs.innerHTML = glaeser.map(filterFuer).join('');
glaeser.forEach(g => { g.style.backdropFilter = 'none'; g.offsetWidth; g.style.backdropFilter = ''; }); // Chromium neu zeichnen
}
function druckStaerke(an) {
if (html.dataset.modus !== 'liquid' || reduziert()) return;
defs.querySelectorAll('feDisplacementMap').forEach(d => d.setAttribute('scale', an ? SCALE_DRUCK : SCALE));
}
function anwenden() {
html.dataset.v = variante;
html.dataset.modus = modus();
$$('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
$$('[data-stufe]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe)));
gebaut = ''; filterBauen();
const { a, k } = schlechtester(), m = html.dataset.modus;
const was = { liquid: `Refraktion · scale ${SCALE}/${SCALE_DRUCK}`, frost: `blur 12 px · saturate 1.6${variante === 'liquid' ? ' (liquid: Fallback)' : ''}`, ohne: 'ohne backdrop-filter', deckend: 'deckend' }[m];
werte.innerHTML = `<b>${was}</b> · Kern ${a === 1 ? '1' : a.toFixed(2).replace('0.', '.')} · Kontrast min <b>${k.toFixed(1).replace('.', ',')}:1</b>`;
}
function setzeVariante(v, url) {
variante = v; anwenden();
if (url) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(history.state, '', u); }
}
$$('[data-variante]').forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeVariante(b.dataset.variante, true); }));
$$('[data-stufe]').forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); stufe = b.dataset.stufe; anwenden(); }));
if (transparenzAus.addEventListener) transparenzAus.addEventListener('change', anwenden);
if ('ResizeObserver' in window) new ResizeObserver(() => filterBauen()).observe(cta);
addEventListener('resize', () => filterBauen());
/* ---------- Glanzpunkt und Glanzwinkel folgen dem Zeiger ---------- */
function glanzAn(g, x, y) {
const r = g.getBoundingClientRect();
g.style.setProperty('--mx', (x - r.left) + 'px'); g.style.setProperty('--my', (y - r.top) + 'px');
const w = Math.atan2(y - (r.top + r.height / 2), (x - (r.left + r.width / 2)) * (r.height / r.width)) * 180 / Math.PI + 90;
g.style.setProperty('--winkel', w.toFixed(1) + 'deg');
}
glaeser.forEach(g => {
g.addEventListener('pointermove', e => { if (!reduziert()) glanzAn(g, e.clientX, e.clientY); });
g.addEventListener('pointerdown', e => { glanzAn(g, e.clientX, e.clientY); if (g === cta && cta.getAttribute('aria-disabled') !== 'true') druckStaerke(true); });
['pointerup', 'pointercancel', 'pointerleave'].forEach(t => g.addEventListener(t, () => druckStaerke(false)));
g.addEventListener('focus', () => { const r = g.getBoundingClientRect(); glanzAn(g, r.left + r.width * .3, r.top); });
});
/* ---------- Buchungslogik: Laden, Ergebnis, ausgebucht ---------- */
const PREIS = { '14.11.': 118, '21.11.': 118, '05.12.': 132 };
let zustand = 'bereit', ladeUhr = null;
function setzeCta(art) {
zustand = art;
cta.removeAttribute('aria-busy'); cta.removeAttribute('aria-disabled'); cta.style.minWidth = '';
if (art === 'voll') {
cta.setAttribute('aria-disabled', 'true'); ctaText.textContent = 'Silvester ausgebucht';
status.dataset.art = 'voll'; status.textContent = 'Für Silvester sind alle zwölf Zimmer vergeben. Warteliste per E-Mail an gaeste@elsterhang.example.';
} else if (art === 'frei') {
ctaText.textContent = 'Zimmer wählen';
} else {
ctaText.textContent = 'Verfügbarkeit prüfen'; status.textContent = ''; delete status.dataset.art;
}
filterBauen();
}
function auswahlGeaendert() { clearTimeout(ladeUhr); setzeCta(anreise.value === '31.12.' ? 'voll' : 'bereit'); }
[anreise, naechte, gaeste].forEach(s => s.addEventListener('change', auswahlGeaendert));
function pruefen() {
if (zustand === 'voll' || zustand === 'laedt') return;
if (zustand === 'frei') { document.getElementById('zimmer').scrollIntoView({ behavior: reduziert() ? 'auto' : 'smooth' }); return; }
zustand = 'laedt';
cta.style.minWidth = cta.offsetWidth + 'px';
cta.setAttribute('aria-busy', 'true'); ctaText.textContent = 'Prüfe …';
status.textContent = '';
ladeUhr = setTimeout(() => {
const n = +naechte.value, p = PREIS[anreise.value] || 118, g = +gaeste.value;
const summe = n * (p + (g > 2 ? 34 : 0));
setzeCta('frei');
status.textContent = `Frei ab ${anreise.value}: Hangzimmer und Turmzimmer. ${n} ${n === 1 ? 'Nacht' : 'Nächte'} für ${g} ${g === 1 ? 'Gast' : 'Gäste'} ab ${summe} € inkl. Frühstück und MwSt.`;
}, 1100);
}
form.addEventListener('submit', e => { e.preventDefault(); pruefen(); });
cta.addEventListener('click', e => { if (cta.getAttribute('aria-disabled') === 'true') e.preventDefault(); });
$$('a[href^="#"]').forEach(a => a.addEventListener('click', e => { if (a.getAttribute('href') === '#start') e.preventDefault(); }));
/* ---------- Vorführung: Bild wandert unter dem Glas (hell → dunkel), Hover, Druck, Laden, ausgebucht ---------- */
let vorf = false, uhren = [], panId = 0, t0 = 0, pausiertBei = 0;
const RUNDE = 10500;
const SCHRITTE = [
[0, () => { auswahlZuruck(); bildY(18, 0); zeigerZu(.18, .2, 0); }],
[300, () => bildY(92, 9000)],
[700, () => zeigerZu(.5, .5, 1000)],
[1700, () => cta.classList.add('ist-hover')],
[3000, () => { cta.classList.add('ist-druck'); druckStaerke(true); }],
[3160, () => { cta.classList.remove('ist-druck'); druckStaerke(false); pruefen(); }],
[5600, () => { cta.classList.remove('ist-hover'); zeigerZu(.2, .9, 900); }],
[6800, () => { anreise.value = '31.12.'; auswahlGeaendert(); }],
[8000, () => { zeigerZu(.5, .5, 800); }],
[8800, () => cta.classList.add('ist-hover')],
[9900, () => { cta.classList.remove('ist-hover'); zeigerZu(.85, .2, 600); }]
];
function auswahlZuruck() { anreise.value = '14.11.'; naechte.value = '2'; gaeste.value = '2'; clearTimeout(ladeUhr); setzeCta('bereit'); }
function bildY(ziel, ms) {
const id = ++panId, von = parseFloat(getComputedStyle(bild).getPropertyValue('--bild-y')) || 62;
if (reduziert() || !ms) { bild.style.setProperty('--bild-y', (reduziert() ? (ziel > 50 ? 80 : 30) : ziel) + '%'); return; }
const s = performance.now();
const k = p => p < .5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
const tick = t => { if (id !== panId || !vorf) return; const p = Math.min(1, (t - s) / ms); bild.style.setProperty('--bild-y', (von + (ziel - von) * k(p)).toFixed(2) + '%'); if (p < 1) requestAnimationFrame(tick); };
requestAnimationFrame(tick);
}
/* Zeiger relativ zum CTA (fx, fy in Bruchteilen); 0 ms = springen */
let zx = 0, zy = 0, zId = 0;
function zeigerZu(fx, fy, ms) {
const r = cta.getBoundingClientRect(), x = r.left + r.width * fx, y = r.top + r.height * fy + (fy > .8 ? 60 : 0);
const id = ++zId, vx = zx, vy = zy, s = performance.now();
if (!ms || reduziert()) { zx = x; zy = y; setze(); return; }
const k = p => 1 - Math.pow(1 - p, 3);
const tick = t => { if (id !== zId) return; const p = Math.min(1, (t - s) / ms); zx = vx + (x - vx) * k(p); zy = vy + (y - vy) * k(p); setze(); if (p < 1) requestAnimationFrame(tick); };
requestAnimationFrame(tick);
function setze() { zeiger.style.transform = `translate(${zx}px, ${zy}px)`; if (!reduziert()) glanzAn(cta, zx, zy); }
}
function runde(ab) {
uhren.forEach(clearTimeout); uhren = [];
t0 = performance.now() - ab;
SCHRITTE.forEach(([t, f]) => { if (t >= ab) uhren.push(setTimeout(() => { if (vorf) f(); }, t - ab)); });
uhren.push(setTimeout(() => { if (vorf) runde(0); }, RUNDE - ab));
}
function vorfuehren() {
stopVorfuehrung();
vorf = true; html.classList.add('vorfuehrung');
runde(0);
}
function stopVorfuehrung() {
vorf = false; uhren.forEach(clearTimeout); uhren = []; panId++; zId++;
html.classList.remove('vorfuehrung'); cta.classList.remove('ist-hover', 'ist-druck');
}
function pause() { if (vorf) { pausiertBei = (performance.now() - t0) % RUNDE; stopVorfuehrung(); html.dataset.pausiert = ''; } }
function weiter() { if ('pausiert' in html.dataset) { delete html.dataset.pausiert; vorf = true; html.classList.add('vorfuehrung'); runde(pausiertBei); } }
function neustart() { stopVorfuehrung(); delete html.dataset.pausiert; auswahlZuruck(); bild.style.removeProperty('--bild-y'); anwenden(); }
['pointerdown', 'keydown'].forEach(t => document.addEventListener(t, e => { if (vorf && e.isTrusted && !e.target.closest('fundus-pult-knopf')) stopVorfuehrung(); }));
anwenden();
requestAnimationFrame(() => html.classList.add('geladen'));
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { gebaut = ''; filterBauen(); });
})();
</script>
</body>
</html>