Holo-/Irisierungsfolie auf Neigung und Cursor
Geschenkkarte eines Münchner Concept Stores: schwarze Karte mit holografischer Heißfolie, die beim Zeigen kippt und schimmert; Folie wahlweise in den Guillochen, vollflächig oder nur im Rahmen.
Eine Karte (Ticket, Mitgliedsausweis, Gutschein, Sammelkarte) bekommt über dem Motiv eine Glanzschicht aus wiederholten Regenbogen-Verläufen mit fünf bis sieben Spektralfarben und diagonalen Streifen. Die Schicht wird per mix-blend-mode color-dodge mit dem Motiv verrechnet und über brightness, contrast und saturate abgestimmt. Zeigerposition oder Geräteneigung landen in Custom Properties. Die Verläufe verschieben sich gegenläufig, ein radialer Glare folgt dem Zeiger. Meist kippt die Karte dazu in 3D, und eine Maske begrenzt den Glanz auf Teile der Karte.
Der Effekt erinnert an Holo-Sammelkarten, Ausweise und Tickets. Der Farbwechsel belohnt jede Bewegung und lässt ein digitales Objekt wie etwas wirken, das man besitzen kann.
Wann einsetzen
Event- und Konferenz-Tickets, Mitgliedskarten, Gutscheine, Sammel- und Gamification-Elemente, Kampagnen-Microsites, einzelne Produkt-Highlights.
Wann nicht
Als Dauerdekor auf ganzen Seiten oder auf Fließtext. Bei seriösen Marken aus Recht, Finanzen oder Gesundheit. Auf Kacheln mit viel Text, weil color-dodge die Lesbarkeit verschlechtert. Nicht auf jeder Produktkarte eines Shops, dort wirkt es wie ein Gadget.
Varianten
- Regular Holo: Spektral-Verlauf und diagonale Streifen über der ganzen Karte
- Reverse Holo: nur der Rahmen schimmert, das Motiv bleibt matt
- Masken-Holo: Glanz nur in einer Textur oder einem Muster, etwa Glitzer oder Guilloche
- Geräteneigung per DeviceOrientation auf Mobilgeräten
- WebGL-Variante mit Dünnschicht-Interferenz (iridescence in MeshPhysicalMaterial von three.js)
- Statischer Irisierungs-Verlauf ohne Interaktion, Glanz nur beim Hover
Umsetzung
Karte mit transform: perspective(600px) rotateX(var(--rx)) rotateY(var(--ry)). Darüber zwei Ebenen. Die Glanzebene trägt repeating-linear-gradient(133deg, hsl(0 100% 70%) 0%, hsl(60 100% 70%) 5%, hsl(120 100% 70%) 10%, hsl(200 100% 70%) 15%, hsl(280 100% 70%) 20%) mit background-size 400 % und background-position aus den Custom Properties, dazu mix-blend-mode: color-dodge und filter: brightness(.85) contrast(2.75) saturate(.65). Ihre Deckkraft hängt am Abstand des Zeigers zur Mitte. Die Glare-Ebene ist ein radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsl(0 0% 100% / .6), transparent 60%) mit mix-blend-mode: overlay. JS setzt bei pointermove Prozentwerte, gedrosselt per requestAnimationFrame, und führt die Karte beim Verlassen weich in die Mitte zurück. Für Masken-Holo bekommt die Glanzebene ein mask-image, etwa ein SVG-Muster als Data-URL. color-dodge braucht einen dunklen Grund; auf hellem Papier wird der Glanz weiß.
Robust umsetzen
Telefon. Ohne Hover: Ziehen auf der Karte, Geräteneigung per DeviceOrientation (auf iOS nur nach Erlaubnis des Nutzers) oder eine langsame Auto-Animation des Glanzes. Sonst ein statischer Zustand.
Ohne Bewegung. Neigung und Auto-Animation aus. Der Holo-Glanz bleibt statisch in Mittelstellung.
Barrierefrei. Text nicht unter die Glanzschicht legen oder den Glanz dort maskieren. Schnell flackernde Farbwechsel vermeiden (WCAG 2.3.1). Die Ebenen sind Dekoration: aria-hidden oder Pseudo-Elemente. Die Karte selbst bekommt eine Beschreibung, etwa role=img mit aria-label.
Code
<!DOCTYPE html>
<!--
Holo-/Irisierungsfolie auf Neigung und Cursor
Fundus · Glas & Material · https://cecodesign.de/lab/fundus/glas-material/holo-irisierende-folie/
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>Holo-/Irisierungsfolie auf Neigung und Cursor · 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: 'Zalando Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/zalando-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: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
/* Rezept mode-beauty-kollektion: warmes Off-White, Fast-Schwarz mit Rotstich, UI zweifarbig */
--grund: #f6f3ee; --text: #121010; --flaeche: #ebe7e0; --linie: #cecdc6; --meta: #736d64; --signal: #a3402a;
--kurve: cubic-bezier(0.25, 1, 0.5, 1); /* Rezept (Toteme --Carousel_Ease) */
--ui: 250ms;
--rand: clamp(18px, 3vw, 44px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Zalando Sans', system-ui, sans-serif; font-size: 100%; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; }
button, input { font: inherit; color: inherit; }
.mono { font: 400 .75rem/1.4 'Fragment Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; }
.meta { color: var(--meta); }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 1.5px solid var(--text); outline-offset: 3px; }
/* ---------- Kopf ---------- */
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 64px; padding: 0 var(--rand); border-bottom: 1px solid var(--linie); }
.kopf nav { display: flex; gap: 22px; font-size: .9375rem; }
.kopf nav a, .konto a { text-decoration: none; background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat; padding-bottom: 2px; transition: background-size 100ms var(--kurve); }
.kopf nav a:hover, .konto a:hover { background-size: 100% 1px; }
.wortmarke-kopf { font-size: 1.375rem; font-weight: 500; font-stretch: 125%; letter-spacing: .32em; margin-right: -.32em; text-decoration: none; }
.konto { display: flex; gap: 22px; justify-self: end; font-size: .9375rem; font-variant-numeric: tabular-nums; }
.menue { display: none; background: none; border: 0; padding: 0; font-size: .9375rem; }
/* ---------- Produkt: Bild links, Kaufbox rechts ---------- */
.produkt { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); min-height: calc(100svh - 64px); }
.buehne { position: relative; background: var(--flaeche); display: grid; place-items: center; overflow: hidden; padding: clamp(32px, 5vw, 80px) clamp(20px, 4vw, 64px); }
.buehne::before { content: ''; position: absolute; inset: 0; background: radial-gradient(60% 50% at 50% 42%, rgba(255, 255, 255, .55), transparent 70%); pointer-events: none; }
.buehne figcaption { position: absolute; left: var(--rand); right: var(--rand); bottom: 18px; display: flex; justify-content: space-between; gap: 16px; }
/* Die Karte */
.karte { --rx: 0deg; --ry: 0deg; --mx: 50%; --my: 50%; --bx: 50%; --by: 50%; --o: .35; --naehe: 0;
position: relative; width: min(560px, 100%); aspect-ratio: 85.6 / 54; perspective: 600px; touch-action: none; cursor: grab; z-index: 1; }
.karte:active { cursor: grabbing; }
.schatten { position: absolute; left: 6%; right: 6%; bottom: -9%; height: 30%; border-radius: 50%; background: radial-gradient(closest-side, rgba(18, 16, 16, .32), transparent); filter: blur(8px);
transform: translate(calc(var(--ry) * -1.2px / 1deg), calc(var(--rx) * 1px / 1deg)); }
.koerper { position: absolute; inset: 0; border-radius: 4.6% / 7.3%; overflow: hidden; isolation: isolate;
transform: rotateX(var(--rx)) rotateY(var(--ry)); will-change: transform;
background: radial-gradient(120% 90% at 20% 0%, #2e2826, transparent 60%), linear-gradient(160deg, #221d1b, #121010 58%, #191514);
box-shadow: inset 0 0 0 1px rgba(246, 243, 238, .08), 0 1px 2px rgba(18, 16, 16, .3), 0 30px 60px -24px rgba(18, 16, 16, .45); }
.guilloche { position: absolute; inset: 0; width: 100%; height: 100%; color: rgba(246, 243, 238, .085); }
.inhalt { position: absolute; inset: 0; padding: 7.2% 7.6%; display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto 1fr auto; color: #e9e3d9; }
.inhalt .zeichen { font-size: clamp(1.25rem, 3.4cqi, 1.75rem); }
.karte { container-type: inline-size; }
.fuer i { font-style: normal; }
@container (max-width: 440px) { .fuer i { display: none; } .art { font-size: 9px; } }
.zeichen { font-weight: 500; font-stretch: 125%; letter-spacing: .3em; line-height: 1; }
.art { justify-self: end; text-align: right; font-size: clamp(9px, 1.9cqi, 11px); color: rgba(233, 227, 217, .62); }
.fuer { grid-row: 3; align-self: end; font-size: clamp(12px, 2.7cqi, 15px); font-weight: 400; }
.fuer span { display: block; font: 400 clamp(8px, 1.7cqi, 10px)/1.5 'Fragment Mono', monospace; letter-spacing: .06em; text-transform: uppercase; color: rgba(233, 227, 217, .55); margin-top: 6px; }
.betrag { grid-row: 3; grid-column: 2; align-self: end; font-size: clamp(2rem, 11cqi, 4rem); font-weight: 300; font-stretch: 125%; letter-spacing: -.01em; line-height: .9; }
.betrag small { font-size: .45em; font-weight: 400; margin-left: .12em; vertical-align: .9em; }
/* Folie auf der Wortmarke: gleicher Spektralverlauf, per background-clip auf die Schrift */
.folie-text { background: repeating-linear-gradient(133deg, #c9c4bd 0%, hsl(0 70% 78%) 5%, hsl(60 70% 80%) 10%, hsl(140 55% 76%) 15%, hsl(200 70% 78%) 20%, hsl(280 60% 80%) 25%, #c9c4bd 30%) var(--bx) var(--by) / 400% 400%;
-webkit-background-clip: text; background-clip: text; color: transparent; }
/* Glanzschicht: Spektrum + diagonale Streifen, color-dodge (poke-holo base.css) */
.glanz { position: absolute; inset: 0; pointer-events: none; z-index: 2;
background-image:
repeating-linear-gradient(133deg, hsl(0 100% 70%) 0%, hsl(60 100% 70%) 5%, hsl(120 100% 70%) 10%, hsl(200 100% 70%) 15%, hsl(280 100% 70%) 20%, hsl(360 100% 70%) 25%),
repeating-linear-gradient(-45deg, #0e152e 0%, hsl(180 10% 60%) 3.8%, hsl(180 29% 66%) 4.5%, hsl(180 10% 60%) 5.2%, #0e152e 10%, #0e152e 12%);
background-size: 400% 400%, 300% 300%;
background-position: var(--bx) var(--by), calc(100% - var(--bx)) calc(100% - var(--by));
background-blend-mode: overlay;
mix-blend-mode: color-dodge; filter: brightness(.85) contrast(2.75) saturate(.65);
opacity: var(--o); }
.glare { position: absolute; inset: 0; pointer-events: none; z-index: 3; mix-blend-mode: overlay; opacity: calc(.4 + var(--o) * .6);
background: radial-gradient(farthest-corner circle at var(--mx) var(--my), hsl(0 0% 100% / .6) 0%, hsl(0 0% 100% / .12) 30%, transparent 60%); }
/* Varianten: wo die Folie liegt */
[data-folie='rahmen'] .glanz { padding: 4.2%; -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); }
[data-folie='rahmen'] .folie-text { background: none; color: #e9e3d9; }
[data-folie='rahmen'] .koerper { box-shadow: inset 0 0 0 1px rgba(246, 243, 238, .14), 0 1px 2px rgba(18, 16, 16, .3), 0 30px 60px -24px rgba(18, 16, 16, .45); }
[data-folie='linien'] .glanz { -webkit-mask: var(--maske) center / 100% 100% no-repeat; mask: var(--maske) center / 100% 100% no-repeat; filter: brightness(1) contrast(2.2) saturate(.8); }
[data-folie='linien'] .guilloche { color: rgba(246, 243, 238, .16); }
/* Kaufbox */
.kauf { padding: clamp(28px, 4vw, 64px) var(--rand) clamp(28px, 3vw, 44px); display: flex; flex-direction: column; gap: 22px; }
.pfad { color: var(--meta); }
h1 { font-size: clamp(2rem, 3.4vw, 3.25rem); font-weight: 400; font-stretch: 125%; letter-spacing: -.01em; line-height: 1.05; text-wrap: balance; }
.formel { font-size: .9375rem; line-height: 1.5; display: flex; flex-wrap: wrap; gap: 2px 10px; }
.formel span:not(:last-child)::after { content: '·'; margin-left: 10px; color: var(--meta); }
.formel b { font-weight: 500; font-variant-numeric: tabular-nums; }
.wahl { border: 0; padding: 0; display: grid; gap: 10px; }
.wahl legend { display: flex; justify-content: space-between; width: 100%; padding: 0; margin-bottom: 10px; }
.wahl legend b { font-weight: 400; color: var(--text); }
.optionen { display: flex; flex-wrap: wrap; gap: 6px; }
.optionen label { position: relative; }
.optionen input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.optionen span { display: inline-flex; align-items: center; justify-content: center; min-width: 64px; height: 40px; padding: 0 14px; border: 1px solid var(--linie); font-size: .9375rem; font-variant-numeric: tabular-nums; transition: border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.optionen label:hover span { border-color: var(--text); }
.optionen input:checked + span { background: var(--text); border-color: var(--text); color: var(--grund); }
.optionen input:focus-visible + span { outline: 1.5px solid var(--text); outline-offset: 3px; }
.optionen .drueckt span { border-color: var(--text); }
.feld { display: grid; gap: 8px; }
.feld input { height: 44px; border: 1px solid var(--linie); background: transparent; padding: 0 14px; font-size: 1rem; transition: border-color var(--ui) var(--kurve); }
.feld input:focus { border-color: var(--text); outline: none; }
.tasche { height: 52px; border: 0; background: var(--text); color: var(--grund); font-size: 1rem; font-weight: 500; display: flex; justify-content: space-between; align-items: center; padding: 0 20px; cursor: pointer; transition: background-color var(--ui) var(--kurve); }
.tasche:hover { background: #2c2725; }
.tasche [data-preis] { font-variant-numeric: tabular-nums; }
.hinweise { list-style: none; padding: 0; display: grid; gap: 6px; color: var(--meta); font-size: .875rem; line-height: 1.45; }
.hinweise li { display: flex; gap: 10px; }
.hinweise li::before { content: ''; flex: none; width: 10px; height: 1px; background: var(--meta); margin-top: .72em; }
.werte { color: var(--meta); display: flex; flex-wrap: wrap; gap: 4px 14px; border-top: 1px solid var(--linie); padding-top: 14px; margin-top: auto; font-size: 10.5px; }
.werte b { color: var(--text); font-weight: 400; text-transform: none; letter-spacing: 0; }
.neigung { display: none; align-self: start; background: none; border: 1px solid var(--linie); height: 36px; padding: 0 12px; cursor: pointer; }
/* ---------- Darunter: Neu im Laden ---------- */
.neu { padding: clamp(56px, 7vw, 104px) var(--rand); border-top: 1px solid var(--linie); }
.neu-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; margin-bottom: 28px; }
h2 { font-size: clamp(1.5rem, 2.6vw, 2.25rem); font-weight: 400; font-stretch: 125%; letter-spacing: -.01em; }
.reihe { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(12px, 1.6vw, 24px); }
.reihe figure { aspect-ratio: 4 / 5; background: var(--flaeche); display: grid; place-items: center; }
.reihe svg { width: 34%; height: auto; overflow: visible; filter: drop-shadow(0 22px 18px rgba(18, 16, 16, .14)); }
.reihe h3 { font-size: .9375rem; font-weight: 500; margin-top: 12px; }
.reihe p { font-size: .9375rem; line-height: 1.45; color: var(--meta); font-variant-numeric: tabular-nums; }
.fuss { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; padding: 40px var(--rand) 56px; border-top: 1px solid var(--linie); font-size: .9375rem; line-height: 1.6; }
/* Gedachter Zeiger der Vorführung */
.zeiger { position: fixed; z-index: 6; left: 0; top: 0; width: 22px; height: 22px; margin: -11px 0 0 -11px; border: 1.5px solid var(--text); border-radius: 50%; background: rgba(246, 243, 238, .45); pointer-events: none; display: none; transition: scale 150ms var(--kurve); }
.zeiger.drueckt { scale: .7; }
.vorfuehrung .zeiger { display: block; }
/* ---------- Vorschau 960 × 720 ---------- */
.vorschau body { overflow: hidden; }
.vorschau .neu, .vorschau .fuss, .vorschau .feld, .vorschau .hinweise, .vorschau .werte, .vorschau .kopf nav, .vorschau .konto a:not(:last-child), .vorschau .pfad { display: none; }
.vorschau .produkt { height: calc(100vh - 64px); min-height: 0; }
.vorschau .kauf { justify-content: center; }
.vorschau .buehne figcaption span:first-child { display: none; }
.vorschau .wortmarke-kopf { grid-column: 2; justify-self: center; }
.vorschau .konto { grid-column: 3; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (max-width: 1100px) { .kopf nav a:nth-child(n+3) { display: none; } }
@media (max-width: 860px) {
.kopf { grid-template-columns: auto 1fr auto; }
.kopf nav { display: none; } .menue { display: block; }
.wortmarke-kopf { grid-column: 2; justify-self: center; }
.konto a:not(:last-child) { display: none; }
.produkt { grid-template-columns: 1fr; min-height: 0; }
.buehne { padding: 56px 22px 64px; }
.reihe { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.reihe li:nth-child(3) { display: none; }
.fuss { grid-template-columns: 1fr; }
}
@media (hover: none) {
.neigung { display: inline-flex; align-items: center; }
.optionen span { height: 44px; }
}
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
.folie-text { background: none !important; color: CanvasText !important; -webkit-text-fill-color: CanvasText !important; }
}
</style>
</head>
<body>
<header class="kopf">
<nav aria-label="Hauptmenü"><a href="#neu">Kleidung</a><a href="#neu">Hautpflege</a><a href="#neu">Journal</a><a href="#laden">Laden</a></nav>
<button class="menue" type="button" aria-expanded="false">Menü</button>
<a class="wortmarke-kopf" href="#titel">MILDE</a>
<div class="konto"><a href="#laden">Suche</a><a href="#laden">Konto</a><a href="#kauf">Tasche (1)</a></div>
</header>
<main>
<section class="produkt" aria-labelledby="titel">
<figure class="buehne">
<div class="karte" data-karte data-folie="linien" role="img" aria-label="Geschenkkarte von MILDE, schwarz mit holografischer Heißfolie, Betrag 100 Euro.">
<div class="schatten" aria-hidden="true"></div>
<div class="koerper" aria-hidden="true">
<svg class="guilloche" data-guilloche viewBox="0 0 856 540" preserveAspectRatio="none"></svg>
<div class="inhalt">
<span class="zeichen folie-text">MILDE</span>
<span class="art mono">Geschenkkarte<br>Nr. 0412 / 1000</span>
<span class="fuer" data-fuer-karte>Für Kathi<span>Edition Isarwinter<i> · Klenzestraße 41</i></span></span>
<span class="betrag" data-betrag-karte>100<small>€</small></span>
</div>
<div class="glanz"></div>
<div class="glare"></div>
</div>
</div>
<figcaption class="mono meta"><span>Heißfolie holografisch auf Karton, 0,76 mm</span><span data-zustand>Folie: Guilloche</span></figcaption>
</figure>
<div class="kauf" id="kauf">
<p class="pfad mono">Geschenke / Geschenkkarten</p>
<h1 id="titel">Geschenkkarte Edition Isarwinter</h1>
<p class="formel"><span>Karton 0,76 mm</span><span>Heißfolie holografisch</span><span>ab <b>50,00 €</b></span></p>
<fieldset class="wahl">
<legend class="mono meta"><span>Betrag</span><b data-betrag-text>100,00 €</b></legend>
<div class="optionen" data-betraege>
<label><input type="radio" name="betrag" value="50"><span>50 €</span></label>
<label><input type="radio" name="betrag" value="100" checked><span>100 €</span></label>
<label><input type="radio" name="betrag" value="150"><span>150 €</span></label>
<label><input type="radio" name="betrag" value="250"><span>250 €</span></label>
</div>
</fieldset>
<fieldset class="wahl">
<legend class="mono meta"><span>Veredelung</span><b data-folie-text>Folie in den Guillochen</b></legend>
<div class="optionen" data-varianten>
<label><input type="radio" name="folie" value="linien" checked><span>Guilloche</span></label>
<label><input type="radio" name="folie" value="voll"><span>Vollfläche</span></label>
<label><input type="radio" name="folie" value="rahmen"><span>Rahmen</span></label>
</div>
</fieldset>
<label class="feld"><span class="mono meta">Name auf der Karte (optional)</span><input type="text" maxlength="18" value="Kathi" data-name autocomplete="off"></label>
<button class="neigung mono" type="button" data-neigung>Neigung des Telefons nutzen</button>
<button class="tasche" type="button"><span>In die Tasche</span><span data-preis>100,00 €</span></button>
<ul class="hinweise">
<li>Versand in 1–2 Werktagen im Umschlag aus Baumwollpapier</li>
<li>Einlösbar online und im Laden in der Klenzestraße 41</li>
<li>Drei Jahre gültig, auch für Behandlungen im Hautraum</li>
</ul>
<p class="werte mono" data-werte></p>
</div>
</section>
<section class="neu" id="neu" aria-labelledby="neu-titel">
<div class="neu-kopf"><h2 id="neu-titel">Neu im Laden</h2><a class="mono meta" href="#neu">Alle 42 Produkte</a></div>
<ul class="reihe">
<li><figure><svg viewBox="0 0 120 200" aria-hidden="true"><rect x="44" y="8" width="32" height="26" rx="3" fill="#2a2524"/><rect x="50" y="34" width="20" height="12" fill="#8c857b"/><rect x="28" y="46" width="64" height="146" rx="10" fill="#c8a98a"/><rect x="28" y="46" width="64" height="146" rx="10" fill="url(#ol)"/><rect x="40" y="110" width="40" height="44" fill="#f6f3ee"/><defs><linearGradient id="ol" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity=".35"/><stop offset=".3" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".18"/></linearGradient></defs></svg></figure><h3>Gesichtsöl Nr. 3</h3><p>Squalan 40 % + Hagebutte 5 % · 30 ml · 38,00 €</p></li>
<li><figure><svg viewBox="0 0 160 200" aria-hidden="true"><path d="M30 20h100v150c0 10-8 16-18 16H48c-10 0-18-6-18-16Z" fill="#9a8b7a"/><path d="M30 20h100v24H30Z" fill="#7d6f60"/><g stroke="#f6f3ee" stroke-opacity=".25" stroke-width="2">
<path d="M30 60h100M30 80h100M30 100h100M30 120h100M30 140h100M30 160h100"/></g><path d="M58 186v10M74 186v10M90 186v10M106 186v10" stroke="#7d6f60" stroke-width="3"/></svg></figure><h3>Schal aus Yakwolle</h3><p>100 % Yak · 190 × 40 cm · 165,00 €</p></li>
<li><figure><svg viewBox="0 0 140 200" aria-hidden="true"><rect x="30" y="70" width="80" height="110" rx="4" fill="#d9d2c6"/><rect x="30" y="70" width="80" height="16" fill="#cfc7ba"/><path d="M70 70v-14" stroke="#2a2524" stroke-width="2"/><path d="M70 30c6 8 7 15 0 22-7-7-6-14 0-22Z" fill="#a3402a"/><rect x="44" y="118" width="52" height="30" fill="#f6f3ee"/></svg></figure><h3>Kerze Zirbe</h3><p>Rapswachs · 45 Stunden · 34,00 €</p></li>
</ul>
</section>
<footer class="fuss" id="laden">
<div><p class="mono meta">Laden</p><p>MILDE · Klenzestraße 41<br>80469 München</p></div>
<div><p class="mono meta">Öffnungszeiten</p><p>Di bis Fr 11 bis 19 Uhr<br>Sa 10 bis 18 Uhr</p></div>
<div><p class="mono meta">Service</p><p>Versand und Rückgabe<br>Termin im Hautraum</p></div>
</footer>
</main>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
<p class="nur-sr" aria-live="polite" data-ansage></p>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const karte = $('[data-karte]'), zeiger = $('[data-zeiger]'), ansage = $('[data-ansage]');
/* ---------- Werte (poke-holo base.css; Rückkehr nach Rezept-Kurve) ---------- */
const W = {
neigung: 12, // max. Grad, Messwert 10–15°
perspektive: 600, // px
verschiebung: 50, // Verlauf ± % der background-size
folgen: 0.16, // Nachlauf pro Frame bei 60 fps
rueckkehr: 500, // ms, Messwert 400–600
ruhe: 0.35, // Deckkraft der Glanzschicht ohne Zeiger
kurve: [0.25, 1, 0.5, 1],
};
const FOLIEN = { voll: 'Folie auf der ganzen Karte', rahmen: 'Folie nur im Rahmen', linien: 'Folie in den Guillochen' };
const KURZ = { voll: 'Vollfläche', rahmen: 'Rahmen', linien: 'Guilloche' };
function bezier(x1, y1, x2, y2) {
const A = (a, b) => 1 - 3 * b + 3 * a, B = (a, b) => 3 * b - 6 * a, C = a => 3 * a;
const f = (t, a, b) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t, d = (t, a, b) => 3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
return x => { if (x <= 0) return 0; if (x >= 1) return 1; let t = x; for (let i = 0; i < 6; i++) { const s = d(t, x1, x2); if (Math.abs(s) < 1e-6) break; t -= (f(t, x1, x2) - x) / s; } return f(t, y1, y2); };
}
const kurve = bezier(...W.kurve);
/* ---------- Guilloche: Rosette und Randwellen als SVG, zugleich Maske der Folie ---------- */
function guilloche() {
const P = [], rund = v => v.toFixed(1);
// Rosette rechts: 18 phasenverschobene Ringe r(θ) = R + a·sin(kθ + φ)
const cx = 640, cy = 250;
for (let i = 0; i < 18; i++) {
const R = 70 + i * 6.2, a = 14 + (i % 3) * 4, k = 12, ph = i * 0.42;
let d = '';
for (let j = 0; j <= 360; j++) { const t = j / 360 * Math.PI * 2, r = R + a * Math.sin(k * t + ph); d += (j ? 'L' : 'M') + rund(cx + r * Math.cos(t)) + ' ' + rund(cy + r * Math.sin(t) * .92); }
P.push(d + 'Z');
}
// Randband oben und unten: verschränkte Sinuslinien
[[38, 1], [502, -1]].forEach(([y0, s]) => {
for (let i = 0; i < 6; i++) {
let d = '';
for (let x = 30; x <= 826; x += 4) { const y = y0 + s * 6 + Math.sin(x / 22 + i * .9) * 7 * Math.cos(x / 140); d += (x === 30 ? 'M' : 'L') + x + ' ' + rund(y); }
P.push(d);
}
});
return P;
}
const pfade = guilloche();
const svg = $('[data-guilloche]');
svg.innerHTML = pfade.map(d => `<path d="${d}" fill="none" stroke="currentColor" stroke-width="1.6"/>`).join('');
const maske = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 856 540" preserveAspectRatio="none">${pfade.map(d => `<path d="${d}" fill="none" stroke="#000" stroke-width="2.6"/>`).join('')}</svg>`;
karte.style.setProperty('--maske', `url("data:image/svg+xml,${encodeURIComponent(maske)}")`);
/* ---------- Zustand: Ziel (Zeiger) und Ist, als Anteil 0…1 ---------- */
const ziel = { x: .5, y: .5, o: W.ruhe }, ist = { x: .5, y: .5, o: W.ruhe };
let rueck = null, laeuft = false, letzte = 0, aktiv = false;
function setzen() {
const nx = ist.x - .5, ny = ist.y - .5;
const s = karte.style;
s.setProperty('--ry', (nx * 2 * W.neigung).toFixed(2) + 'deg');
s.setProperty('--rx', (-ny * 2 * W.neigung).toFixed(2) + 'deg');
s.setProperty('--mx', (ist.x * 100).toFixed(1) + '%');
s.setProperty('--my', (ist.y * 100).toFixed(1) + '%');
s.setProperty('--bx', (50 + nx * 2 * W.verschiebung).toFixed(1) + '%');
s.setProperty('--by', (50 + ny * 2 * W.verschiebung).toFixed(1) + '%');
s.setProperty('--o', ist.o.toFixed(3));
}
function bild(t) {
const dt = Math.min(64, t - (letzte || t)) / 16.667; letzte = t;
if (vorfuehrt) vorfuehrSchritt(t);
else if (auto) autoSchritt(t);
if (rueck) {
const p = Math.min(1, (t - rueck.start) / W.rueckkehr), e = kurve(p);
ist.x = rueck.x + (.5 - rueck.x) * e; ist.y = rueck.y + (.5 - rueck.y) * e; ist.o = rueck.o + (W.ruhe - rueck.o) * e;
if (p >= 1) rueck = null;
} else {
const k = 1 - Math.pow(1 - W.folgen, dt);
ist.x += (ziel.x - ist.x) * k; ist.y += (ziel.y - ist.y) * k; ist.o += (ziel.o - ist.o) * k;
}
setzen();
const ruhig = !rueck && !aktiv && !vorfuehrt && !auto && Math.abs(ist.x - ziel.x) < .001 && Math.abs(ist.y - ziel.y) < .001;
if (ruhig) { laeuft = false; letzte = 0; return; }
requestAnimationFrame(bild);
}
function starten() { if (!laeuft) { laeuft = true; requestAnimationFrame(bild); } }
function zeigerAuf(cx, cy) {
const r = karte.getBoundingClientRect();
const x = Math.min(1, Math.max(0, (cx - r.left) / r.width)), y = Math.min(1, Math.max(0, (cy - r.top) / r.height));
// Deckkraft wächst zum Rand hin: Mitte 0.5, Ecke 1 (poke-holo --pointer-from-center)
const naehe = Math.min(1, Math.hypot(x - .5, y - .5) / .7071);
ziel.x = x; ziel.y = y; ziel.o = .5 + .4 * naehe;
rueck = null; aktiv = true; starten();
}
function loslassen() {
aktiv = false;
if (reduziert()) { Object.assign(ist, { x: .5, y: .5, o: W.ruhe }); Object.assign(ziel, ist); setzen(); return; }
Object.assign(ziel, { x: .5, y: .5, o: W.ruhe });
rueck = { x: ist.x, y: ist.y, o: ist.o, start: performance.now() }; starten();
}
/* Maus und Stift: Hover; Touch: Ziehen auf der Karte */
karte.addEventListener('pointermove', e => { if (reduziert() || vorfuehrt) return; if (e.pointerType !== 'touch' || e.buttons) zeigerAuf(e.clientX, e.clientY); });
karte.addEventListener('pointerdown', e => { if (reduziert()) return; stopVorfuehrung(); auto = false; if (e.pointerType === 'touch') { karte.setPointerCapture(e.pointerId); zeigerAuf(e.clientX, e.clientY); } });
karte.addEventListener('pointerup', e => { if (e.pointerType === 'touch') { loslassen(); autoSpaeter(); } });
karte.addEventListener('pointercancel', () => { loslassen(); autoSpaeter(); });
karte.addEventListener('pointerleave', e => { if (e.pointerType !== 'touch' && !vorfuehrt) loslassen(); });
/* ---------- Touch ohne Eingabe: langsamer Kreis des Glanzes; optional Geräteneigung ---------- */
let auto = false, autoUhr = null, autoStart = 0, neigungAn = false;
function autoSchritt(t) {
const w = (t - autoStart) / 6000 * Math.PI * 2; // 6 s pro Runde
ziel.x = .5 + Math.cos(w) * .32; ziel.y = .5 + Math.sin(w) * .26; ziel.o = .7;
}
function autoAn() { if (reduziert() || !beruehrung.matches || neigungAn || vorfuehrt) return; auto = true; autoStart = performance.now(); starten(); }
function autoSpaeter() { clearTimeout(autoUhr); autoUhr = setTimeout(autoAn, 2500); }
function neigung(e) {
if (reduziert() || e.gamma == null) return;
neigungAn = true; auto = false;
const x = Math.max(-1, Math.min(1, e.gamma / 30)), y = Math.max(-1, Math.min(1, (e.beta - 45) / 30));
ziel.x = .5 + x * .5; ziel.y = .5 + y * .5; ziel.o = .5 + .4 * Math.min(1, Math.hypot(x, y)); starten();
}
$('[data-neigung]').addEventListener('click', async () => {
try { if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) { if (await DeviceOrientationEvent.requestPermission() !== 'granted') return; } } catch (e) { return; }
addEventListener('deviceorientation', neigung);
$('[data-neigung]').textContent = 'Neigung aktiv';
});
/* ---------- Produktoptionen ---------- */
const preis = v => v.toLocaleString('de-DE', { minimumFractionDigits: 2 }) + '\u202f€';
function setzeBetrag(v) {
$('[data-betrag-karte]').innerHTML = `${v}<small>€</small>`;
$('[data-betrag-text]').textContent = preis(v); $('[data-preis]').textContent = preis(v);
karte.setAttribute('aria-label', `Geschenkkarte von MILDE, schwarz mit holografischer Heißfolie, Betrag ${v} Euro.`);
}
$$('[name=betrag]').forEach(i => i.addEventListener('change', () => setzeBetrag(+i.value)));
function setzeFolie(f, ansagen) {
karte.dataset.folie = f;
$$('[name=folie]').forEach(i => { i.checked = i.value === f; });
$('[data-folie-text]').textContent = FOLIEN[f]; $('[data-zustand]').textContent = 'Folie: ' + KURZ[f];
if (ansagen) ansage.textContent = FOLIEN[f];
werteZeigen();
}
$$('[name=folie]').forEach(i => i.addEventListener('change', () => { stopVorfuehrung(); setzeFolie(i.value, true); }));
$('[data-name]').addEventListener('input', e => {
const n = e.target.value.trim();
$('[data-fuer-karte]').firstChild.textContent = n ? 'Für ' + n : 'Für dich';
});
function werteZeigen() {
const f = karte.dataset.folie;
const t = [
['Mischung', 'color-dodge'],
['Filter', f === 'linien' ? 'contrast(2.2) saturate(.8)' : 'brightness(.85) contrast(2.75) saturate(.65)'],
['Streifen', '133° · 5 %'],
['Neigung', reduziert() ? 'aus' : `± ${W.neigung}° · perspective ${W.perspektive}px`],
['Verlauf', `± ${W.verschiebung} %`],
['Rückkehr', reduziert() ? 'aus' : `${W.rueckkehr} ms cubic-bezier(.25, 1, .5, 1)`],
];
$('[data-werte]').innerHTML = t.map(([a, b]) => `<span>${a} <b>${b}</b></span>`).join('');
}
new MutationObserver(() => { werteZeigen(); if (reduziert()) { auto = false; loslassen(); } }).observe(html, { attributes: true, attributeFilter: ['class'] });
/* ---------- Vorführung: gedachter Zeiger kreist über die Karte und wählt die Veredelung ---------- */
const FOLGE = ['linien', 'voll', 'rahmen'], TAKT = 5200;
let vorfuehrt = false, vStart = 0, vTakt = -1;
const mitte = el => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, r }; };
function vorfuehrSchritt(t) {
const z = t - vStart, takt = Math.floor(z / TAKT), s = (z % TAKT) / TAKT;
const k = mitte(karte), knopf = $(`[name=folie][value="${FOLGE[(takt + 1) % 3]}"]`).parentElement, km = mitte(knopf);
if (takt !== vTakt) { vTakt = takt; setzeFolie(FOLGE[takt % 3]); }
const w = s / .78 * Math.PI * 2;
const bahn = { x: k.x + Math.sin(w) * k.r.width * .42, y: k.y + Math.sin(w * 2) * k.r.height * .36 };
let p = bahn;
if (s > .82) { const e = kurve((s - .82) / .14); p = { x: bahn.x + (km.x - bahn.x) * e, y: bahn.y + (km.y - bahn.y) * e }; }
if (s > .97 && !knopf.classList.contains('drueckt')) { knopf.classList.add('drueckt'); zeiger.classList.add('drueckt'); setTimeout(() => { knopf.classList.remove('drueckt'); zeiger.classList.remove('drueckt'); }, 150); }
zeiger.style.transform = `translate(${p.x}px, ${p.y}px)`;
if (reduziert()) return;
const r = k.r, x = Math.min(1, Math.max(0, (p.x - r.left) / r.width)), y = Math.min(1, Math.max(0, (p.y - r.top) / r.height));
const drin = p.x > r.left && p.x < r.right && p.y > r.top && p.y < r.bottom;
ziel.x = drin ? x : .5; ziel.y = drin ? y : .5; ziel.o = drin ? .5 + .4 * Math.min(1, Math.hypot(x - .5, y - .5) / .7071) : W.ruhe;
}
function stopVorfuehrung() { if (!vorfuehrt) return; vorfuehrt = false; html.classList.remove('vorfuehrung'); loslassen(); }
function neustart() {
stopVorfuehrung(); auto = false; rueck = null; aktiv = false;
Object.assign(ist, { x: .5, y: .5, o: W.ruhe }); Object.assign(ziel, ist); setzen();
setzeFolie('linien');
}
function vorfuehren() {
neustart();
if (!html.classList.contains('vorschau')) scrollTo(0, 0);
vorfuehrt = true; vStart = performance.now(); vTakt = -1;
html.classList.add('vorfuehrung'); starten();
}
function pause() { stopVorfuehrung(); auto = false; }
function weiter() { vorfuehren(); }
setzen(); werteZeigen();
addEventListener('load', () => { if (!html.classList.contains('vorschau')) autoSpaeter(); });
})();
</script>
</body>
</html>