Startseite / Artikel

Drift-freie Countdown-Timer in React: Von setTimeout bis zu reinem CSS

Vergleichen Sie setTimeout, requestAnimationFrame sowie eine JavaScript-freie CSS-Technik für Countdowns in React, einschließlich des Tricks mit einzelnem Verzögerungswert, der die Ziffern synchron hält.

Verlag: REACTAPP.TOP17 Min. Lesezeit4011 Wörter

Quellen: How I Build a Countdown Timer in React

Eine Gutscheinkarte mit Ablaufdatum benötigt eine sichtbare Uhr: Stunden, Minuten und Sekunden, die bis zum Verschwinden des Angebots herunterzählen. Dieses Element ist zwar winzig, hat aber großen Einfluss auf das Produktbild. Ein Timer, der hinter der echten Zeit zurückbleibt, plötzlich um zwei Sekunden springt oder den Lüfter eines Notebooks weiterhin leise laufen lässt, untergräbt das Vertrauen des Nutzers in alles um ihn herum. Diese Anleitung zeigt drei Möglichkeiten, einen solchen Countdown in React zu erstellen, erläutert genau, warum die erste Methode abweicht, und endet mit einer rein CSS-basierten Technik, die Hunderte von Timern ohne jeglichen JavaScript-Code pro Zählvorgang steuern kann.

Das Problem in einem Satz

Jeder Gutschein weist einen claim_end_time auf. Anzeige und Uhrzeit sollten anzeigen, wie viel Zeit bis zu diesem Moment verbleibt – im Format HH:MM:SS und alle Sekunde aktualisiert – und beim Ablauf des Gutscheins bei null anhalten.

Die Arithmetik ist trivial. Der schwierige Teil besteht darin, zu entscheiden wie und wann sie neu berechnet werden soll – diese Entscheidung bestimmt die Genauigkeit, den CPU-Verbrauch sowie das, was man tun kann, wenn die Zeit ausläuft.

Ansatz 1: Eine setTimeout-Kette und warum sie abweicht

Die intuitive Implementierung besteht aus einer Funktion, die die Anzeige aktualisiert und sich anschließend eine Sekunde später erneut ansetzt.

function tick() {
  updateDisplay();
  setTimeout(tick, 1000);
}
tick();

Es liest sich klar und scheint in einem schnellen Test zu funktionieren. Das Problem liegt darin, wie Timer übergeben werden. JavaScript auf der Seite läuft in einem einzigen Hauptthread ab, und der Event-Loop gibt ihm jeweils nur eine Aufgabe nach der anderen. Ein Timer-Callback ist einfach nur eine weitere Aufgabe, die hinter Klick-Handlern, Netzwerk-Callbacks, Render-Arbeiten sowie allen anderen Aufgaben, die React abgleicht, in der Warteschlange steht. Der Wert 1000, den Sie übergeben, ist ein Minimum und keine Garantie. Wenn der Thread zum Zeitpunkt des Ablaufs einer Sekunde beschäftigt ist, wartet der Callback – Sie könnten ihn stattdessen nach 1.050 ms oder 1.200 ms erhalten. Wenn Sie einen Überblick darüber wollen, warum sich der Hauptthread so verhält, lesen Sie unseren Artikel zu JavaScripts einziger Thread im Vergleich zum mehrprozessigen Engine-Modell des Browsers.

Was gelegentliche Verspätungen in eine sichtbare Abweichung verwandelt, ist die Kette der Aufgaben. Jeder Zeitstempel plant den nächsten Zeitstempel aus sich selbst heraus, sodass jede Verzögerung auf alle folgenden Zeitstempel übertragen wird. Nichts bringt die Abfolge jemals wieder auf die Uhrzeit des Wanduhrs. Lassen Sie einen solchen Loop zehn Minuten lang laufen, vergleichen Sie ihn mit Date.now(), und Sie werden sehen, dass er zurückbleibt.

Ein Timer, der bei jedem Zeitstempel einen Zähler verringert, misst Callbacks und nicht die verstrichene Zeit, und das Drosseln von Hintergrundtabellen macht den Unterschied sogar noch größer.

