Herzlich willkommen zum SELF-Treffen 2026
vom 24.04. – 26.04.2026
in Halle (Saale)
Beispiel:JS-WAAPI-3.html
Aus SELFHTML-Wiki
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Karten Flip Animation mit Timeline</title>
<style>
:root {
--kartenbreite: 15vw;
}
html,
body {
height: 100vh;
margin: 0;
}
body {
display: grid;
justify-content: center;
align-items: end;
perspective: 100vw;
}
.spielfeld {
position: relative;
width: calc(4 * var(--kartenbreite));
box-sizing: content-box;
aspect-ratio: 16/9;
transform: rotateX(50deg);
border: 1em solid #888;
border-radius: 5vw;
padding: 1em 3em;
background: oklch(45% 0.13 125);
transform-style: preserve-3d;
}
.karte {
position: absolute;
width: var(--kartenbreite);
aspect-ratio: 0.665;
outline: none;
border: none;
cursor: pointer;
padding: 0;
background-color: transparent;
transform-style: preserve-3d;
left: calc(var(--kartenbreite) / 3);
top: calc(var(--kartenbreite) / 2);
}
.karte.animating {
cursor: default;
z-index: 100 !important;
pointer-events: none;
}
.vorderseite,
.rueckseite {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: calc(var(--kartenbreite) / 11);
left: 0;
top: 0;
pointer-events: none;
}
.vorderseite {
transform: rotateY(180deg);
border: thin solid black;
background-size: cover;
background-position: center;
}
.karte:nth-child(1) .vorderseite {
background-image: url("https://upload.wikimedia.org/wikipedia/commons/5/5f/English_pattern_ace_of_clubs.svg");
}
.karte:nth-child(2) .vorderseite {
background-image: url("https://upload.wikimedia.org/wikipedia/commons/2/26/English_pattern_2_of_hearts.svg");
}
.karte:nth-child(3) .vorderseite {
background-image: url("https://upload.wikimedia.org/wikipedia/commons/2/2c/English_pattern_3_of_diamonds.svg");
}
.rueckseite {
background-color: white;
background-image:
repeating-linear-gradient(
45deg,
steelblue 0px,
steelblue 5px,
transparent 5px,
transparent 10px
),
repeating-linear-gradient(
-45deg,
steelblue 0px,
steelblue 5px,
transparent 5px,
transparent 10px
);
background-size: 28px 28px;
border: thin solid black;
}
.steuerung,
#timelineInfo {
text-align: center;
}
.steuerung button {
cursor: pointer;
padding: 1em;
}
.steuerung button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<article class="spielfeld">
<!-- Karte 1: links unten (Z=3px), rechts oben (Z=9px) -->
<button class="karte" data-seite="links" data-karte="1">
<span class="rueckseite"></span>
<span class="vorderseite"></span>
</button>
<!-- Karte 2: links mitte (Z=6px), rechts mitte (Z=6px) -->
<button class="karte" data-seite="links" data-karte="2">
<span class="rueckseite"></span>
<span class="vorderseite"></span>
</button>
<!-- Karte 3: links oben (Z=9px), rechts unten (Z=3px) -->
<button class="karte" data-seite="links" data-karte="3">
<span class="rueckseite"></span>
<span class="vorderseite"></span>
</button>
</article>
<p id="timelineInfo"></p>
<p class="steuerung">
<button id="losButton">Los</button>
<button id="pauseButton" disabled>Pause</button>
<button id="zurueckButton">Zurück</button>
</p>
<script>
'use strict';
document.addEventListener('DOMContentLoaded', function () {
const losButton = document.getElementById('losButton');
const pauseButton = document.getElementById('pauseButton');
const zurueckButton = document.getElementById('zurueckButton');
const karten = document.querySelectorAll('.karte');
// Prüfe ob bereits initialisiert
if (losButton.dataset.initialisiert === 'true') {
console.warn('Buttons bereits initialisiert - überspringe Doppel-Initialisierung');
return;
}
losButton.dataset.initialisiert = 'true';
const KARTENBREITE_VW = 15;
const MAX_FLUGHOEHE_FAKTOR = 2; // Flughöhe als Vielfaches der Kartenbreite
const MAX_FLUGHOEHE = (KARTENBREITE_VW * MAX_FLUGHOEHE_FAKTOR * window.innerWidth) / 100;
const ANIMATION_DAUER = 1500; // Dauer der Animation in Millisekunden
const timelineInfo = document.getElementById('timelineInfo');
let laufendeAnimationen = [];
let animationsTracker = null;
// Gemeinsame Filter-Funktion
function findeKarten(seite, ohneAnimierende = true) {
return Array.from(karten).filter(k =>
k.dataset.seite === seite &&
(!ohneAnimierende || !k.classList.contains('animating'))
);
}
// Aktualisiere Timeline-Info Anzeige
function aktualisiereTimelineInfo() {
if (!animationsTracker) {
timelineInfo.textContent = 'Keine Animation aktiv';
timelineInfo.classList.remove('active');
return;
}
const vergangeneZeit = Date.now() - animationsTracker.startZeit;
const fortschritt = Math.min(100, (vergangeneZeit / ANIMATION_DAUER) * 100);
const vergangeneMs = Math.min(ANIMATION_DAUER, vergangeneZeit);
timelineInfo.textContent = `Karte ${animationsTracker.karteNr}: ${Math.round(vergangeneMs)}ms / ${ANIMATION_DAUER}ms (${fortschritt.toFixed(1)}%)`;
timelineInfo.classList.add('active');
if (vergangeneZeit < ANIMATION_DAUER && animationsTracker.animation.playState === 'running') {
requestAnimationFrame(aktualisiereTimelineInfo);
}
}
// Finde die oberste Karte auf dem linken Stapel
function findeObersteKarte() {
const linkeKarten = findeKarten('links');
if (linkeKarten.length === 0) return null;
return linkeKarten.sort((a, b) =>
parseInt(b.dataset.karte) - parseInt(a.dataset.karte)
)[0];
}
// Finde die oberste Karte auf dem rechten Stapel
function findeObersteRechteKarte() {
const rechteKarten = findeKarten('rechts');
if (rechteKarten.length === 0) return null;
return rechteKarten.sort((a, b) =>
parseInt(b.dataset.rechtsZ) - parseInt(a.dataset.rechtsZ)
)[0];
}
function aktualisiereButtons() {
const hatPausierteAnimationen = laufendeAnimationen.length > 0 &&
laufendeAnimationen.some(a => a.animation.playState === 'paused');
const linkeKarten = findeKarten('links');
const rechteKarten = findeKarten('rechts');
losButton.disabled = linkeKarten.length === 0 || hatPausierteAnimationen;
zurueckButton.disabled = rechteKarten.length === 0 || hatPausierteAnimationen;
pauseButton.disabled = laufendeAnimationen.length === 0;
if (laufendeAnimationen.length > 0) {
const allesPausiert = laufendeAnimationen.every(a => a.animation.playState === 'paused');
pauseButton.textContent = allesPausiert ? 'Weiter' : 'Pause';
}
}
// Erstelle Animation
function erstelleAnimationen(karte, nachRechts) {
const deltaXinVw = (KARTENBREITE_VW * 3) - (KARTENBREITE_VW / 3) + 1;
const deltaX = (deltaXinVw * window.innerWidth) / 100;
const startZ = nachRechts ? karte.dataset.linksZ : karte.dataset.rechtsZ;
const endZ = nachRechts ? karte.dataset.rechtsZ : karte.dataset.linksZ;
const startX = nachRechts ? 0 : deltaX;
const endX = nachRechts ? deltaX : 0;
// Animation mit allen Transformationen (translateX + translateZ + rotateY + rotateX)
const startRotateY = nachRechts ? 0 : 180;
const endRotateY = nachRechts ? 180 : 0;
const animation = karte.animate([
{
transform: `translateX(${startX}px) translateZ(${startZ}px) rotateY(${startRotateY}deg) rotateX(0deg)`,
offset: 0
},
{
transform: `translateX(${startX + (endX - startX) * 0.3}px) translateZ(${Math.max(startZ, endZ) + MAX_FLUGHOEHE * 0.8}px) rotateY(${startRotateY + (endRotateY - startRotateY) * 0.3}deg) rotateX(30deg)`,
offset: 0.3
},
{
transform: `translateX(${startX + (endX - startX) * 0.5}px) translateZ(${Math.max(startZ, endZ) + MAX_FLUGHOEHE}px) rotateY(${(startRotateY + endRotateY) / 2}deg) rotateX(40deg)`,
offset: 0.5
},
{
transform: `translateX(${startX + (endX - startX) * 0.7}px) translateZ(${Math.max(startZ, endZ) + MAX_FLUGHOEHE * 0.8}px) rotateY(${startRotateY + (endRotateY - startRotateY) * 0.7}deg) rotateX(20deg)`,
offset: 0.7
},
{
transform: `translateX(${endX}px) translateZ(${endZ}px) rotateY(${endRotateY}deg) rotateX(0deg)`,
offset: 1
}
], {
duration: ANIMATION_DAUER,
easing: 'ease-in-out',
fill: 'forwards'
});
return { animation };
}
// Animation beenden und aufräumen
function beendeAnimation(karte, neueSeite, animObj) {
karte.dataset.seite = neueSeite;
// Setze finale Transformationen
const deltaXinVw = (KARTENBREITE_VW * 3) - (KARTENBREITE_VW / 3) + 1;
const deltaX = (deltaXinVw * window.innerWidth) / 100;
const finalX = neueSeite === 'rechts' ? deltaX : 0;
const finalZ = neueSeite === 'rechts' ? karte.dataset.rechtsZ : karte.dataset.linksZ;
const finalRotateY = neueSeite === 'rechts' ? 180 : 0;
karte.style.transform = `translateX(${finalX}px) translateZ(${finalZ}px) rotateY(${finalRotateY}deg) rotateX(0deg)`;
karte.classList.remove('animating');
laufendeAnimationen = laufendeAnimationen.filter(a => a.karte !== karte);
// Timeline-Tracker zurücksetzen wenn Animation beendet
if (animationsTracker && animationsTracker.karte === karte) {
animationsTracker = null;
aktualisiereTimelineInfo();
}
aktualisiereButtons();
}
// Animation starten
function starteAnimation(karte, nachRechts) {
if (!karte) return;
// Zusätzliche Sicherheit: Prüfe ob bereits animiert
if (karte.classList.contains('animating')) {
return;
}
karte.classList.add('animating');
const animObj = erstelleAnimationen(karte, nachRechts);
const neueSeite = nachRechts ? 'rechts' : 'links';
laufendeAnimationen.push({
karte,
animation: animObj.animation
});
// Starte Timeline-Tracking für diese Animation
animationsTracker = {
karte: karte,
karteNr: parseInt(karte.dataset.karte),
startZeit: Date.now(),
animation: animObj.animation
};
aktualisiereTimelineInfo();
aktualisiereButtons();
animObj.animation.onfinish = () => {
beendeAnimation(karte, neueSeite, animObj);
};
}
// Event-Handler-Funktionen
function losClickHandler() {
starteAnimation(findeObersteKarte(), true);
}
function pauseClickHandler() {
if (laufendeAnimationen.length === 0) return;
const allesPausiert = laufendeAnimationen.every(a => a.animation.playState === 'paused');
laufendeAnimationen.forEach(({ animation }) => {
allesPausiert ? animation.play() : animation.pause();
});
// Bei Fortsetzung der Animation: Timeline-Info neu starten
if (allesPausiert && animationsTracker) {
// Korrigiere die Startzeit basierend auf der aktuellen Animation-Zeit
const animation = animationsTracker.animation;
if (animation.currentTime) {
animationsTracker.startZeit = Date.now() - animation.currentTime;
}
aktualisiereTimelineInfo();
}
aktualisiereButtons();
}
function zurueckClickHandler() {
starteAnimation(findeObersteRechteKarte(), false);
}
// Event-Listener registrieren (mit { once: false } explizit, ist aber default)
losButton.addEventListener('click', losClickHandler);
pauseButton.addEventListener('click', pauseClickHandler);
zurueckButton.addEventListener('click', zurueckClickHandler);
// Initiale Z-Index Positionen für gestapelte Karten
karten.forEach((karte) => {
const kartenNr = parseInt(karte.dataset.karte);
// Prüfe ob diese Karte bereits initialisiert wurde
if (karte.dataset.karteInitialisiert === 'true') {
return; // Überspringe bereits initialisierte Karten
}
karte.dataset.karteInitialisiert = 'true';
karte.style.zIndex = kartenNr;
const linksZ = kartenNr * 3;
const rechtsZ = (4 - kartenNr) * 3;
karte.dataset.linksZ = linksZ;
karte.dataset.rechtsZ = rechtsZ;
karte.style.transform = `translateX(0px) translateZ(${linksZ}px) rotateY(0deg) rotateX(0deg)`;
// Click-Handler für jede Karte
karte.addEventListener('click', (event) => {
const hatPausierteAnimationen = laufendeAnimationen.length > 0 &&
laufendeAnimationen.some(a => a.animation.playState === 'paused');
if (karte.classList.contains('animating') || hatPausierteAnimationen) return;
const seite = karte.dataset.seite;
const obersteKarte = seite === 'links' ? findeObersteKarte() : findeObersteRechteKarte();
if (karte === obersteKarte) {
// Verhindere Event-Bubbling
event.stopPropagation();
event.preventDefault();
(seite === 'links' ? losButton : zurueckButton).click();
}
});
});
// Initiale Button-Zustände setzen
aktualisiereButtons();
// Initiale Timeline-Anzeige setzen
aktualisiereTimelineInfo();
});
</script>
</body>
</html>