diff --git a/src/back/jdd-components/countdown/countdown.css b/src/back/jdd-components/countdown/countdown.css
new file mode 100644
index 0000000..29ba59e
--- /dev/null
+++ b/src/back/jdd-components/countdown/countdown.css
@@ -0,0 +1,58 @@
+@keyframes countdown-tick {
+ 0% {
+ opacity: 1;
+ }
+ 49% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
+
+.countdown {
+ --height: 60px;
+ font-size: 32px;
+
+ .countdown__wrapper {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: center;
+ }
+
+ .countdown-number {
+ background-color: var(--color-brand-text-bg);
+ color: var(--color-brand-text-fg);
+
+ display: inline-flex;
+ flex-flow: column;
+ height: var(--height);
+ padding: 8px 16px;
+ margin: 4px;
+ text-align: center;
+
+ .countdown-number__unit {
+ font-size: 16px;
+ }
+ }
+
+ .separator {
+ display: flex;
+ height: var(--height);
+ align-items: center;
+ }
+
+ .separator--seconds {
+ animation: countdown-tick linear infinite 2s;
+ }
+
+ @container (max-width: 400px) {
+ .separator--seconds,
+ [data-unit="second"] {
+ display: none;
+ }
+ }
+}
diff --git a/src/back/jdd-components/countdown/countdown.jdd.tsx b/src/back/jdd-components/countdown/countdown.jdd.tsx
new file mode 100644
index 0000000..485f502
--- /dev/null
+++ b/src/back/jdd-components/countdown/countdown.jdd.tsx
@@ -0,0 +1,72 @@
+import { TempstreamJSX } from "tempstream";
+import type { ComponentToHTMLArgs } from "@sealcode/jdd";
+import { Component, ComponentArguments } from "@sealcode/jdd";
+import { calculateDifference } from "./difference.js";
+
+const component_arguments = {
+ date: new ComponentArguments.ShortText().setExampleValues(["2025-08-05"]),
+} as const;
+
+function renderBlock({
+ number,
+ unit,
+ unit_label,
+}: {
+ number: number;
+ unit: "day" | "hour" | "minute" | "second";
+ unit_label: string;
+}) {
+ return (
+
+
+ {unit == "second" || unit == "minute"
+ ? number.toString().padStart(2, "0")
+ : number}
+
+
{unit_label}
+
+ );
+}
+
+export class Countdown extends Component {
+ getArguments() {
+ return component_arguments;
+ }
+
+ async toHTML({
+ args: { date },
+ classes,
+ jdd_context: { render_markdown, render_image, language },
+ }: ComponentToHTMLArgs): Promise {
+ const { days, hours, minutes, seconds } = calculateDifference(date);
+ return (
+
+
+ {renderBlock({ number: days, unit: "day", unit_label: "days" })}
+ {renderBlock({ number: hours, unit: "hour", unit_label: "hours" })}
+ :
+ {renderBlock({
+ number: minutes,
+ unit: "minute",
+ unit_label: "minutes",
+ })}
+
+ :
+
+ {renderBlock({
+ number: seconds,
+ unit: "second",
+ unit_label: "seconds",
+ })}
+
+
+ );
+ }
+}
diff --git a/src/back/jdd-components/countdown/countdown.stimulus.ts b/src/back/jdd-components/countdown/countdown.stimulus.ts
new file mode 100644
index 0000000..3130e45
--- /dev/null
+++ b/src/back/jdd-components/countdown/countdown.stimulus.ts
@@ -0,0 +1,40 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
+import { Controller } from "stimulus";
+import { calculateDifference } from "./difference.js";
+
+export default class Countdown extends Controller {
+ currentIndex = 0;
+ interval_id: number;
+
+ declare dateValue: string;
+
+ static values = {
+ date: String,
+ };
+
+ static targets = ["day", "hour", "minute", "second", "ticker"];
+ declare dayTarget: HTMLDivElement;
+ declare hourTarget: HTMLDivElement;
+ declare minuteTarget: HTMLDivElement;
+ declare secondTarget: HTMLDivElement;
+ declare tickerTarget: HTMLDivElement;
+
+ async connect() {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ this.interval_id = setInterval(() => this.tick(), 1000) as unknown as number;
+ this.tickerTarget.style.setProperty("animation", "none"); // we're gonna animate it ourselves to keep it in sync with the js-changed seconds countdown
+ }
+
+ async disconnect() {
+ clearInterval(this.interval_id);
+ }
+
+ tick() {
+ const { days, hours, minutes, seconds } = calculateDifference(this.dateValue);
+ this.dayTarget.textContent = days.toString();
+ this.hourTarget.textContent = hours.toString();
+ this.minuteTarget.textContent = minutes.toString().padStart(2, "0");
+ this.secondTarget.textContent = seconds.toString().padStart(2, "0");
+ this.tickerTarget.style.setProperty("opacity", seconds % 2 ? "0" : "1");
+ }
+}
diff --git a/src/back/jdd-components/countdown/difference.ts b/src/back/jdd-components/countdown/difference.ts
new file mode 100644
index 0000000..b694db7
--- /dev/null
+++ b/src/back/jdd-components/countdown/difference.ts
@@ -0,0 +1,20 @@
+const second = 1000;
+const minute = 60 * second;
+const hour = 60 * minute;
+const day = 24 * hour;
+
+export function calculateDifference(date: string) {
+ const now = new Date();
+ const target_date = new Date(date);
+ const distance = target_date.getTime() - now.getTime();
+ if (distance <= 0) {
+ return { days: 0, hours: 0, minutes: 0, seconds: 0 };
+ }
+ const days = Math.floor(distance / day);
+ const hours = Math.floor((distance - days * day) / hour);
+ const minutes = Math.floor((distance - days * day - hours * hour) / minute);
+ const seconds = Math.floor(
+ (distance - days * day - hours * hour - minutes * minute) / second
+ );
+ return { days, hours, minutes, seconds };
+}
diff --git a/src/front/controllers.ts b/src/front/controllers.ts
index 2a22e3f..6c67d0b 100644
--- a/src/front/controllers.ts
+++ b/src/front/controllers.ts
@@ -7,6 +7,9 @@ const application = Application.start();
import { default as AutoscrollingImages } from "./../back/jdd-components/autoscrolling-images/autoscrolling-images.stimulus.js";
application.register("autoscrolling-images", AutoscrollingImages);
+import { default as Countdown } from "./../back/jdd-components/countdown/countdown.stimulus.js";
+application.register("countdown", Countdown);
+
import { default as MapWithPins } from "./../back/jdd-components/map-with-pins/map-with-pins.stimulus.js";
application.register("map-with-pins", MapWithPins);