Man kann dies ausgleichen, indem man misst, wie spät jeder Callback ausgeführt wird, und die nächste Verzögerung entsprechend verkürzt. Das funktioniert zwar, doch man muss anschließend eine Korrekturlogik pflegen, die gegen die Plattform arbeitet anstatt von ihr zu profitieren. In jedem Fall läuft der Code weiterhin jede Sekunde auf dem Hauptthread.

Ansatz 2: requestAnimationFrame und Neuberechnung anhand der Uhrzeit

requestAnimationFrame (rAF) bittet den Browser, Ihre Funktion unmittelbar vor dem nächsten Neubilden des Bildschirms aufzurufen, was bei den meisten Anzeigen etwa 60 Mal pro Sekunde geschieht. Die Häufigkeit ist jedoch nicht das Wichtige. Der entscheidende Unterschied liegt konzeptionell: Anstatt zu fragen „Ist eine weitere Sekunde vergangen?“, fragt man stattdessen „Wie viel Zeit bleibt genau in diesem Moment?“

function updateTime() {
  const now = Date.now();
  const remaining = endTime - now;
  if (remaining <= 0) {
    display('00:00:00');
    return;
  }
  display(format(remaining));
  requestAnimationFrame(updateTime);
}
requestAnimationFrame(updateTime);

In jedem Frame wird die verbleibende Zeit von Grund auf berechnet, wobei Date.now() als einzige zuverlässige Quelle verwendet wird. Es gibt keinen Taktzähler und keine akkumulierten Intervalle. Wenn ein Frame verspätet ankommt, liest der nächste einfach erneut die Uhrzeit ab und zeigt den korrekten Wert an. Fehler können sich nicht ansammeln, da außer der Endzeit selbst nichts von einem Frame zum nächsten übertragen wird. Derselbe Grundsatz gilt auch für die setTimeout-Version: Wenn jede Callback-Funktion die Zeit erneut aus Date.now() berechnet anstelle eines Zählers zu verringern, korrigiert sich die Anzeige auch dann selbst, wenn die Callbacks verspätet ausgeführt werden.

Ein berechtigter Einwand: Der sichtbare Wert ändert sich nur einmal pro Sekunde – warum also 60 Mal pro Sekunde arbeiten? In React liegt der aufwändige Teil nicht in der Subtraktion, sondern in der erneuten Darstellung, die durch setState ausgelöst wird. Daher vergleicht der Loop in jedem Frame und aktualisiert den Zustand nur, wenn sich der Wert tatsächlich über eine volle Sekunde hinweg geändert hat.

Zustand nur bei Sekundenwechsel aktualisieren

So sieht diese Idee innerhalb eines Components aus. Beachten Sie, dass endTime ein Unix-Zeitstempel in Sekunden ist – deshalb wird er vor dem Vergleich mit Date.now() mit 1000 multipliziert.

