Papier- und Drucktextur
Mittagskarte eines Regensburger Wirtshauses als Riso-Bogen: Papierfasern im Streiflicht des Zeigers, zwei versetzte Druckfarben, Klebeband und Ausriss; jede Ebene einzeln schaltbar.
Der Hintergrund bekommt eine Papierstruktur aus Fasern und leichten Helligkeitswolken, als Shader oder als hochaufgelöstes Tile. Text und Grafik werden leicht fehlregistriert gedruckt: Wie beim Risographen sind die Farbebenen um 1 bis 2 px versetzt, die Kanten sind unruhig. Bilder bekommen Körnung und wenige Farben. Optional kommen Knick- und Falzschatten als Verläufe dazu.
Ein Gegenpol zur glatten, generierten Optik. Papier und Druckfehler zeigen Handarbeit und wirken warm und greifbar.
Wann einsetzen
Kultur, Gastronomie, Handwerk, Bildung, Editorial, Kinder- und Familienprojekte. Am stärksten, wenn die Marke selbst druckt: Speisekarte, Plakat, Zine.
Wann nicht
Bei High-Tech-Produkten mit Präzisionsanspruch. Nicht als Struktur unter langem Fließtext.
Varianten
- Papier-Shader, optional mit Streiflicht vom Zeiger
- Risograph-Fehlregistrierung mit zwei oder drei Farbebenen
- Collage mit Klebeband und Ausriss
Umsetzung
Meist genügt ein statisches Tile. Ein Shader lohnt sich erst bei Interaktion, etwa Licht, das über das Papier wandert. Die Druckfarben als getrennte Ebenen mit mix-blend-mode: multiply anlegen, die zweite per translate um 1 bis 2 px versetzen. Unruhige Kanten und Farbaussparungen liefert ein SVG-Filter aus feTurbulence, feDisplacementMap und feComposite. Text bleibt echter HTML-Text. Award-Belege fehlen bisher.
Robust umsetzen
Telefon. Unverändert. Hover-Licht entfällt oder folgt einem Tippen.
Ohne Bewegung. Kein Handlungsbedarf, die Textur ist statisch. Bewegtes Licht oder Einblendungen ohne Animation setzen.
Barrierefrei. Kontrast des Textes auf dem strukturierten Grund prüfen, auch im hellsten und dunkelsten Bereich. Der SVG-Filter verändert nur die Darstellung; der Text bleibt auswählbar und lesbar für Screenreader. Schrift nicht so stark aufrauen, dass kleine Größen zerfallen.
Code
<!DOCTYPE html>
<!--
Papier- und Drucktextur
Fundus · Glas & Material · https://cecodesign.de/lab/fundus/glas-material/papier-druck-textur/
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>Papier- und Drucktextur · 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: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; font-style: italic; font-display: swap; }
@font-face { font-family: 'Commissioner'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/commissioner:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept gastro-warm-appetitlich: warmes Papier-Weiß, warme dunkle Schrift, Hausfarben sparsam */
--grund: #f0ece0; --text: #353839; --leise: #6b6a63; --linie: rgba(53, 56, 57, .18);
--salbei: #7f937a; --tanne: #293e3a; --senf: #eac16e; --rose: #e1a894;
--platte-a: #293e3a; /* Riso-Trommel 1: Tannengrün */
--platte-b: #e0805f; /* Riso-Trommel 2: Koralle (kräftiges Rosé) */
--vx: 0px; --vy: 0px; /* Fehlregistrierung der zweiten Trommel */
--kurve: cubic-bezier(0.16, 1, 0.3, 1);
--rand: clamp(18px, 3vw, 48px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font: 400 1.0625rem/1.55 'Commissioner', system-ui, sans-serif; font-variation-settings: 'FLAR' 0; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; }
button { font: inherit; color: inherit; }
.label { font: 600 .75rem/1.3 'Commissioner', sans-serif; font-variation-settings: 'FLAR' 80; letter-spacing: .15em; text-transform: uppercase; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 2px solid var(--tanne); outline-offset: 3px; }
.serif { font-family: 'Fraunces', Georgia, serif; font-variation-settings: 'SOFT' 100, 'WONK' 0; }
/* ---------- Kopf ---------- */
.kopf { display: flex; align-items: center; gap: 28px; height: 72px; padding: 0 var(--rand); border-bottom: 1px solid var(--linie); }
.marke { font: 600 1.625rem/1 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 1; letter-spacing: -.01em; text-decoration: none; color: var(--tanne); }
.kopf nav { display: flex; gap: 24px; margin-left: 12px; }
.kopf nav a { text-decoration: none; font-weight: 500; background: linear-gradient(var(--text), var(--text)) 0 100% / 0 1px no-repeat; transition: background-size 200ms var(--kurve); }
.kopf nav a:hover { background-size: 100% 1px; }
.status { margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: .9375rem; }
.status i { width: 8px; height: 8px; border-radius: 50%; background: var(--salbei); }
.status.zu i { background: var(--rose); }
.knopf { display: inline-flex; align-items: center; gap: 10px; height: 46px; padding: 0 20px; border-radius: 999px; background: var(--tanne); color: var(--grund); text-decoration: none; font-weight: 600; transition: background-color 200ms var(--kurve); }
.knopf:hover { background: #1c2c29; }
.knopf.hell { background: transparent; color: var(--tanne); box-shadow: inset 0 0 0 1.5px var(--tanne); }
.knopf.hell:hover { background: rgba(41, 62, 58, .07); }
/* ---------- Bühne ---------- */
.held { display: grid; grid-template-columns: minmax(0, 6fr) minmax(0, 6fr); gap: clamp(28px, 4vw, 72px); padding: clamp(12px, 1.6vw, 24px) var(--rand) 0; min-height: calc(100svh - 72px - 58px); align-items: center; }
.text { max-width: 560px; }
.kicker { display: inline-flex; gap: 10px; align-items: center; background: var(--senf); color: var(--tanne); padding: 7px 12px; border-radius: 999px; }
h1 { font: 500 clamp(2.5rem, 1.6rem + 4vw, 5rem)/1.02 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 0; letter-spacing: -.01em; color: var(--tanne); margin-top: 22px; text-wrap: balance; }
h1 em { font-style: italic; font-weight: 400; }
.lead { margin-top: 22px; max-width: 46ch; text-wrap: pretty; }
.aktionen { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
.fakten { display: grid; grid-template-columns: repeat(3, auto); justify-content: start; gap: 4px clamp(20px, 3vw, 44px); margin-top: 36px; padding-top: 18px; border-top: 1px solid var(--linie); font-size: .9375rem; }
.fakten dt { color: var(--leise); }
.fakten dd { margin-top: 2px; font-variant-numeric: tabular-nums; }
/* Der Bogen: Papier (Shader), Druck (zwei Riso-Trommeln), Collage (Klebeband, Ausriss) */
.tisch { position: relative; display: grid; place-items: center; padding: 22px 0 30px; }
.bogen-rahmen { position: relative; width: min(500px, 100%, calc((100svh - 72px - 58px - 100px) / 1.414)); aspect-ratio: 1 / 1.414; rotate: -1.6deg; filter: drop-shadow(0 1px 1px rgba(53, 56, 57, .12)) drop-shadow(0 18px 26px rgba(53, 56, 57, .16)); container-type: inline-size; touch-action: pan-y; }
.bogen { position: absolute; inset: 0; background: #f7f4ec; clip-path: var(--riss-glatt); transition: clip-path 700ms var(--kurve); }
.mit-collage .bogen { clip-path: var(--riss); }
.bogen canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 700ms var(--kurve); }
.mit-papier .bogen canvas { opacity: 1; }
.druck { position: absolute; inset: 0; padding: 7.5cqi 8cqi 9cqi; display: flex; flex-direction: column; }
.druck > * { mix-blend-mode: multiply; }
.a { color: var(--platte-a); }
.b { color: var(--platte-b); transition: translate 700ms var(--kurve); translate: var(--vx) var(--vy); }
.mit-riso .a, .mit-riso .b { filter: url(#riso); }
.kopfzeile { display: flex; justify-content: space-between; align-items: baseline; gap: 3cqi; border-bottom: .5cqi solid var(--platte-a); padding-bottom: 2.2cqi; }
.kopfzeile .marke-druck { font: 600 7.6cqi/1 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 1; letter-spacing: -.01em; }
.kopfzeile .kw { font: 700 2.3cqi/1.2 'Commissioner', sans-serif; font-variation-settings: 'FLAR' 100; letter-spacing: .15em; text-transform: uppercase; text-align: right; }
.ill { position: relative; height: 34cqi; margin: 2.5cqi 0 1cqi; }
.ill svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; mix-blend-mode: multiply; }
.ill .b-ill { translate: var(--vx) var(--vy); transition: translate 700ms var(--kurve); }
.titel-druck { font: 500 6.4cqi/1 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 0; letter-spacing: -.01em; }
.titel-druck em { font-style: italic; font-weight: 400; }
.tage { list-style: none; padding: 0; margin-top: 3.4cqi; display: grid; gap: 2.5cqi; }
.tage li { display: grid; grid-template-columns: 9cqi 1fr auto; gap: 0 2.6cqi; align-items: baseline; }
.tage .tag { font: 700 2.3cqi/1.2 'Commissioner', sans-serif; font-variation-settings: 'FLAR' 100; letter-spacing: .12em; text-transform: uppercase; }
.tage .gericht { font: 500 3.15cqi/1.2 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 0; }
.tage .gericht small { display: block; font: 400 2.3cqi/1.35 'Commissioner', sans-serif; margin-top: .6cqi; }
.tage .preis { font: 600 3cqi/1 'Commissioner', sans-serif; font-variant-numeric: tabular-nums; }
.fusszeile { margin-top: auto; display: flex; justify-content: space-between; gap: 3cqi; border-top: .3cqi solid; padding-top: 2cqi; font: 400 1.9cqi/1.4 'Commissioner', sans-serif; }
/* Collage: Klebeband und ein Zettel */
.band { position: absolute; width: 26%; height: 6.5%; background: rgba(234, 193, 110, .62); mix-blend-mode: multiply; opacity: 0; scale: .94; transition: opacity 700ms var(--kurve), scale 700ms var(--kurve);
clip-path: polygon(2% 8%, 6% 0, 11% 9%, 15% 2%, 20% 6%, 100% 0, 97% 22%, 100% 41%, 96% 63%, 100% 84%, 97% 100%, 0 100%, 3% 79%, 0 58%, 4% 37%, 0 17%); }
.band-1 { top: -2.6%; left: 9%; rotate: -5deg; }
.band-2 { top: -2.2%; right: 8%; rotate: 4deg; }
.zettel { position: absolute; right: -7%; bottom: 13%; width: 40%; padding: 3.2cqi 3.6cqi 3.8cqi; background: #fbf8f1; rotate: 4.5deg; opacity: 0; translate: 0 12px; transition: opacity 700ms var(--kurve), translate 700ms var(--kurve);
clip-path: polygon(0 3%, 9% 0, 18% 2%, 30% 0, 44% 3%, 58% 0, 71% 2%, 85% 0, 100% 2%, 100% 100%, 0 100%); box-shadow: 0 8px 18px rgba(53, 56, 57, .14); }
.zettel p { font: italic 400 3.5cqi/1.25 'Fraunces', serif; color: var(--platte-a); mix-blend-mode: multiply; }
.zettel span { display: block; font: 700 1.9cqi/1.3 'Commissioner', sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--platte-b); margin-bottom: 1.4cqi; }
.zettel .band { width: 40%; height: 18%; top: -9%; left: 30%; rotate: -3deg; }
.mit-collage .band, .mit-collage .zettel { opacity: 1; scale: 1; translate: 0 0; }
/* Steuerung der Demo */
.pult { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px 24px; min-height: 58px; padding: 10px var(--rand); border-top: 1px solid var(--linie); }
.schalter { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.schalter > span { margin-right: 6px; color: var(--leise); }
.schalter button { height: 32px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--linie); background: none; cursor: pointer; font-weight: 500; font-size: .875rem; display: inline-flex; gap: 8px; align-items: center; transition: background-color 200ms var(--kurve), border-color 200ms var(--kurve), color 200ms var(--kurve); }
.schalter button::before { content: ''; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; }
.schalter button[aria-pressed='true'] { background: var(--tanne); border-color: var(--tanne); color: var(--grund); }
.schalter button[aria-pressed='true']::before { background: var(--senf); border-color: var(--senf); }
.schalter button:hover { border-color: var(--tanne); }
.werte { display: flex; flex-wrap: wrap; gap: 2px 16px; color: var(--leise); font-size: .8125rem; font-variant-numeric: tabular-nums; }
.werte b { color: var(--text); font-weight: 500; }
/* ---------- Darunter ---------- */
.karte { padding: clamp(56px, 7vw, 104px) var(--rand); border-top: 1px solid var(--linie); display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(28px, 4vw, 72px); }
h2 { font: 500 clamp(2rem, 1.4rem + 2vw, 3.25rem)/1.05 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 0; color: var(--tanne); }
.karte p { margin-top: 16px; max-width: 42ch; }
.reiter { display: flex; gap: 4px; border-bottom: 1px solid var(--linie); }
.reiter button { background: none; border: 0; padding: 10px 14px; font-weight: 600; cursor: pointer; color: var(--leise); box-shadow: inset 0 -2px 0 transparent; transition: color 200ms var(--kurve), box-shadow 200ms var(--kurve); }
.reiter button[aria-selected='true'] { color: var(--tanne); box-shadow: inset 0 -2px 0 var(--tanne); }
.speisen { list-style: none; padding: 0; }
.speisen li { display: grid; grid-template-columns: 1fr auto; gap: 4px 20px; padding: 16px 0; border-bottom: 1px solid var(--linie); }
.speisen b { font: 500 1.125rem/1.3 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 0; }
.speisen span { font-size: .9375rem; color: var(--leise); grid-column: 1; }
.speisen em { font-style: normal; font-variant-numeric: tabular-nums; font-weight: 500; grid-row: 1; grid-column: 2; }
.allergene { margin-top: 14px; font-size: .8125rem; color: var(--leise); }
.dunkel { background: var(--tanne); color: var(--grund); padding: clamp(48px, 6vw, 88px) var(--rand); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; }
.dunkel h2 { color: var(--grund); grid-column: 1 / -1; }
.dunkel p { font-size: .9375rem; }
.dunkel .label { color: var(--senf); margin-bottom: 8px; }
/* Mobil: Reservieren / Karte / Route / Anrufen fest unten (Rezept-Regel) */
.unten { display: none; }
/* 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-radius: 50%; border: 1.5px solid var(--tanne); background: rgba(240, 236, 224, .4); pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
/* ---------- Vorschau 960 × 720 ---------- */
.vorschau body { overflow: hidden; }
.vorschau .karte, .vorschau .dunkel, .vorschau .kopf nav, .vorschau .werte, .vorschau .fakten, .vorschau .lead, .vorschau .unten { display: none !important; }
.vorschau .held { min-height: 0; height: calc(100vh - 72px - 58px); padding-top: 0; }
.vorschau .bogen-rahmen { width: calc((100vh - 72px - 58px - 70px) / 1.414); }
.vorschau h1 { font-size: 3.4rem; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (max-width: 1100px) { .kopf nav { display: none; } .werte span:nth-child(n+4) { display: none; } }
@media (max-width: 820px) {
.kopf { height: 60px; gap: 14px; } .marke { font-size: 1.375rem; }
.kopf .knopf { display: none; }
.held { grid-template-columns: 1fr; min-height: 0; padding-top: 28px; }
.bogen-rahmen { width: min(440px, 88%); }
.fakten { grid-template-columns: 1fr 1fr; }
.karte, .dunkel { grid-template-columns: 1fr; }
.pult { flex-direction: column; align-items: flex-start; }
.unten { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; left: 0; right: 0; bottom: 0; z-index: 5; background: var(--tanne); padding: 6px 6px calc(6px + env(safe-area-inset-bottom)); }
.unten a { display: grid; justify-items: center; gap: 2px; padding: 6px 0; color: var(--grund); text-decoration: none; font-size: .8125rem; font-weight: 600; }
.unten a:first-child { background: var(--senf); color: var(--tanne); border-radius: 10px; }
.unten svg { width: 20px; height: 20px; }
body { padding-bottom: 70px; }
}
@media (hover: none) { .schalter button { height: 40px; } }
.s-kurz { display: none; }
@media (max-width: 520px) { .kicker .lang { display: none; } .status { font-size: .875rem; } .s-lang { display: none; } .s-kurz { display: inline; } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<filter id="riso" x="-3%" y="-3%" width="106%" height="106%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" seed="4" result="kante"/>
<feDisplacementMap in="SourceGraphic" in2="kante" scale="1.4" xChannelSelector="R" yChannelSelector="G" result="verzogen"/>
<feTurbulence type="fractalNoise" baseFrequency="1.7" numOctaves="1" seed="9" result="korn"/>
<feColorMatrix in="korn" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.6 0 0 0 2.15" result="deckung"/>
<feComposite in="verzogen" in2="deckung" operator="in"/>
</filter>
</svg>
<header class="kopf">
<a class="marke" href="#titel">Wöhrdküche</a>
<nav aria-label="Hauptmenü"><a href="#karte">Karte</a><a href="#karte">Mittagstisch</a><a href="#karte">Weine</a><a href="#besuch">Besuch</a></nav>
<p class="status" data-status><i aria-hidden="true"></i><span data-status-text>Öffnungszeiten werden berechnet</span></p>
<a class="knopf" href="#besuch">Tisch reservieren</a>
</header>
<main>
<section class="held" aria-labelledby="titel">
<div class="text">
<p class="kicker label">Wochenkarte KW 45<span class="lang"> · 2. bis 6. November</span></p>
<h1 id="titel">Jede Woche <em>frisch</em> gedruckt.</h1>
<p class="lead">Unsere Mittagskarte entsteht montags um sieben auf dem Risographen im Hinterzimmer: zwei Farben, ein Bogen, 120 Stück. Was aus ist, streichen wir mit Bleistift.</p>
<div class="aktionen"><a class="knopf" href="#besuch">Tisch reservieren</a><a class="knopf hell" href="#karte">Ganze Karte</a></div>
<dl class="fakten">
<div><dt>Mittag</dt><dd>Mo bis Fr 11:30–14:30</dd></div>
<div><dt>Abend</dt><dd>Di bis Sa ab 17:30</dd></div>
<div><dt>Adresse</dt><dd>Unterer Wöhrd 12</dd></div>
</dl>
</div>
<div class="tisch">
<figure class="bogen-rahmen mit-papier mit-riso mit-collage" data-rahmen aria-labelledby="bogen-titel">
<div class="bogen" data-bogen>
<canvas data-papier aria-hidden="true"></canvas>
<div class="druck">
<div class="kopfzeile"><span class="marke-druck a">Wöhrdküche</span><span class="kw b">Mittagstisch<br>KW 45 / 2026</span></div>
<div class="ill" aria-hidden="true">
<svg viewBox="0 0 400 140" class="b-ill b" fill="currentColor">
<path d="M112 62h176c0 40-32 68-88 68s-88-28-88-68Z"/>
<ellipse cx="200" cy="62" rx="88" ry="12" opacity=".55"/>
<path d="M40 132h320" stroke="currentColor" stroke-width="3" opacity=".4"/>
</svg>
<svg viewBox="0 0 400 140" class="a-ill a" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M108 62h184c0 42-34 72-92 72s-92-30-92-72Z"/>
<ellipse cx="200" cy="62" rx="92" ry="13"/>
<path d="M136 82c18 6 40 8 64 8s46-2 64-8M150 100c14 4 32 6 50 6s36-2 50-6" stroke-width="2"/>
<path d="M170 44c-10-10 8-16-2-28M200 40c-10-12 10-18 0-32M230 44c-10-10 8-16-2-28" stroke-width="2.5"/>
<path d="M262 40l52-34" stroke-width="5"/><path d="M308 2c10-2 16 6 10 12" stroke-width="3"/>
</svg>
</div>
<p class="titel-druck a" id="bogen-titel">Was diese Woche <em>auf den Tisch</em> kommt</p>
<ol class="tage">
<li><span class="tag b">Mo</span><span class="gericht a">Kartoffelsuppe mit Majoran<small>Brezencroutons, Schnittlauch (A, G)</small></span><span class="preis b">7,50</span></li>
<li><span class="tag b">Di</span><span class="gericht a">Krautwickerl in Speckrahm<small>Kartoffelstampf, Kümmel (G)</small></span><span class="preis b">12,90</span></li>
<li><span class="tag b">Mi</span><span class="gericht a">Saibling aus dem Regental<small>Wurzelgemüse, Kren, Petersilie (D, G)</small></span><span class="preis b">15,50</span></li>
<li><span class="tag b">Do</span><span class="gericht a">Linsen mit Spätzle<small>Saitenwürstl oder Räuchertofu (A, C, F)</small></span><span class="preis b">11,90</span></li>
<li><span class="tag b">Fr</span><span class="gericht a">Dampfnudel aus dem Ofen<small>Vanillesoße, Zwetschgenröster (A, C, G)</small></span><span class="preis b">9,80</span></li>
</ol>
<p class="fusszeile a"><span>Unterer Wöhrd 12 · 93059 Regensburg</span><span>0941 466 27 10</span></p>
</div>
</div>
<span class="band band-1" aria-hidden="true"></span><span class="band band-2" aria-hidden="true"></span>
<div class="zettel" role="img" aria-label="Notiz: Mittwoch ist der Saibling ab 13 Uhr aus."><span>Mittwoch</span><p>Saibling ab 13 Uhr aus, dann Kässpatzn.</p><i class="band" aria-hidden="true"></i></div>
</figure>
</div>
</section>
<div class="pult">
<div class="schalter" role="group" aria-label="Ebenen des Drucks"><span class="label">Druck</span>
<button type="button" data-ebene="papier" aria-pressed="true">Papier</button>
<button type="button" data-ebene="riso" aria-pressed="true">Riso</button>
<button type="button" data-ebene="collage" aria-pressed="true">Collage</button>
</div>
<p class="werte" data-werte></p>
</div>
<section class="karte" id="karte" aria-labelledby="karte-titel">
<div><h2 id="karte-titel">Die ganze Karte</h2><p>Mittags die Wochenkarte, abends eine kleine Karte aus der Region. Wein vor allem aus Franken und der Wachau.</p></div>
<div>
<div class="reiter" role="tablist" aria-label="Karte">
<button type="button" role="tab" aria-selected="false" data-reiter="0">Mittag</button>
<button type="button" role="tab" aria-selected="true" data-reiter="1">Abend</button>
<button type="button" role="tab" aria-selected="false" data-reiter="2">Getränke</button>
</div>
<ul class="speisen" data-speisen role="tabpanel"></ul>
<p class="allergene">A Gluten · C Ei · D Fisch · F Soja · G Milch · Alle Preise in Euro.</p>
</div>
</section>
<section class="dunkel" id="besuch" aria-labelledby="besuch-titel">
<h2 id="besuch-titel">Besuch</h2>
<div><p class="label">Reservieren</p><p>0941 466 27 10 oder tisch@woehrdkueche.de<br>Gruppen ab acht Personen bitte mittags anrufen.</p></div>
<div><p class="label">Öffnungszeiten</p><p>Mo 11:30–14:30<br>Di bis Fr 11:30–14:30 und 17:30–23:00<br>Sa 17:30–23:00 · So Ruhetag</p></div>
<div><p class="label">Anfahrt</p><p>Unterer Wöhrd 12, 93059 Regensburg<br>Fünf Minuten zu Fuß über die Eiserne Brücke</p></div>
</section>
</main>
<nav class="unten" aria-label="Schnellzugriff">
<a href="#besuch"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M9 3v4M15 3v4"/></svg>Reservieren</a>
<a href="#karte"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M6 3h12v18H6zM9 8h6M9 12h6M9 16h4"/></svg>Karte</a>
<a href="#besuch"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21Z"/><circle cx="12" cy="9.5" r="2.5"/></svg>Route</a>
<a href="tel:+499414662710"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z"/></svg>Anrufen</a>
</nav>
<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 $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const rahmen = $('[data-rahmen]'), bogen = $('[data-bogen]'), canvas = $('[data-papier]'), zeiger = $('[data-zeiger]'), ansage = $('[data-ansage]');
/* ---------- Werte ---------- */
const W = {
versatz: [1.5, -1], // px, Fehlregistrierung der zweiten Trommel (Messwert 1–2 px)
dauer: 700, // ms, Ein- und Ausblenden (Rezept: Bild-Einblendung)
relief: 5.0, // Höhe der Papierfasern für die Lichtberechnung
lichtHoehe: 260, // px über dem Papier
lichtFolgen: 0.08, // Nachlauf pro Frame bei 60 fps
kurve: [0.16, 1, 0.3, 1],
};
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);
/* ---------- Jetzt geöffnet? Aus den echten Zeiten berechnet ---------- */
const ZEITEN = { 1: [['11:30', '14:30']], 2: [['11:30', '14:30'], ['17:30', '23:00']], 3: [['11:30', '14:30'], ['17:30', '23:00']], 4: [['11:30', '14:30'], ['17:30', '23:00']], 5: [['11:30', '14:30'], ['17:30', '23:00']], 6: [['17:30', '23:00']], 0: [] };
const TAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
const min = s => { const [h, m] = s.split(':'); return +h * 60 + +m; };
function status() {
const j = new Date(), t = j.getDay(), m = j.getHours() * 60 + j.getMinutes(), el = $('[data-status]'), tx = $('[data-status-text]');
const setze = (lang, kurz) => { tx.innerHTML = `<span class="s-lang">${lang}</span><span class="s-kurz">${kurz || lang}</span>`; };
const offen = (ZEITEN[t] || []).find(([a, b]) => m >= min(a) && m < min(b));
el.classList.toggle('zu', !offen);
if (offen) { const bis = offen[1].replace(':00', ''); setze(`Jetzt geöffnet bis ${bis} Uhr`, `Offen bis ${bis} Uhr`); return; }
const spaeter = (ZEITEN[t] || []).find(([a]) => min(a) > m);
if (spaeter) { setze(`Heute ab ${spaeter[0]} Uhr`); return; }
for (let i = 1; i <= 7; i++) {
const d = (t + i) % 7;
if (ZEITEN[d].length) { const wann = `${i === 1 ? 'Morgen' : TAGE[d]} ab ${ZEITEN[d][0][0]}`; setze(`${t === 0 ? 'Heute Ruhetag' : 'Geschlossen'} · ${wann}`, wann); return; }
}
}
status(); setInterval(status, 60000);
/* ---------- Ausriss: gleich viele Punkte für glatt und gerissen, damit clip-path überblenden kann ---------- */
(function riss() {
let s = 7; const r = () => (s = (s * 16807) % 2147483647) / 2147483647;
const n = 34, gl = ['0 0', '100% 0'], ri = ['0 0', '100% 0'];
for (let i = 0; i <= n; i++) {
const x = (100 - i * 100 / n).toFixed(2);
gl.push(`${x}% 100%`);
ri.push(`${x}% calc(100% - ${(1 + r() * 7 + (i % 3 === 0 ? 3 : 0)).toFixed(1)}px)`);
}
rahmen.style.setProperty('--riss-glatt', `polygon(${gl.join(',')})`);
rahmen.style.setProperty('--riss', `polygon(${ri.join(',')})`);
})();
/* ---------- Papier-Shader: Wolken, Fasern, Streiflicht vom Zeiger ---------- */
const VS = 'attribute vec2 p; void main(){ gl_Position = vec4(p, 0.0, 1.0); }';
const FS = `
precision highp float;
uniform vec2 uAuf; uniform float uDpr, uRelief;
uniform vec3 uLicht;
uniform vec3 uPapier;
float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); }
float fbm(vec2 p) { float s = 0.0, a = 0.5; for (int i = 0; i < 4; i++) { s += a * noise(p); p *= 2.03; a *= 0.5; } return s; }
/* Fasern: Rauschen, entlang dreier Richtungen stark gestreckt, nur die Spitzen */
float fasern(vec2 p) {
float f = 0.0;
for (int i = 0; i < 3; i++) {
float w = float(i) * 2.1 + 0.4; vec2 d = vec2(cos(w), sin(w));
vec2 q = vec2(dot(p, d), dot(p, vec2(-d.y, d.x)));
f += smoothstep(0.74, 0.98, noise(q * vec2(0.07, 1.5) + float(i) * 17.0)) * 0.6;
}
return f;
}
float hoehe(vec2 p) { return fbm(p / 170.0) * 0.8 + fasern(p) * 0.4 + noise(p * 0.85) * 0.22; }
void main() {
vec2 p = gl_FragCoord.xy / uDpr;
float h = hoehe(p), hx = hoehe(p + vec2(1.0, 0.0)), hy = hoehe(p + vec2(0.0, 1.0));
vec3 n = normalize(vec3((h - hx) * uRelief, (h - hy) * uRelief, 1.0));
vec3 l = normalize(vec3(uLicht.xy - p, uLicht.z));
float diff = dot(n, l);
float wolke = fbm(p / 240.0 + 7.0);
float nah = exp(-length(uLicht.xy - p) / (0.9 * max(uAuf.x, uAuf.y) / uDpr));
vec3 col = uPapier * (0.96 + 0.06 * wolke) * (0.89 + 0.13 * diff) + 0.035 * nah;
gl_FragColor = vec4(col, 1.0);
}`;
let gl = null, U = {};
(function start() {
gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false });
if (!gl) return;
const sh = (t, s) => { const o = gl.createShader(t); gl.shaderSource(o, s); gl.compileShader(o); if (!gl.getShaderParameter(o, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(o)); return o; };
const pr = gl.createProgram();
try { gl.attachShader(pr, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FS)); } catch (e) { console.warn(e); gl = null; return; }
gl.linkProgram(pr); if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) { gl = null; return; }
gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const lp = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(lp); gl.vertexAttribPointer(lp, 2, gl.FLOAT, false, 0, 0);
['uAuf', 'uDpr', 'uRelief', 'uLicht', 'uPapier'].forEach(n => U[n] = gl.getUniformLocation(pr, n));
})();
/* Ohne WebGL: Papier als SVG-Rauschen (statisch), Hinweis im Wertefeld */
if (!gl) {
html.classList.add('kein-gl');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><filter id="f"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="3" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 .2 0 0 0 0 .19 0 0 0 0 .16 0 0 0 .09 0"/></filter><rect width="100%" height="100%" filter="url(#f)"/></svg>`;
canvas.style.background = `url("data:image/svg+xml,${encodeURIComponent(svg)}"), #f3eee2`;
}
const dpr = () => Math.min(devicePixelRatio || 1, 1.5);
const licht = { x: .3, y: .2, zx: .3, zy: .2 }; // Anteil der Bogenbreite/-höhe; z = Ziel
let schmutzig = true;
function malen() {
if (!gl) return;
const w = canvas.width, h = canvas.height;
gl.viewport(0, 0, w, h);
gl.uniform2f(U.uAuf, w, h); gl.uniform1f(U.uDpr, dpr()); gl.uniform1f(U.uRelief, W.relief);
gl.uniform3f(U.uLicht, licht.x * w / dpr(), (1 - licht.y) * h / dpr(), W.lichtHoehe);
gl.uniform3f(U.uPapier, 247 / 255, 243 / 255, 233 / 255);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function groesse() {
const w = bogen.offsetWidth, h = bogen.offsetHeight; // ohne Drehung
if (!gl || !w) return;
canvas.width = Math.round(w * dpr()); canvas.height = Math.round(h * dpr());
schmutzig = true; starten();
}
new ResizeObserver(groesse).observe(bogen);
/* Licht folgt dem Zeiger (Maus) bzw. dem Finger beim Tippen */
function lichtAuf(cx, cy) {
const r = bogen.getBoundingClientRect();
licht.zx = (cx - r.left) / r.width; licht.zy = (cy - r.top) / r.height;
if (reduziert()) { licht.x = licht.zx; licht.y = licht.zy; schmutzig = true; }
starten();
}
document.querySelector('.tisch').addEventListener('pointermove', e => { if (!vorfuehrt && e.pointerType === 'mouse') lichtAuf(e.clientX, e.clientY); });
document.querySelector('.tisch').addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') { stopVorfuehrung(); lichtAuf(e.clientX, e.clientY); } });
let laeuft = false, letzte = 0;
function bild(t) {
const dt = Math.min(64, t - (letzte || t)) / 16.667; letzte = t;
if (vorfuehrt) vorfuehrSchritt(t);
const k = reduziert() ? 1 : 1 - Math.pow(1 - W.lichtFolgen, dt);
const dx = licht.zx - licht.x, dy = licht.zy - licht.y;
licht.x += dx * k; licht.y += dy * k;
const bewegt = Math.abs(dx) > .0005 || Math.abs(dy) > .0005;
if (bewegt || schmutzig) { malen(); schmutzig = false; }
if (bewegt || vorfuehrt) requestAnimationFrame(bild); else { laeuft = false; letzte = 0; }
}
function starten() { if (!laeuft) { laeuft = true; requestAnimationFrame(bild); } }
/* ---------- Ebenen: Papier, Riso, Collage ---------- */
const knoepfe = $$('[data-ebene]');
const an = { papier: true, riso: true, collage: true };
function setzeEbene(e, wert, ansagen) {
an[e] = wert;
rahmen.classList.toggle('mit-' + e, wert);
if (e === 'riso') { rahmen.style.setProperty('--vx', wert ? W.versatz[0] + 'px' : '0px'); rahmen.style.setProperty('--vy', wert ? W.versatz[1] + 'px' : '0px'); }
knoepfe.forEach(b => { if (b.dataset.ebene === e) b.setAttribute('aria-pressed', wert ? 'true' : 'false'); });
if (ansagen) ansage.textContent = `${e[0].toUpperCase() + e.slice(1)} ${wert ? 'an' : 'aus'}`;
werteZeigen();
}
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeEbene(b.dataset.ebene, !an[b.dataset.ebene], true); }));
setzeEbene('riso', true);
function werteZeigen() {
const t = [
['Versatz', an.riso ? `${W.versatz[0]} / ${W.versatz[1]} px` : '0 px'],
['Trommeln', '#293E3A · #E0805F, multiply'],
['Kante', 'feDisplacementMap 1.4'],
['Licht', gl ? `${W.lichtHoehe} px hoch` : 'kein WebGL, SVG-Rauschen'],
['Ein/Aus', `${W.dauer} ms cubic-bezier(.16, 1, .3, 1)`],
];
$('[data-werte]').innerHTML = t.map(([a, b]) => `<span>${a} <b>${b}</b></span>`).join('');
}
/* ---------- Karte unten: Tabs als HTML-Text ---------- */
const KARTE = [
[['Wochenkarte', 'Täglich ein Gericht, siehe Bogen oben', ''], ['Suppe des Tages', 'Mit Bauernbrot (A)', '5,50'], ['Salat vom Markt', 'Kürbiskernöl, geröstete Kerne', '6,80']],
[['Ochsenbackerl', 'Rotweinsoße, Selleriepüree, Petersilie (G)', '23,50'], ['Zander vom Grill', 'Rahmkraut, Speck, Kartoffeln (D, G)', '24,90'], ['Kässpatzn', 'Bergkäse, Röstzwiebeln, Salat (A, C, G)', '15,80'], ['Zwetschgendatschi', 'Mit geschlagenem Rahm (A, G)', '7,20']],
[['Spital-Hell, 0,5 l', 'Regensburg', '4,60'], ['Silvaner, 0,2 l', 'Franken, trocken', '6,90'], ['Grüner Veltliner, 0,2 l', 'Wachau', '7,40'], ['Apfelschorle, 0,4 l', 'Streuobst aus dem Landkreis', '3,90']],
];
const tabs = $$('[data-reiter]');
function karteZeigen(i) {
tabs.forEach((b, k) => b.setAttribute('aria-selected', k === i ? 'true' : 'false'));
$('[data-speisen]').innerHTML = KARTE[i].map(([n, d, p]) => `<li><b>${n}</b><span>${d}</span>${p ? `<em>${p}</em>` : ''}</li>`).join('');
}
tabs.forEach((b, i) => b.addEventListener('click', () => karteZeigen(i)));
karteZeigen(1);
/* ---------- Vorführung: glatt → Papier → Riso → Collage, Licht wandert über den Bogen ---------- */
const PHASEN = [[], ['papier'], ['papier', 'riso'], ['papier', 'riso', 'collage'], ['papier', 'riso', 'collage']];
const TAKT = 1900;
let vorfuehrt = false, vStart = 0, vPhase = -1;
function vorfuehrSchritt(t) {
const z = t - vStart, phase = Math.floor(z / TAKT) % PHASEN.length;
if (phase !== vPhase) {
vPhase = phase;
['papier', 'riso', 'collage'].forEach(e => { const soll = PHASEN[phase].includes(e); if (an[e] !== soll) setzeEbene(e, soll); });
}
const w = z / 9500 * Math.PI * 2;
licht.zx = .5 + Math.cos(w) * .42; licht.zy = .45 + Math.sin(w * 1.3) * .38;
const r = bogen.getBoundingClientRect();
zeiger.style.transform = `translate(${r.left + licht.x * r.width}px, ${r.top + licht.y * r.height}px)`;
}
function stopVorfuehrung() { if (!vorfuehrt) return; vorfuehrt = false; html.classList.remove('vorfuehrung'); }
function neustart() {
stopVorfuehrung();
['papier', 'riso', 'collage'].forEach(e => setzeEbene(e, true));
licht.zx = licht.x = .3; licht.zy = licht.y = .2; schmutzig = true; starten();
}
function vorfuehren() {
neustart();
if (!html.classList.contains('vorschau')) scrollTo(0, 0);
vorfuehrt = true; vStart = performance.now(); vPhase = -1;
html.classList.add('vorfuehrung'); starten();
}
function pause() { stopVorfuehrung(); }
function weiter() { vorfuehren(); }
werteZeigen(); groesse();
})();
</script>
</body>
</html>