Teilen und In den Kalender
Teilen über das System-Menü (Web Share) mit Rückfall „Link kopieren“ und E-Mail, „In den Kalender“ als .ics mit Zeitzone sowie Links zu Google Kalender, Outlook.com und Microsoft 365, ohne fremde Skripte; dazu Termin mit Erinnerung und Zitat-Link per Text-Fragment.
Zwei kleine Knöpfe für Termine und Artikel. „Teilen“ öffnet auf dem Telefon das Teilen-Menü des Systems (Web Share API); wo es fehlt, klappt ein kleines Menü mit „Link kopieren“ und „Per E-Mail“ auf. „In den Kalender“ bietet eine .ics-Datei für Apple, Outlook und andere sowie direkte Links zu Google Kalender und Outlook.com, ohne fremde Skripte und ohne Tracking-Parameter. Beim Termin kommt eine Erinnerung dazu, beim Artikel ein Knopf „Zitat teilen“, der einen Link direkt auf die Textstelle baut.
Wer einen Termin gut findet, will ihn sich merken oder weitergeben, am liebsten mit einem Tipp. Native Teilen-Menüs kennen die eigenen Kontakte; die Seite muss nichts von Facebook oder WhatsApp laden. Eine korrekte .ics mit Zeitzone erspart Tippfehler bei Datum und Ort. Im Bestand bauen sieben Demos .ics und zwei das Teilen je selbst; dieses Muster ist der gemeinsame Baustein.
Wann einsetzen
Veranstaltungen, Kurse, Führungen, Tag der offenen Tür, Termine nach einer Buchung, Vereins- und Kulturprogramme, News und Artikel, Stellenanzeigen (Teilen).
Wann nicht
Keine Teilen-Leiste mit sechs Netzwerk-Logos und Zählern; die laden oft Dritt-Skripte. Keine Teilen-Knöpfe an privaten Inhalten (Buchungsbestätigung mit Namen). Kalender nur, wenn Datum und Uhrzeit feststehen; bei „Termin folgt“ lieber Benachrichtigung.
Varianten
- Veranstaltung: Teilen und In den Kalender nebeneinander, .ics mit TZID Europe/Berlin, Google- und Outlook-Link
- Termin: nach einer Buchung, Kalender mit wählbarer Erinnerung (VALARM), kein Teilen
- Artikel: Teilen, Link kopieren, E-Mail und Zitat teilen per Text-Fragment-Link
Umsetzung
Teilen: Knopf ruft navigator.share({title, text, url}) nur nach einem Klick (Nutzergeste nötig) und nur, wenn navigator.canShare die Daten annimmt; AbortError (Abbruch) bleibt still. Ohne Web Share öffnet derselbe Knopf ein Popover (popover-Attribut, Anker darunter) mit „Link kopieren“ (navigator.clipboard.writeText, Rückmeldung „Kopiert“ am Knopf und in role=status) und „Per E-Mail“ (mailto: mit subject und body). Kalender: Popover mit vier Wegen. .ics als Blob (text/calendar;charset=utf-8) mit CRLF, Zeilen nach 75 Oktetten gefaltet, Kommas, Semikolons und Zeilenumbrüche maskiert, UID, DTSTAMP in UTC, DTSTART und DTEND mit TZID=Europe/Berlin und VTIMEZONE, LOCATION, URL, DESCRIPTION. Google: calendar.google.com/calendar/render?action=TEMPLATE&text=…&dates=…/…&ctz=Europe/Berlin&details=…&location=…. Outlook.com und Microsoft 365: …/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent&subject=…&startdt=…&enddt=…. Links mit rel=noopener und Hinweis, dass sie zu Google bzw. Microsoft führen. Termin: VALARM mit TRIGGER:-P1D oder -PT2H. Artikel: Zitat teilen baut #:~:text=Anfang,Ende (Text-Fragment). Datenschutz-Hinweis vom Kunden prüfen lassen.
Robust umsetzen
Telefon. Auf Telefonen mit Web Share öffnet „Teilen“ direkt das System-Menü. Das Kalender-Menü öffnet unter dem Knopf in voller Breite; .ics öffnet auf iOS und Android die Kalender-App. Knöpfe 44 px hoch, nebeneinander mit Umbruch.
Ohne Bewegung. Menüs erscheinen ohne Versatz, das Häkchen steht sofort.
Barrierefrei. Echte Knöpfe mit sichtbarem Text, keine reinen Symbole. Popover mit popovertarget (aria-expanded setzt der Browser), Escape schließt, Fokus kehrt zum Knopf zurück. Links, die die Seite verlassen, sagen es im Text („öffnet Google Kalender“). Rückmeldung „Link kopiert“ in einer role=status-Zeile. Datum im Knopf-Umfeld ausgeschrieben, nicht nur als Symbol.
Code
<!DOCTYPE html>
<!--
Teilen und In den Kalender
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/teilen-und-kalender/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de" data-v="veranstaltung">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Teilen und In den Kalender · 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: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; 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; }
/* Rezept kultur-programm-haltung: Schwarz auf warmem Off-White, je Sparte eine Farbe, eckig */
:root {
--grund: #efede9; --text: #0b0b0b; --leise: #5f5e5c; --linie: #0b0b0b; --linie-fein: #cbc9c5; --flaeche: #e4e1db;
--musik: #c4421a; --theater: #3d5f9a; --kunst: #2f7d4a; --sparte: var(--musik);
--ein: cubic-bezier(0.25, 1, 0.5, 1); --aus: cubic-bezier(0.5, 0, 0.75, 0);
--t-auf: 200ms; --t-zu: 150ms; --t-haken: 250ms; --t-ui: 150ms;
}
:root[data-v="termin"] { --sparte: var(--theater); }
:root[data-v="artikel"] { --sparte: var(--kunst); }
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
body { min-height: 100svh; font-size: 1.125rem; line-height: 1.6; }
button, input { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
:root:has(:focus-visible) { scroll-behavior: auto; }
a { color: inherit; text-underline-offset: .2em; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: .8125rem; font-variant-numeric: tabular-nums; letter-spacing: .01em; }
[data-nur] { display: none !important; }
:root[data-v="veranstaltung"] [data-nur~="veranstaltung"], :root[data-v="termin"] [data-nur~="termin"], :root[data-v="artikel"] [data-nur~="artikel"] { display: revert !important; }
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 8px 24px; flex-wrap: wrap; padding: 14px clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--linie); }
.marke { font: 800 1.25rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; text-decoration: none; }
.marke span { font-weight: 400; }
.kopf nav { display: flex; gap: 4px 22px; flex-wrap: wrap; font-size: 1rem; }
.kopf nav a { text-decoration: none; }
.kopf nav a[aria-current] { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .3em; }
main { max-width: 1320px; margin: 0 auto; padding: clamp(28px, 4vw, 56px) clamp(16px, 4vw, 48px) clamp(64px, 8vw, 112px); }
.buehne { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); gap: clamp(24px, 4vw, 64px); align-items: start; }
.sparte { display: inline-flex; align-items: center; gap: 10px; font: 500 .8125rem/1 'IBM Plex Mono', monospace; letter-spacing: .08em; text-transform: uppercase; }
.sparte::before { content: ''; width: 12px; height: 12px; background: var(--sparte); }
h1 { font: 850 clamp(2.75rem, 1.4rem + 5.6vw, 6.5rem)/.96 'Schibsted Grotesk', sans-serif; letter-spacing: -.035em; margin: 18px 0 14px; text-wrap: balance; }
:root[data-v="artikel"] h1 { font-size: clamp(2.25rem, 1.3rem + 3.6vw, 4.25rem); line-height: 1; }
.unter { font-size: clamp(1.125rem, 1rem + .5vw, 1.375rem); line-height: 1.4; max-width: 34ch; text-wrap: pretty; }
.fakten { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 28px 0 0; border-top: 1px solid var(--linie); }
.fakten div { padding: 12px 16px 12px 0; border-bottom: 1px solid var(--linie-fein); }
.fakten dt { font: 500 .75rem/1.2 'IBM Plex Mono', monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); }
.fakten dd { font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
/* Knopfreihe */
.handlung { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; align-items: center; }
.k { display: inline-flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 20px; border: 1.5px solid var(--text); background: transparent; text-decoration: none; font-weight: 700; font-size: 1rem; cursor: pointer; position: relative; transition: background-color var(--t-ui) var(--ein), color var(--t-ui) var(--ein); }
.k svg { width: 18px; height: 18px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.k.voll { background: var(--text); color: var(--grund); }
.k:not(.voll):hover, .k[aria-expanded="true"] { background: var(--text); color: var(--grund); }
.k.voll:hover { background: #2a2a2a; }
.k .haken { position: absolute; left: 20px; opacity: 0; }
.k .haken path { stroke-dasharray: 20; stroke-dashoffset: 20; }
.k.kopiert .symbol { opacity: 0; }
.k.kopiert .haken { opacity: 1; }
.k.kopiert .haken path { stroke-dashoffset: 0; transition: stroke-dashoffset var(--t-haken) var(--ein); }
/* Menüs als Popover */
.menue { margin: 0; inset: auto; position: fixed; width: min(360px, calc(100vw - 24px)); padding: 6px 0; border: 1.5px solid var(--text); background: var(--grund); color: var(--text); box-shadow: 8px 8px 0 rgba(11, 11, 11, .12); }
.menue:popover-open { opacity: 1; translate: 0 0; transition: opacity var(--t-auf) var(--ein), translate var(--t-auf) var(--ein), display var(--t-auf) allow-discrete, overlay var(--t-auf) allow-discrete; }
@starting-style { .menue:popover-open { opacity: 0; translate: 0 -6px; } }
.menue { opacity: 0; translate: 0 -6px; transition: opacity var(--t-zu) var(--aus), translate var(--t-zu) var(--aus), display var(--t-zu) allow-discrete, overlay var(--t-zu) allow-discrete; }
.menue h2 { font: 500 .75rem/1 'IBM Plex Mono', monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); padding: 12px 16px 8px; }
.menue ul { list-style: none; }
.menue li + li { border-top: 1px solid var(--linie-fein); }
.menue a, .menue button.eintrag { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 2px 12px; align-items: center; width: 100%; text-align: left; padding: 11px 16px; border: 0; background: none; text-decoration: none; cursor: pointer; font-size: 1rem; font-weight: 600; line-height: 1.3; }
.menue a small, .menue button.eintrag small { grid-column: 2; font-weight: 400; font-size: .875rem; color: var(--leise); }
.menue svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; grid-row: 1 / span 2; }
.menue a:hover, .menue button.eintrag:hover, .menue a:focus-visible, .menue button.eintrag:focus-visible { background: var(--flaeche); outline-offset: -3px; }
.menue .notiz { font-size: .8125rem; color: var(--leise); padding: 10px 16px 8px; line-height: 1.45; border-top: 1px solid var(--linie-fein); }
.menue:popover-open li { animation: eintrag var(--t-auf) var(--ein) backwards; }
.menue:popover-open li:nth-child(2) { animation-delay: 30ms; } .menue:popover-open li:nth-child(3) { animation-delay: 60ms; } .menue:popover-open li:nth-child(4) { animation-delay: 90ms; }
@keyframes eintrag { from { opacity: 0; translate: 0 -4px; } }
/* Erinnerung (Termin) */
.erinnerung { border: 0; margin-top: 24px; }
.erinnerung legend { font: 500 .75rem/1.2 'IBM Plex Mono', monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); margin-bottom: 8px; }
.erinnerung div { display: flex; flex-wrap: wrap; gap: 6px; }
.erinnerung label { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--linie-fein); cursor: pointer; font-size: 1rem; }
.erinnerung label:has(:checked) { border-color: var(--text); background: var(--flaeche); }
.erinnerung input { accent-color: var(--text); width: 16px; height: 16px; }
/* Bestätigung (Termin) */
.gebucht { display: inline-flex; align-items: center; gap: 10px; font-weight: 700; }
.gebucht svg { width: 22px; height: 22px; stroke: var(--sparte); fill: none; stroke-width: 2; }
/* Artikel */
.lesen { max-width: 68ch; }
.lesen p { margin-top: 1em; }
.lesen .autor { font-size: .9375rem; color: var(--leise); margin-top: 6px; }
.zitat { margin: 28px 0 8px; padding: 18px 0 14px 22px; border-left: 4px solid var(--sparte); }
.zitat p { font: 700 clamp(1.375rem, 1.1rem + 1vw, 1.75rem)/1.25 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; margin: 0; }
.zitat footer { font-size: .9375rem; color: var(--leise); margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; }
.zitat .k { min-height: 36px; padding: 0 12px; font-size: .875rem; border-width: 1px; }
.teilen-leiste { margin-top: 36px; padding-top: 18px; border-top: 1px solid var(--linie); display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.teilen-leiste > span { font: 500 .75rem/1 'IBM Plex Mono', monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); margin-right: 8px; }
/* Plakat rechts */
.plakat { position: relative; aspect-ratio: 4 / 5; background: var(--sparte); color: var(--grund); overflow: hidden; }
.plakat svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.plakat .ecke { position: absolute; left: 16px; right: 16px; bottom: 14px; display: flex; justify-content: space-between; font: 500 .75rem/1.3 'IBM Plex Mono', monospace; letter-spacing: .06em; text-transform: uppercase; color: #fff; }
.plakat strong { position: absolute; left: 16px; top: 12px; font: 850 clamp(4rem, 9vw, 8rem)/.85 'Schibsted Grotesk', sans-serif; letter-spacing: -.05em; color: #fff; }
/* Rückmeldung und Demo-Hinweis */
.rueck { margin-top: 16px; min-height: 1.6em; font-size: .9375rem; color: var(--text); display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
.rueck code { font: 400 .75rem/1.5 'IBM Plex Mono', monospace; color: var(--leise); word-break: break-all; }
.rueck.neu { animation: eintrag var(--t-auf) var(--ein); }
/* Pult */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 40; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 5px; background: rgba(11, 11, 11, .92); color: #efede9; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.pult span { padding: 0 8px 0 10px; opacity: .75; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; background: none; border: 0; padding: 9px 11px; cursor: pointer; }
.pult button[aria-pressed="true"] { background: #efede9; color: #0b0b0b; }
.pult button:focus-visible { outline: 2px solid #efede9; outline-offset: 1px; }
@media (max-width: 900px) { .buehne { grid-template-columns: minmax(0, 1fr); } .plakat { aspect-ratio: 16 / 9; order: -1; } .plakat strong { font-size: 4rem; } }
@media (max-width: 640px) { .kopf nav { display: none; } .fakten { grid-template-columns: minmax(0, 1fr); } .handlung .k { flex: 1 1 auto; justify-content: center; } }
@media (max-width: 360px) { .k { padding: 0 14px; } }
.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .rueck { display: none; }
:root.reduziert *, :root.reduziert *::before, :root.reduziert *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-delay: 0ms !important; }
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
.menue { border: 1px solid CanvasText; }
.sparte::before { background: CanvasText; }
}
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#">Alte Seilerei <span>Lörrach</span></a>
<nav aria-label="Hauptmenü"><a href="#" aria-current="page">Programm</a><a href="#">Haus</a><a href="#">Besuch</a><a href="#">Magazin</a></nav>
</header>
<main>
<div class="buehne">
<div>
<!-- Veranstaltung -->
<article data-nur="veranstaltung" aria-labelledby="t-v">
<p class="sparte">Musik · Konzert</p>
<h1 id="t-v">Zwölf Stränge</h1>
<p class="unter">Das Ensemble Garnwerk spielt auf zwölf gespannten Hanfseilen, verstärkt und mit vier Stimmen.</p>
<dl class="fakten">
<div><dt>Datum</dt><dd>Freitag, 21. November 2026</dd></div>
<div><dt>Beginn</dt><dd>20:00 Uhr, Einlass 19:15 Uhr</dd></div>
<div><dt>Ort</dt><dd>Seilerbahn, Alte Seilerei</dd></div>
<div><dt>Eintritt</dt><dd>18 €, ermäßigt 12 €</dd></div>
</dl>
<div class="handlung">
<a class="k voll" href="#">Karten reservieren</a>
<button class="k" type="button" data-teilen>
<svg class="symbol" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 13V3M6 7l4-4 4 4M4 11v6h12v-6"/></svg>
<svg class="haken" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10.5l4 4 8-9"/></svg>
<span data-text>Teilen</span>
</button>
<button class="k" type="button" popovertarget="kal" data-kal-knopf>
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="4" width="14" height="13"/><path d="M3 8h14M7 2v4M13 2v4M10 10.5v4M8 12.5h4"/></svg>
In den Kalender
</button>
</div>
</article>
<!-- Termin nach einer Buchung -->
<article data-nur="termin" aria-labelledby="t-t">
<p class="gebucht"><svg viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="9.5"/><path d="M6.5 11.5l3 3 6-6.5"/></svg>Ihre Führung ist gebucht</p>
<h1 id="t-t">Hinter den Kulissen</h1>
<p class="unter">Führung durch Seilerbahn, Werkstatt und Bühnentechnik, 90 Minuten, für zwei Personen.</p>
<dl class="fakten">
<div><dt>Datum</dt><dd>Samstag, 22. November 2026</dd></div>
<div><dt>Zeit</dt><dd>11:00 bis 12:30 Uhr</dd></div>
<div><dt>Treffpunkt</dt><dd>Foyer, am Seilermodell</dd></div>
<div><dt>Buchungsnummer</dt><dd class="mono" style="font-size:1rem">AS-2611-0417</dd></div>
</dl>
<fieldset class="erinnerung">
<legend>Erinnerung im Kalender</legend>
<div>
<label><input type="radio" name="erinnerung" value="" > Keine</label>
<label><input type="radio" name="erinnerung" value="-P1D" checked> 1 Tag vorher</label>
<label><input type="radio" name="erinnerung" value="-PT2H"> 2 Stunden vorher</label>
</div>
</fieldset>
<div class="handlung">
<button class="k voll" type="button" popovertarget="kal" data-kal-knopf>
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="4" width="14" height="13"/><path d="M3 8h14M7 2v4M13 2v4M10 10.5v4M8 12.5h4"/></svg>
In den Kalender
</button>
<a class="k" href="#">Anfahrt</a>
</div>
</article>
<!-- Artikel -->
<article data-nur="artikel" aria-labelledby="t-a" class="lesen">
<p class="sparte">Magazin · Haus</p>
<h1 id="t-a">Warum ein Seil singt</h1>
<p class="autor">Von Mira Ostertag · 3. November 2026 · 4 Minuten Lesezeit</p>
<p>Auf der 140 Meter langen Seilerbahn wurden bis 1968 Taue für die Rheinschifffahrt gedreht. Heute probt hier das Ensemble Garnwerk, und die Bahn ist wieder das, was sie immer war: ein Instrument aus Länge und Spannung.</p>
<p>Ein gespanntes Seil schwingt wie eine Saite. Hanf dämpft stärker als Stahl, deshalb klingt der Ton kurz und warm. Erst die Tonabnehmer an den Haken machen ihn im Saal hörbar.</p>
<blockquote class="zitat" id="zitat-1">
<p data-zitat>Die Bahn war schon immer ein Instrument. Wir haben nur angefangen, ihr zuzuhören.</p>
<footer><span>Jonas Wehrle, Garnwerk</span>
<button class="k" type="button" data-zitat-teilen>
<svg class="symbol" viewBox="0 0 20 20" aria-hidden="true"><path d="M8 12l4-4M7 9l-2 2a3 3 0 0 0 4 4l2-2M13 11l2-2a3 3 0 0 0-4-4L9 7"/></svg>
<svg class="haken" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10.5l4 4 8-9"/></svg>
<span data-text>Link zum Zitat kopieren</span>
</button>
</footer>
</blockquote>
<div class="teilen-leiste">
<span>Artikel weitergeben</span>
<button class="k" type="button" data-teilen>
<svg class="symbol" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 13V3M6 7l4-4 4 4M4 11v6h12v-6"/></svg>
<svg class="haken" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10.5l4 4 8-9"/></svg>
<span data-text>Teilen</span>
</button>
<button class="k" type="button" data-kopieren>
<svg class="symbol" viewBox="0 0 20 20" aria-hidden="true"><rect x="7" y="7" width="10" height="10"/><path d="M13 7V3H3v10h4"/></svg>
<svg class="haken" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10.5l4 4 8-9"/></svg>
<span data-text>Link kopieren</span>
</button>
<a class="k" href="#" data-mail><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.5" y="4.5" width="15" height="11"/><path d="M3 5l7 6 7-6"/></svg>Per E-Mail</a>
</div>
</article>
<p class="rueck" role="status" data-rueck></p>
</div>
<figure class="plakat" aria-hidden="true">
<svg viewBox="0 0 400 500" preserveAspectRatio="none" data-straenge></svg>
<strong data-plakat-zahl>12</strong>
<figcaption class="ecke"><span data-plakat-a>Garnwerk</span><span data-plakat-b>21.11. · 20 Uhr</span></figcaption>
</figure>
</div>
</main>
<!-- Teilen-Menü (Rückfall ohne Web Share) -->
<div class="menue" id="teil" popover aria-label="Teilen">
<h2>Teilen</h2>
<ul>
<li><button type="button" class="eintrag" data-menue-kopieren><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="7" y="7" width="10" height="10"/><path d="M13 7V3H3v10h4"/></svg>Link kopieren<small data-link-kurz></small></button></li>
<li><a href="#" data-menue-mail><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.5" y="4.5" width="15" height="11"/><path d="M3 5l7 6 7-6"/></svg>Per E-Mail<small>öffnet Ihr Mailprogramm</small></a></li>
</ul>
</div>
<!-- Kalender-Menü -->
<div class="menue" id="kal" popover aria-label="In den Kalender">
<h2>In den Kalender</h2>
<ul>
<li><button type="button" class="eintrag" data-ics><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3v10M6 9l4 4 4-4M4 16h12"/></svg>Kalenderdatei (.ics)<small>Apple Kalender, Outlook-App, Thunderbird, Android</small></button></li>
<li><a href="#" data-google target="_blank" rel="noopener"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M8 4H4v12h12v-4M11 3h6v6M17 3l-8 8"/></svg>Google Kalender<small>öffnet calendar.google.com</small></a></li>
<li><a href="#" data-outlook target="_blank" rel="noopener"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M8 4H4v12h12v-4M11 3h6v6M17 3l-8 8"/></svg>Outlook.com<small>öffnet outlook.live.com</small></a></li>
<li><a href="#" data-office target="_blank" rel="noopener"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M8 4H4v12h12v-4M11 3h6v6M17 3l-8 8"/></svg>Microsoft 365<small>öffnet outlook.office.com</small></a></li>
</ul>
<p class="notiz">Google und Microsoft erhalten Titel, Zeit und Ort erst, wenn Sie dort klicken. Wir laden nichts von dort.</p>
</div>
<div class="pult" role="group" aria-label="Variante der Demo">
<span>Variante</span>
<button type="button" data-wahl="veranstaltung" aria-pressed="true">Veranstaltung</button>
<button type="button" data-wahl="termin" aria-pressed="false">Termin</button>
<button type="button" data-wahl="artikel" aria-pressed="false">Artikel</button>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
const SEITE = 'alte-seilerei.example'; // erfundene Domain der Demo
const DATEN = {
veranstaltung: { titel: 'Zwölf Stränge: Ensemble Garnwerk', start: '2026-11-21T20:00', ende: '2026-11-21T22:00', ort: 'Alte Seilerei, Seilerbahn, Am Seilerweg 3, 79539 Lörrach', text: 'Konzert auf zwölf gespannten Hanfseilen. Einlass 19:15 Uhr. Eintritt 18 €, ermäßigt 12 €.', pfad: '/programm/zwoelf-straenge', datei: 'zwoelf-straenge-2026-11-21.ics', zahl: '12', a: 'Garnwerk', b: '21.11. · 20 Uhr' },
termin: { titel: 'Führung: Hinter den Kulissen (Alte Seilerei)', start: '2026-11-22T11:00', ende: '2026-11-22T12:30', ort: 'Alte Seilerei, Foyer, Am Seilerweg 3, 79539 Lörrach', text: 'Buchung AS-2611-0417 für 2 Personen. Treffpunkt am Seilermodell im Foyer.', pfad: '/besuch/fuehrungen', datei: 'fuehrung-alte-seilerei-2026-11-22.ics', zahl: '90′', a: 'Führung', b: '22.11. · 11 Uhr' },
artikel: { titel: 'Warum ein Seil singt', pfad: '/magazin/warum-ein-seil-singt', zahl: '140 m', a: 'Magazin', b: 'Seilerbahn' }
};
let v = 'veranstaltung';
const D = () => DATEN[v];
const url = () => `https://${SEITE}${D().pfad}`;
// ---------- Kalender: .ics nach RFC 5545 ----------
const VTZ = ['BEGIN:VTIMEZONE', 'TZID:Europe/Berlin', 'BEGIN:DAYLIGHT', 'TZOFFSETFROM:+0100', 'TZOFFSETTO:+0200', 'TZNAME:CEST', 'DTSTART:19700329T020000', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', 'END:DAYLIGHT', 'BEGIN:STANDARD', 'TZOFFSETFROM:+0200', 'TZOFFSETTO:+0100', 'TZNAME:CET', 'DTSTART:19701025T030000', 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', 'END:STANDARD', 'END:VTIMEZONE'];
const esc = t => String(t).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
const lokal = s => s.replace(/[-:]/g, '') + '00'; // 2026-11-21T20:00 → 20261121T200000
const utc = d => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
// Zeilen nach 75 Oktetten falten (UTF-8, Umlaute zählen doppelt)
function falten(zeile) {
const enc = new TextEncoder(); let aus = '', teil = '', n = 0, grenze = 75;
for (const z of zeile) { const b = enc.encode(z).length; if (n + b > grenze) { aus += teil + '\r\n '; teil = ''; n = 1; grenze = 75; } teil += z; n += b; }
return aus + teil;
}
function ics() {
const d = D(), alarm = $('input[name=erinnerung]:checked')?.value;
const z = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Alte Seilerei Loerrach//Programm//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', ...VTZ, 'BEGIN:VEVENT',
`UID:${d.pfad.split('/').pop()}-${lokal(d.start).slice(0, 8)}@${SEITE}`, `DTSTAMP:${utc(new Date())}`,
`DTSTART;TZID=Europe/Berlin:${lokal(d.start)}`, `DTEND;TZID=Europe/Berlin:${lokal(d.ende)}`,
`SUMMARY:${esc(d.titel)}`, `LOCATION:${esc(d.ort)}`, `DESCRIPTION:${esc(d.text + '\n' + url())}`, `URL:${url()}`, 'STATUS:CONFIRMED'];
if (v === 'termin' && alarm) z.push('BEGIN:VALARM', 'ACTION:DISPLAY', `DESCRIPTION:${esc(d.titel)}`, `TRIGGER:${alarm}`, 'END:VALARM');
z.push('END:VEVENT', 'END:VCALENDAR');
return z.map(falten).join('\r\n') + '\r\n';
}
// Versatz von Europe/Berlin zum Zeitpunkt, z. B. +01:00
function versatz(s) {
const teil = new Intl.DateTimeFormat('en-US', { timeZone: 'Europe/Berlin', timeZoneName: 'longOffset' }).formatToParts(new Date(s + ':00Z')).find(p => p.type === 'timeZoneName').value;
return teil.replace('GMT', '') || '+00:00';
}
function googleLink() {
const d = D(), q = new URLSearchParams({ action: 'TEMPLATE', text: d.titel, dates: `${lokal(d.start)}/${lokal(d.ende)}`, ctz: 'Europe/Berlin', details: d.text + '\n' + url(), location: d.ort });
return 'https://calendar.google.com/calendar/render?' + q;
}
function outlookLink(host) {
const d = D(), q = new URLSearchParams({ path: '/calendar/action/compose', rru: 'addevent', subject: d.titel, startdt: `${d.start}:00${versatz(d.start)}`, enddt: `${d.ende}:00${versatz(d.ende)}`, body: d.text + '\n' + url(), location: d.ort });
return `https://${host}/calendar/0/deeplink/compose?` + q;
}
// ---------- Rückmeldung ----------
const rueck = $('[data-rueck]');
function melde(text, code = '') {
rueck.classList.remove('neu'); void rueck.offsetWidth;
rueck.innerHTML = `<span>${text}</span>${code ? `<code>${code}</code>` : ''}`; rueck.classList.add('neu');
}
function hakenAm(knopf, text) {
const t = $('[data-text]', knopf); if (!t) return;
const alt = knopf.dataset.alt || t.textContent; knopf.dataset.alt = alt;
knopf.classList.add('kopiert'); t.textContent = text;
clearTimeout(knopf._t); knopf._t = setTimeout(() => { knopf.classList.remove('kopiert'); t.textContent = alt; }, 1600);
}
async function kopieren(text) {
if (html.classList.contains('vorfuehrung')) return true; // Vorführung: Zwischenablage nicht anfassen
try { await navigator.clipboard.writeText(text); return true; }
catch (e) { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.append(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (_) {} ta.remove(); return ok; }
}
// ---------- Popover unter dem Knopf platzieren ----------
let ausloeser = null;
function platziere(menue, knopf) {
const r = knopf.getBoundingClientRect(), w = menue.offsetWidth, h = menue.offsetHeight;
let x = Math.min(Math.max(12, r.left), innerWidth - w - 12), y = r.bottom + 8;
if (y + h > innerHeight - 12 && r.top - h - 8 > 12) y = r.top - h - 8;
menue.style.left = x + 'px'; menue.style.top = y + 'px';
}
$$('.menue').forEach(m => m.addEventListener('toggle', e => {
const knopf = ausloeser || $(`[popovertarget="${m.id}"]:not([hidden])`);
if (e.newState === 'open') { platziere(m, knopf); knopf?.setAttribute('aria-expanded', 'true'); if (!html.classList.contains('vorfuehrung')) requestAnimationFrame(() => $('a, button', m)?.focus()); }
else { $$(`[aria-expanded="true"]`).forEach(k => k.setAttribute('aria-expanded', 'false')); if ((m.contains(document.activeElement) || document.activeElement === document.body) && ausloeser && !html.classList.contains('vorfuehrung')) ausloeser.focus({ preventScroll: true }); }
}));
$$('[data-kal-knopf]').forEach(k => { k.setAttribute('aria-expanded', 'false'); k.addEventListener('click', () => { ausloeser = k; }); });
addEventListener('resize', () => $$('.menue').forEach(m => { if (m.matches(':popover-open') && ausloeser) platziere(m, ausloeser); }));
// ---------- Teilen: Web Share oder Menü ----------
const daten = () => ({ title: D().titel, text: v === 'artikel' ? 'Aus dem Magazin der Alten Seilerei' : `${D().titel}, ${new Date(D().start).toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })}`, url: url() });
$$('[data-teilen]').forEach(k => {
k.setAttribute('aria-haspopup', 'menu'); k.setAttribute('aria-expanded', 'false');
k.addEventListener('click', async () => {
const dt = daten();
if (navigator.share && (!navigator.canShare || navigator.canShare(dt)) && !html.classList.contains('vorfuehrung')) {
try { await navigator.share(dt); melde('Geteilt.'); } catch (e) { if (e.name !== 'AbortError') oeffneTeilen(k); }
return;
}
oeffneTeilen(k);
});
});
function oeffneTeilen(k) {
const m = $('#teil'); ausloeser = k;
$('[data-link-kurz]').textContent = url().replace('https://', '');
$('[data-menue-mail]').href = `mailto:?subject=${encodeURIComponent(D().titel)}&body=${encodeURIComponent(daten().text + '\n' + url())}`;
if (m.matches(':popover-open')) m.hidePopover(); else m.showPopover();
}
$('[data-menue-kopieren]').addEventListener('click', async () => {
const ok = await kopieren(url()); $('#teil').hidePopover(); if (!html.classList.contains('vorfuehrung')) ausloeser?.focus();
if (ausloeser) hakenAm(ausloeser, ok ? 'Link kopiert' : 'Nicht kopiert');
melde(ok ? 'Link kopiert:' : 'Kopieren ging nicht. Bitte markieren:', url());
});
$('[data-menue-mail]').addEventListener('click', e => { e.preventDefault(); $('#teil').hidePopover(); melde('Würde Ihr Mailprogramm öffnen:', decodeURIComponent($('[data-menue-mail]').href).slice(0, 140) + ' …'); });
// Artikel: eigener Kopier-Knopf und Mail-Link
$('[data-kopieren]').addEventListener('click', async e => { const k = e.currentTarget; const ok = await kopieren(url()); hakenAm(k, ok ? 'Kopiert' : 'Nicht kopiert'); melde(ok ? 'Link kopiert:' : 'Kopieren ging nicht:', url()); });
$('[data-mail]').addEventListener('click', e => { e.preventDefault(); melde('Würde Ihr Mailprogramm öffnen:', `mailto:?subject=${D().titel}`); });
// Zitat teilen: Text-Fragment-Link auf genau diese Stelle
$('[data-zitat-teilen]').addEventListener('click', async e => {
const k = e.currentTarget;
const w = $('[data-zitat]').textContent.trim().split(/\s+/);
const frag = `#:~:text=${encodeURIComponent(w.slice(0, 4).join(' '))},${encodeURIComponent(w.slice(-3).join(' '))}`;
const ok = await kopieren(url() + frag); hakenAm(k, ok ? 'Zitat-Link kopiert' : 'Nicht kopiert');
melde('Link springt direkt zum Zitat:', url() + frag);
});
// ---------- Kalender-Wege ----------
$('[data-ics]').addEventListener('click', () => {
const inhalt = ics(), blob = new Blob([inhalt], { type: 'text/calendar;charset=utf-8' }), u = URL.createObjectURL(blob), a = document.createElement('a');
a.href = u; a.download = D().datei; if (!html.classList.contains('vorfuehrung')) { document.body.append(a); a.click(); a.remove(); }
setTimeout(() => URL.revokeObjectURL(u), 2000);
$('#kal').hidePopover(); if (!html.classList.contains('vorfuehrung')) ausloeser?.focus();
const alarm = $('input[name=erinnerung]:checked')?.value;
melde(`${D().datei} geladen · ${(blob.size / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} KB${v === 'termin' ? (alarm ? `, Erinnerung ${alarm === '-P1D' ? '1 Tag' : '2 Stunden'} vorher` : ', ohne Erinnerung') : ''}`);
});
function linkWeg(sel, bau, name) {
$(sel).addEventListener('click', e => {
const l = bau(); e.currentTarget.href = l;
e.preventDefault(); // Demo: nicht weg navigieren, Link zeigen. Auf der echten Seite öffnet er im neuen Tab.
$('#kal').hidePopover(); if (!html.classList.contains('vorfuehrung')) ausloeser?.focus();
melde(`Würde ${name} in neuem Tab öffnen:`, l.length > 190 ? l.slice(0, 190) + ' …' : l);
});
}
linkWeg('[data-google]', googleLink, 'Google Kalender');
linkWeg('[data-outlook]', () => outlookLink('outlook.live.com'), 'Outlook.com');
linkWeg('[data-office]', () => outlookLink('outlook.office.com'), 'Microsoft 365');
// ---------- Plakat: zwölf Stränge, einmal gezeichnet ----------
function plakat() {
const d = D(), n = v === 'artikel' ? 7 : 12, svg = $('[data-straenge]');
let s = '';
for (let i = 0; i < n; i++) {
const x = 40 + i * (320 / (n - 1)), a = 10 + (i % 3) * 6, ph = i * .7;
let p = `M${x} 0`;
for (let y = 10; y <= 500; y += 10) p += ` L${(x + Math.sin(y / 60 + ph) * a * (v === 'termin' ? .4 : 1)).toFixed(1)} ${y}`;
s += `<path d="${p}" fill="none" stroke="rgba(255,255,255,${.35 + (i % 4) * .15})" stroke-width="${1.2 + (i % 3) * .8}" pathLength="1" style="stroke-dasharray:1;stroke-dashoffset:0"/>`;
}
svg.innerHTML = s;
$('[data-plakat-zahl]').textContent = d.zahl; $('[data-plakat-a]').textContent = d.a; $('[data-plakat-b]').textContent = d.b;
if (!reduziert()) $$('path', svg).forEach((p, i) => p.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 500, delay: i * 30, easing: 'cubic-bezier(0.25, 1, 0.5, 1)', fill: 'backwards' }));
}
// ---------- Variante ----------
function variante(neu, url_ = true) {
v = DATEN[neu] ? neu : 'veranstaltung';
html.dataset.v = v;
$$('[data-wahl]').forEach(b => b.setAttribute('aria-pressed', b.dataset.wahl === v));
$$('.menue').forEach(m => { if (m.matches(':popover-open')) m.hidePopover(); });
rueck.textContent = '';
plakat();
if (url_) { const q = new URLSearchParams(location.search); q.set('v', v); history.replaceState(null, '', location.pathname + '?' + q); }
}
$$('[data-wahl]').forEach(b => b.addEventListener('click', () => { const lief = laeuft; pause(); variante(b.dataset.wahl); if (lief) vorfuehren(); }));
// ---------- Vorführung ----------
let laeuft = false, gen = 0, uhr = null;
const warte = ms => new Promise(r => { uhr = setTimeout(r, ms); });
const sichtbar = sel => $$(sel).find(el => el.offsetParent !== null);
async function vorfuehren() {
pause(); laeuft = true; const g = ++gen; html.classList.add('vorfuehrung');
const ok = () => g === gen;
rueck.textContent = ''; await warte(500); if (!ok()) return;
if (v === 'veranstaltung' || v === 'artikel') {
const t = sichtbar('[data-teilen]'); oeffneTeilen(t); await warte(1300); if (!ok()) return;
$('[data-menue-kopieren]').click(); await warte(1800); if (!ok()) return;
}
if (v === 'artikel') { $('[data-zitat-teilen]').click(); await warte(2200); if (!ok()) return; }
else {
if (v === 'termin') { $$('input[name=erinnerung]')[2].checked = true; await warte(700); if (!ok()) return; }
const k = sichtbar('[data-kal-knopf]'); ausloeser = k; $('#kal').showPopover(); await warte(1500); if (!ok()) return;
$('[data-ics]').click(); await warte(1700); if (!ok()) return;
ausloeser = k; $('#kal').showPopover(); await warte(1000); if (!ok()) return;
$('[data-google]').click(); await warte(2200); if (!ok()) return;
if (v === 'termin') $$('input[name=erinnerung]')[1].checked = true;
}
if (ok()) uhr = setTimeout(vorfuehren, 300);
}
function neustart() { pause(); variante(v, false); }
function pause() { gen++; laeuft = false; clearTimeout(uhr); html.classList.remove('vorfuehrung'); }
function weiter() { vorfuehren(); }
variante(new URLSearchParams(location.search).get('v'), false);
})();
</script>
</body>
</html>