const updateTime = (): void => {
  const now = Date.now();
  const timeTillStop = endTime * 1000 - now;
  if (timeTillStop <= 0) {
    setTimeLeft({ hours: 0, minutes: 0, seconds: 0 });
    onNoCountdownTimeLeft?.();
    return;
  }
  const totalSeconds = Math.floor(timeTillStop / 1000);
  if (totalSeconds !== lastSecondsRef.current) {
    lastSecondsRef.current = totalSeconds;
    const hours = Math.floor(totalSeconds / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;
    setTimeLeft({ hours, minutes, seconds });
  }
  animationRef.current = requestAnimationFrame(updateTime);
};

Zwei References übernehmen die Hauptaufgabe. lastSecondsRef speichert das letzte gerenderte Sekundenwert. Da es sich um einen Reference handelt, führt das Schreiben darauf niemals zu einer Neuerstellung des Elements; es handelt sich dabei einfach um einen Speicherplatz, der zwischen den Frames erhalten bleibt. Der Loop läuft daher in der Framerate ab, während React höchstens einmal pro Sekunde rendernt. Die optionale Callback-Funktion onNoCountdownTimeLeft wird aufgerufen, sobald die Zeit abgelaufen ist, wodurch dem Elternelement eine Möglichkeit gegeben wird, die Karte zu verbergen, Daten erneut abzurufen oder einen „abgelaufenen“ Zustand anzuzeigen.

Der zweite Reference, animationRef, speichert die von requestAnimationFrame zurückgegebene ID. Die Cleanup-Funktion des Effects verwendet diese, um den ausstehenden Frame zu stornieren, wenn das Komponente deinstalliert wird oder sich endTime ändert:

return () => {
  if (animationRef.current) {
    cancelAnimationFrame(animationRef.current);
  }
};

Ohne diese Bereinigung würde eine Schleife weiterlaufen, nachdem der Benutzer weggegangen ist, wodurch setState auf einem nicht mehr geladenen Komponenten aufgerufen wird und CPU-Ressourcen verschwendet werden. Mit dieser Bereinigung ist die Komponente präzise, kostengünstig in der Darstellung und selbstkorrigierend.

Eine paar praktische Anmerkungen: Browser pausieren rAF-Aufrufe in versteckten Tabellen, was hier tatsächlich hilfreich ist – die Schleife kostet im Hintergrund nichts mehr, und das erste Bild nachdem der Tab sichtbar wird, liest die Uhrzeit ab und springt direkt zum richtigen Wert. Der Vergleich pro Frame ist für einen einzigen Timer unbedeutend, summiert sich aber bei Dutzenden von Karten auf.

Ansatz 3: Eine Countdown-Funktion rein aus CSS

Die rAF-Version führt weiterhin JavaScript in jedem Frame aus. Der dritte Ansatz beseitigt das vollständig: JavaScript berechnet bei der Initialisierung der Komponente eine einzige Zahl, und ab dann übernimmt der Animationssystem des Browsers jede weitere Abfrage.

Die Countdown-Anzeige wird zu einer CSS-Animation, die nacheinander die Ziffern anzeigt. Dabei wird mit einer negativen Verzögerung begonnen, damit die Animation zur richtigen Zeit mitten in der Abfolge startet.

Eine Klarstellung zur Leistung, bevor wir fortfahren: Man liest oft, dass CSS-Animationen immer auf dem Compositor-Thread ausgeführt werden und daher niemals blockiert werden können. Das gilt nur für eine kleine Anzahl von Eigenschaften, hauptsächlich transform, opacity sowie einige Filter. Die Animationierung registrierter benutzerdefinierter Eigenschaften und generierten Content, wie es diese Technik tut, wird vom Styles-Engine des Browsers auf dem Hauptthread bearbeitet. Der eigentliche Vorteil ist zwar anders, aber dennoch erheblich: Es gibt keine Timer, keinen React-State und keine Wiederholungen pro Tick. Zudem hält der eigene Scheduler des Browsers, der den Fortschritt der Animation mit der Dokumentzeitlinie verknüpft, die Werte korrekt.

Damit dies funktioniert, müssen vier CSS-Funktionen zusammenarbeiten. Wir werden sie nacheinander betrachten.

Schritt 1: Animierbare Ganzzahlen mit @property deklarieren

Ein offensichtlicher erster Versuch besteht darin, die content-Eigenschaft direkt zu animieren. Das funktioniert jedoch nicht, weil content nicht interpolierbar ist: Der Browser hat keine Vorstellung davon, wie sich der String „59“ in „58“ verwandelt. Was Sie benötigen, ist eine Zahl, die der Browser animieren kann – und eine registrierte benutzerdefinierte Eigenschaft bietet genau das.

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
}

