diff --git a/src/renderer/components/countdown/countdown-state.injectable.ts b/src/renderer/components/countdown/countdown-state.injectable.ts
new file mode 100644
index 0000000000..6cd290b4cf
--- /dev/null
+++ b/src/renderer/components/countdown/countdown-state.injectable.ts
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
+import {
+ computed,
+ observable,
+ onBecomeObserved,
+ onBecomeUnobserved,
+ runInAction,
+} from "mobx";
+
+const countdownStateInjectable = getInjectable({
+ id: "countdown-state",
+
+ instantiate: (
+ di,
+ { startFrom, onZero }: { startFrom: number; onZero: () => void },
+ ) => {
+ const state = observable.box(startFrom);
+
+ let intervalId: NodeJS.Timer | undefined;
+
+ const stop = () => {
+ clearInterval(intervalId);
+ };
+
+ const start = () => {
+ intervalId = setInterval(() => {
+ const secondsLeft = state.get() - 1;
+
+ runInAction(() => {
+ state.set(secondsLeft);
+ });
+
+ if (secondsLeft === 0) {
+ stop();
+ onZero();
+ }
+ }, 1000);
+ };
+
+ onBecomeObserved(state, start);
+ onBecomeUnobserved(state, stop);
+
+ return computed(() => state.get());
+ },
+
+ lifecycle: lifecycleEnum.transient,
+});
+
+export default countdownStateInjectable;
diff --git a/src/renderer/components/countdown/countdown.test.tsx b/src/renderer/components/countdown/countdown.test.tsx
new file mode 100644
index 0000000000..9bb08f486b
--- /dev/null
+++ b/src/renderer/components/countdown/countdown.test.tsx
@@ -0,0 +1,142 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import type { DiContainer } from "@ogre-tools/injectable";
+import { createContainer } from "@ogre-tools/injectable";
+import countdownStateInjectable from "./countdown-state.injectable";
+import type { DiRender } from "../test-utils/renderFor";
+import { renderFor } from "../test-utils/renderFor";
+import { Countdown } from "./countdown";
+import React from "react";
+import type { RenderResult } from "@testing-library/react";
+import { advanceFakeTime, useFakeTime } from "../../../common/test-utils/use-fake-time";
+import type { IComputedValue } from "mobx";
+import { observe } from "mobx";
+import { noop } from "../../../common/utils";
+
+describe("countdown", () => {
+ let di: DiContainer;
+ let render: DiRender;
+
+ beforeEach(() => {
+ useFakeTime("2015-10-21T07:28:00Z");
+
+ di = createContainer("irrelevant");
+
+ render = renderFor(di);
+
+ di.register(countdownStateInjectable);
+ });
+
+ describe("when rendering countdown", () => {
+ let rendered: RenderResult;
+ let onZeroMock: jest.Mock;
+
+ beforeEach(() => {
+ onZeroMock = jest.fn();
+
+ const secondsTill = di.inject(countdownStateInjectable, {
+ startFrom: 42,
+ onZero: onZeroMock,
+ });
+
+ rendered = render(
+ ,
+ );
+ });
+
+ it("renders with initial seconds", () => {
+ expect(rendered.container).toHaveTextContent("42");
+ });
+
+ describe("when time passes", () => {
+ beforeEach(() => {
+ advanceFakeTime(1000);
+ });
+
+ it("updates the seconds", () => {
+ expect(rendered.container).toHaveTextContent("41");
+ });
+
+ it("does not call callback yet", () => {
+ expect(onZeroMock).not.toHaveBeenCalled();
+ });
+ });
+
+ it("when just not enough time passes to fulfill the countdown, does not call the callback yet", () => {
+ advanceFakeTime(41 * 1000);
+
+ expect(onZeroMock).not.toHaveBeenCalled();
+ });
+
+ describe("when time passes enough to fulfill the countdown", () => {
+ beforeEach(() => {
+ advanceFakeTime(42 * 1000);
+ });
+
+ it("shows zero as seconds", () => {
+ expect(rendered.container).toHaveTextContent("0");
+ });
+
+ it("calls the callback", () => {
+ expect(onZeroMock).toHaveBeenCalled();
+ });
+
+ describe("when time passes even more", () => {
+ beforeEach(() => {
+ onZeroMock.mockClear();
+
+ advanceFakeTime(1000);
+ });
+
+ it("does not update the countdown anymore", () => {
+ expect(rendered.container).toHaveTextContent("0");
+ });
+
+ it("does not call the callback", () => {
+ expect(onZeroMock).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe("given observed", () => {
+ let onZeroMock: jest.Mock;
+ let unobserve: () => void;
+ let secondsTill: IComputedValue;
+
+ beforeEach(() => {
+ onZeroMock = jest.fn();
+
+ secondsTill = di.inject(countdownStateInjectable, {
+ startFrom: 1,
+ onZero: onZeroMock,
+ });
+
+ unobserve = observe(secondsTill, noop);
+ });
+
+ describe("given unobserved, when enough time passes so that it would fulfill the countdown", () => {
+ beforeEach(() => {
+ onZeroMock.mockClear();
+
+ unobserve();
+
+ advanceFakeTime(1000);
+ });
+
+ it("does not call callback yet", () => {
+ expect(onZeroMock).not.toHaveBeenCalled();
+ });
+
+ it("given observed again, when time passes to fulfill the countdown, calls the callback", () => {
+ observe(secondsTill, noop);
+
+ advanceFakeTime(1000);
+
+ expect(onZeroMock).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/src/renderer/components/countdown/countdown.tsx b/src/renderer/components/countdown/countdown.tsx
new file mode 100644
index 0000000000..82b0d60e92
--- /dev/null
+++ b/src/renderer/components/countdown/countdown.tsx
@@ -0,0 +1,16 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import type { IComputedValue } from "mobx";
+import { observer } from "mobx-react";
+import type { HTMLAttributes } from "react";
+import React from "react";
+
+interface CountdownProps extends HTMLAttributes {
+ secondsTill: IComputedValue;
+}
+
+export const Countdown = observer(({ secondsTill, ...props }: CountdownProps) => (
+ {secondsTill.get()}
+));