@property registriert --s mit einem Typ. Die Angabe syntax: '<integer>' teilt dem Browser mit, dass der Wert ein echter Integer und kein unsichtbarer Token ist, was es ermöglicht, ihn einzufügen und somit zu animieren. Durch die Einstellung inherits: true können Nachkommen, einschließlich der ::after-Pseudo-Elemente, den animierten Wert von ihrem Elternelement abrufen. Das bedeutet, dass eine einzige Animation am Timer-Container alle darin enthaltenen Ziffern steuert. Für einen umfassenderen Überblick über registrierte Eigenschaften und deren Unterschiede zu gewöhnlichen benutzerdefinierten Eigenschaften lesen Sie unseren Leitfaden zu CSS-benutzerdefinierten Eigenschaften zur Laufzeit und @property.

Jede Einheit erhält ihre eigene registrierte Eigenschaft. Die Stunden beginnen bei 99, was die Obergrenze darstellt, die dieser Timer anzeigen kann.

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* seconds */
@property --m {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* minutes */
@property --h {
  syntax: '<integer>';
  inherits: true;
  initial-value: 99;
} /* hours   */

Schritt 2: Das Ganzzahlwert als mit Leerzeichen gefüllter Text mit counter() darstellen

Der Ganzzahlwert muss weiterhin zu einem mit Nullen gefüllten Text werden (05, nicht 5). Die Funktion counter() gibt den Wert eines Zählers innerhalb von content aus, und ihr eingebautes Format decimal-leading-zero sorgt kostenlos für die zweistellige Auffüllung mit Nullen.

Die Verbindung zwischen der benannten Eigenschaft und dem Zähler wird durch counter-reset hergestellt: Es setzt einen benannten Zähler auf den aktuellen Wert der Eigenschaft, und counter() liest diesen wieder ab. Der Codeausschnitt ist CSS, obwohl die Quelle ihn anders bezeichnet.

.seconds-digit::after {
  counter-reset: sc var(--s); /* set the "sc" counter = current --s value */
  content: counter(sc, decimal-leading-zero); /* render as "00"–"59" */
}
.minutes-digit::after {
  counter-reset: mc var(--m);
  content: counter(mc, decimal-leading-zero);
}
.hours-digit::after {
  counter-reset: hc var(--h);
  content: counter(hc, decimal-leading-zero);
}

Jedes Mal, wenn --s auf eine neue Ganzzahl wechselt, berechnet der Stil-Engine erneut counter-reset, wodurch sich auch der content-Wert des Pseudo-Elements aktualisiert. Bei dieser Aktualisierung ist kein Skript beteiligt.

Schritt 3: Die Werte schrittweise ändern mit steps()

Standardmäßig verläuft eine Animation kontinuierlich zwischen den Schlüsselbildern. Eine Uhr darf nicht sanft gleiten; sie muss von einer Ziffer zur nächsten springen. Die Zeitfunktion steps(N, end) teilt die Dauer in N gleich große, diskrete Sprünge auf.

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
}
animation: tick-s 60s steps(60, end) infinite;

Sechzig Sprünge über eine Dauer von sechzig Sekunden bedeuten einen Sprung pro Sekunde: 59 am Anfang, 58 nach einer Sekunde, 0 nach 59 Sekunden und wieder 59, wenn die nächste Iteration beginnt.

Das Schlüsselwort end (entspricht jump-end) setzt jeden Sprung am Ende seines Zeitintervalls. Genau bei t = 0 zeigt die Animation noch ihren from-Wert an, da der erste Sprung noch nicht stattgefunden hat. Dieser Aspekt ist wichtig, wenn negative Verzögerungen eine Rolle spielen: Eine Verzögerung, die genau bei t = 0 eintritt, zeigt 59 an statt 58.

Die Schlüsselbilder bei -1 zu beenden wirkt seltsam, ist aber absichtlich so. Die Animation interpoliert über einen Bereich von 60 Ganzzahlen, und aufgrund von jump-end kehrt sie vor dem Erscheinen des Endwerts wieder zu from zurück. Die sichtbare Abfolge umfasst daher genau 59 Schritte von 0 bis hinunter, jeweils in sauberen Schritten von eins.

Jede Einheit hat ihre eigenen Schlüsselbilder und Schrittzahl. Minuten benötigen 60 Schritte à 60 Sekunden, und Stunden benötigen 100 Schritte à einer Stunde:

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
} /* 60 steps × 1s  = 60s  */
@keyframes tick-m {
  from {
    --m: 59;
  }
  to {
    --m: -1;
  }
} /* 60 steps × 60s = 1hr  */
@keyframes tick-h {
  from {
    --h: 99;
  }
  to {
    --h: -1;
  }
} /* 100 steps × 1hr = 100hr */

Schritt 4: Mit einer negativen Verzögerung mitten in der Abfolge starten

Das ist der zentrale Trick. Wenn animation-delay negativ ist, behandelt der Browser die Animation so, als wäre sie bereits vor dieser Anzahl an Sekunden gestartet, wodurch sie dort beginnt, wo sie sonst jetzt angekommen wäre.

Nehmen wir an, es bleiben 23 Sekunden in der aktuellen Minute. Die Sekunden-Animation beginnt bei 59 und muss bereits 36 Schritte zurückgegangen sein, um 23 anzuzeigen. Daher bringt eine Verzögerung von -36s sie an die richtige Stelle. Im Allgemeinen:

delay = -(59 - s)      where s = remaining seconds within the current minute

Um dies auf alle drei Einheiten anzuwenden, benötigen Sie zunächst die gesamte verbleibende Zeit in Sekunden, die aus dem Endzeitstempel berechnet wird:

const remaining = endTime - Math.floor(Date.now() / 1000);

Diese Gesamtzeit wird anschließend in die jeweiligen Anteile für jede Einheit aufgeteilt. Die Stunden werden auf 99 begrenzt, da die Stunden-Animation nur 100 Schritte umfasst:

const s = remaining % 60;
const m = Math.floor(remaining / 60) % 60;
const h = Math.min(Math.floor(remaining / 3600), 99);

Bedenken Sie diese Begrenzung als Produktbeschränkung. Ein Gutschein, der in fünf Tagen abläuft, würde 99:59:59 anzeigen und scheinbar blockiert bleiben, bis weniger als 100 Stunden verbleiben. Wenn Ihre Angebote so lange laufen können, fügen Sie eine Tageseinheit hinzu oder wechseln Sie das Format.

Synchronisierung der Ziffern mit einer einzigen Verzögerung

Der offensichtliche nächste Schritt besteht darin, eine separate Verzögerung pro Einheit zu berechnen. Dadurch entsteht ein gravierender Fehler: Die Sekunden springen auf 59, während die Minuten viel zu lange unverändert bleiben.

Warum sich separate Verzögerungen voneinander entfernen

Nehmen wir remaining = 125, was 0 Stunden, 2 Minuten und 5 Sekunden entspricht. Die unabhängige Berechnung der Sekunden- und Minutenverzögerungen ergibt:

delayS = -(59 - 5)       = -54s     → seconds virtual start: now - 54s
delayM = -(59 - 2) × 60  = -3420s   → minutes virtual start: now - 3420s

Jede Animation misst ihre Sprünge relativ zu einer privaten, virtuellen Startzeit. Die Sekunden-Schritte werden jede Sekunde ab now - 54s ausgelöst; die Minuten-Schritte werden alle 60 Sekunden ab now - 3420s ausgelöst. Da die Ausgangspunkte unterschiedlich sind, fallen die Grenzen zu unzusammenhängenden Zeiten an. Die Sekundenstelle erreicht hier ihren nächsten Überschlag:

now - 54 + 60 = now + 6s

Die Minutenstelle hingegen springt erst dann weiter:

now - 3420 + (58 × 60) = now - 3420 + 3480 = now + 60s

So werden in sechs Sekunden die Sekunden wiederholt, während in einer vollen Minute die Minuten wechseln. Während 54 Sekunden zeigt das Display 01:59, obwohl der tatsächliche Wert unter 01:00 liegt. Die Anzeige ist den größten Teil jeder Minute falsch.

Eine gemeinsame Ausgangswert

Die Lösung besteht darin, für alle drei Animationen einen gemeinsamen Ausgangswert zu verwenden, der durch die Kombination der Versatzwerte zu einer einzigen Verzögerung erzielt wird:

const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));
//           = -(  99×3600      +   57×60       +   54   )
//           = -(356400 + 3420 + 54)
//           = -359874s

Jede Animation misst nun ab now - 359874s. Ihre Grenzen sind jeweils Vielfache der Dauer ihres eigenen Schritts, gerechnet von diesem Ausgangspunkt aus. Schauen Sie sich das Beispiel an: Innerhalb eines 60-sekündigen Zeitraums befindet sich die Sekunden-Animation bei Position 54, sodass ihre nächste Grenze nach 6 Sekunden erreicht wird. Innerhalb eines 3.600-sekündigen Zeitraums liegt die Minuten-Animation bei Position 3.474, und ihre nächste Grenze ist bei 3.480 – ebenfalls nach 6 Sekunden. Beide Werte ändern sich gemeinsam bei now + 6s und bleiben danach alle 60 Sekunden synchron.

Wie eine Zahl drei Ziffern kodiert

Die kombinierte Verzögerung funktioniert wie eine Zahl mit gemischter Basis, ähnlich wie HH:MM:SS drei Felder in einen String packt. Die Kodierung erfolgt aus den drei Komponenten:

|delay| = (99 - h) × 3600    ← hours field,   each unit = 3600s
        + (59 - m) × 60      ← minutes field, each unit = 60s
        + (59 - s)           ← seconds field, each unit = 1s

Die Dekodierung wird von jeder Animation implizit durchgeführt, indem sie nur ihren eigenen Zeitraum betrachtet:

seconds position = |delay| mod 60                 → display 59 - position
minutes position = floor((|delay| mod 3600) / 60) → display 59 - position
hours   position = floor(|delay| / 3600)          → display 99 - position

Durch Ausführen des Beispiels mit dem Dekoder wird dies bestätigt. 359.874 modulo 60 ergibt 54, daher zeigen die Sekunden 59 − 54 = 5 an. 359.874 modulo 3.600 ergibt 3.474, was geteilt durch 60 und abgerundet auf das nächste ganze Zahl ergibt 57, sodass die Minuten 59 − 57 = 2 anzeigen. 359.874 geteilt durch 3.600 und abgerundet ergibt 99, wodurch die Stunden 99 − 99 = 0 anzeigen. Der Anzeigewert lautet 00:02:05, was korrekt ist.

Die Animationen können sich gegenseitig nicht stören, da jedes Feld genau das Vielfache des Zyklus des folgenden Felds darstellt: Der Stundenanteil ist immer ein Vielfaches von 3.600 und der Minutenanteil immer ein Vielfaches von 60, wodurch keiner die darunterliegenden Angaben stört.

Sauberes Anhalten bei 00:00:00

Die Ticken-Animationen verwenden eine unendliche Iteration, weshalb sie von selbst niemals aufhören. Wenn der Sekundenzyklus endet, springt er auf 59 zurück, und ein Timer, der null erreicht, würde fröhlich wieder mit dem Zählen nach unten beginnen.

Die Lösung besteht darin, eine weitere Animation hinzuzufügen, die genau zum Zeitpunkt des Ablaufs einmal abgespielt wird und alle drei Eigenschaften auf null setzt. Dieser Block ist trotz seines ursprünglichen Namens ebenfalls CSS:

@keyframes freeze {
  to {
    --s: 0;
    --m: 0;
    --h: 0;
  }
}
.timer {
  animation-name: tick-s, tick-m, tick-h, freeze;
  animation-duration: 60s, 3600s, 360000s, 1ms;
  animation-timing-function: steps(60, end), steps(60, end), steps(100, end),
    step-start;
  animation-iteration-count: infinite, infinite, infinite, 1;
  animation-fill-mode: none, none, none, forwards;
}

JavaScript weist die gemeinsame Verzögerung den drei Tick-Animationen zu und die verbleibende Zeit als positive Verzögerung der Freeze-Animation:

el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;

Drei Faktoren wirken hier zusammen:

  • step-start (das gleiche wie steps(1, jump-start)) springt sofort zum letzten Keyframe, sobald die Animation beginnt – anstatt am Ende ihrer 1 ms dauernden Laufzeit – sodass alle drei Werte im selben Moment, in dem die Verzögerung abläuft, auf 0 werden.
  • animation-fill-mode: forwards sorgt dafür, dass der letzte Keyframe auch nach Abschluss der Animation weiterhin angewendet bleibt, wodurch die Werte dauerhaft bei null bleiben.
  • Die Reihenfolge in animation-name ist wichtig: Wenn mehrere Animationen dieselbe Eigenschaft setzen, gewinnt die später aufgeführte. Da freeze zuletzt kommt, überschreiben seine Werte die Tick-Animationen, die weiterhin im Hintergrund ablaufen, ohne sichtbaren Effekt zu erzeugen.
  • Falls der Gutschein bereits abgelaufen ist, wenn die Seite geladen wird, beträgt die verbleibende Zeit 0, die Verzögerung durch freeze ist 0s, und der Timer zeigt sofort Nullen an.

    Was die CSS-Lösung Sie kostet

    Das JavaScript-freie Design hat echte Einschränkungen, die Ihre Entscheidung beeinflussen sollten:

    • Kein Ablauf-Hook. Es gibt keinen Callback, kein Ereignis oder keine Zustandsänderung, wenn der Timer auf Null kommt. Wenn ein Ablauf einen API-Aufruf auslösen muss, um die Karte zu verbergen oder etwas anderes anzuzeigen, benötigen Sie zusätzlich einen separaten einmaligen setTimeout, was etwas von der Einfachheit zurückgibt.
    • Eingeschränktes Styling. counter() erzeugt reinen Text. Schriftart, Farbe und Layout liegen vollständig in Ihrer Kontrolle, doch alles, was von dem aktuellen Wert in JavaScript abhängt – wie zum Beispiel der Wechsel auf Rot nach 60 Sekunden oder das Hinzufügen einer Klasse bei einem bestimmten Wert – erfordert zusätzliche Arbeit.
    • Genauigkeit bis zur vollen Sekunde. Die verbleibende Zeit wird bei der Initialisierung auf die nächste ganze Sekunde abgerundet, wodurch die sichtbaren Zeiger bis zu einer Sekunde von den tatsächlichen Sekundengrenzen abweichen können. Das ist bei Werbezähldowns meist unerheblich, handelt sich aber nicht um eine präzise Uhr.
    • Browserunterstützung. Die Technik setzt @property sowie animierte, registrierte Eigenschaften voraus; überprüfen Sie vor der Verwendung die Unterstützung in Ihren aktuellen Kompatibilitätsdaten für Ihre verwendeten Browser.

    Im Gegenzug ist eine nur zum Zählen und Anhalten verwendete, read-only-Zeitlupe äußerst effizient: eine Animationsserie pro Karte, kein JavaScript pro Zählvorgang, keine Abweichungen und nichts, was beim Entfernen der Komponente bereinigt werden muss. Sie kommt besonders gut zur Geltung, wenn eine Seite gleichzeitig Hunderte von Gutschein-Karten anzeigt.

    Die React-Komponente für den CSS-Zähler

    Die CSS-Version verändert die Form der Komponente. Es gibt keinen Zustand, keine Animationsschleife und keine Nachbereitung. Die Komponente liest die Uhrzeit einmal ab, schreibt eine Stil-Eigenschaft und rendernt das Markup. Dafür benötigt sie nur zwei Hooks von React:

    import { useEffect, useRef } from 'react';
    

    Die untenstehende Komponente berechnet die verbleibenden Sekunden, teilt sie in Einheiten auf, erstellt die gemeinsame Verzögerung und schreibt die vier Verzögerungen in das Element. Die type-Eigenschaft wechselt lediglich zwischen zwei visuellen Stilen der Ziffern.

    interface CSSCountdownTimerProps {
      endTime: number; // Unix timestamp in seconds, same as existing prop
      type?: 'solid' | 'regular';
    }export function CSSCountdownTimer({
      endTime,
      type = 'solid',
    }: CSSCountdownTimerProps) {
      const timerRef = useRef<HTMLDivElement>(null);  useEffect(() => {
        const el = timerRef.current;
        if (!el) return;    const now = Math.floor(Date.now() / 1000);
        const remaining = Math.max(0, endTime - now);    const s = remaining % 60;
        const m = Math.floor(remaining / 60) % 60;
        const h = Math.min(Math.floor(remaining / 3600), 99);    const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));    el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;    // No cleanup needed.
        // There are no JS timers, no rAF loops, no subscriptions.
        // CSS animations stop automatically when the element is removed from the DOM.
      }, [endTime]);  return (
        <div ref={timerRef} className={`timer ${type}`}>
          <span
            className={type === 'solid' ? 'digit hours-digit' : 'hours-digit'}
          />
          <span className="sep">:</span>
          <span
            className={type === 'solid' ? 'digit minutes-digit' : 'minutes-digit'}
          />
          <span className="sep">:</span>
          <span
            className={type === 'solid' ? 'digit seconds-digit' : 'seconds-digit'}
          />
        </div>
      );
    }
    

    Wie die Kommentare erwähnen, ist keine Nachbereitung erforderlich: Wenn das Element den DOM verlässt, gehen seine Animationen mit ihm. Zwei Verbesserungen sind zu berücksichtigen. Erstens läuft useEffect nachdem der Browser gezeichnet hat, wodurch im ersten Frame die Animationen kurzzeitig in ihren Standardpositionen angezeigt werden können, bevor die Verzögerung angewendet wird; useLayoutEffect oder die Berechnung der Verzögerung während des Renderns und deren Übermittlung als Inline-Style verhindern dieses Flackern. Zweitens hängt der Effekt von endTime ab, sodass bei einer Änderung der Eigenschaft die Verzögerungen neu berechnet werden und sich die Animationen korrekt neu positionieren.

    Auswahl zwischen den drei Ansätzen

    Eine einfache Entscheidungsregel deckt die meisten Fälle ab:

    • Falls etwas ausgelöst werden muss, wenn der Timer abläuft – beispielsweise eine Zustandsaktualisierung, ein Neuladen oder ein API-Aufruf – verwenden Sie requestAnimationFrame in Kombination mit einer zeitbasierten Neuberechnung. Dadurch haben Sie volle Kontrolle und einen geeigneten Ort für den Ablauffunktion.
    • Falls eine Seite viele Timer anzeigt und keiner davon Logik auslöst, wenden Sie die CSS-Technik an und überlassen Sie die Arbeit dem Browser.
    • Falls Sie eine einfache setTimeout-Kette verwenden, die einen Zähler verringert, ersetzen Sie diese. Sie ist ungenau und verbraucht weiterhin jede Sekunde Ressourcen auf der Hauptthread, wodurch sich die Nachteile beider Alternativen addieren. Beide Ansätze können auch kombiniert werden: CSS zur Anzeige und ein gemeinsamer Timer für die Ablauffunktionalität.

    Hauptpunkte

    • Timer-Verzögerungen sind nur Mindestwerte; ermitteln Sie die verbleibende Zeit stattdessen bei jeder Aktualisierung mit Date.now(), sodass keine Abweichungen entstehen.
  • Mit rAF wird die letzte renderierte Sekunde in einem Referenzwert gespeichert und das Frame wird beim Entfernen gestoppt.
  • Die CSS-Version basiert auf vier Funktionen: Ganzzahlen mit @property, counter() für ausgefüllten Text, steps() für diskrete Sprünge sowie eine negative Verzögerung, um die Anzeige mitten in einer Sequenz zu starten.
  • Man teilt eine einzige Verzögerung allen Einheiten zu, sodass die Ziffern gemeinsam übergehen, und fügt eine freeze-Animation hinzu, um Nullen festzuhalten.