diff --git a/frontend/cntr/CountdownTimer/CountdownTimer.test.tsx b/frontend/cntr/CountdownTimer/CountdownTimer.test.tsx
new file mode 100644
index 0000000..dbc06b6
--- /dev/null
+++ b/frontend/cntr/CountdownTimer/CountdownTimer.test.tsx
@@ -0,0 +1,165 @@
+import React from "react";
+
+import {
+ render,
+ screen,
+ act,
+} from "@testing-library/react";
+
+import {
+ CountdownTimer,
+} from "./CountdownTimer";
+
+describe(
+ "CountdownTimer",
+ () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+
+ jest.setSystemTime(
+ new Date(
+ "2026-01-01T12:00:00Z",
+ ),
+ );
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it(
+ "renders HH:MM:SS format",
+ () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText(
+ "01:01:05",
+ ),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it(
+ "turns red when 5 minutes remain",
+ () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId(
+ "countdown-timer",
+ ),
+ ).toHaveClass(
+ "text-red-600",
+ );
+ },
+ );
+
+ it(
+ "calls onExpire exactly once",
+ () => {
+ const onExpire =
+ jest.fn();
+
+ render(
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(
+ 5000,
+ );
+ });
+
+ expect(
+ onExpire,
+ ).toHaveBeenCalledTimes(
+ 1,
+ );
+ },
+ );
+
+ it(
+ "updates every second",
+ () => {
+ render(
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(
+ 1000,
+ );
+ });
+
+ expect(
+ screen.getByText(
+ "00:00:09",
+ ),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it(
+ "clears interval on unmount",
+ () => {
+ const clearSpy =
+ jest.spyOn(
+ window,
+ "clearInterval",
+ );
+
+ const {
+ unmount,
+ } = render(
+ ,
+ );
+
+ unmount();
+
+ expect(
+ clearSpy,
+ ).toHaveBeenCalled();
+
+ clearSpy.mockRestore();
+ },
+ );
+ },
+);
\ No newline at end of file
diff --git a/frontend/cntr/CountdownTimer/CountdownTimer.tsx b/frontend/cntr/CountdownTimer/CountdownTimer.tsx
new file mode 100644
index 0000000..3dc4e09
--- /dev/null
+++ b/frontend/cntr/CountdownTimer/CountdownTimer.tsx
@@ -0,0 +1,151 @@
+import React, {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+export interface CountdownTimerProps {
+ endsAt: Date | string;
+ onExpire?: () => void;
+}
+
+const WARNING_THRESHOLD_SECONDS = 300;
+
+function getRemainingSeconds(
+ endsAt: Date | string,
+): number {
+ const endTime =
+ new Date(endsAt).getTime();
+
+ const now = Date.now();
+
+ return Math.max(
+ 0,
+ Math.floor(
+ (endTime - now) / 1000,
+ ),
+ );
+}
+
+function formatTime(
+ totalSeconds: number,
+): string {
+ const hours = Math.floor(
+ totalSeconds / 3600,
+ );
+
+ const minutes = Math.floor(
+ (totalSeconds % 3600) / 60,
+ );
+
+ const seconds =
+ totalSeconds % 60;
+
+ return [
+ hours,
+ minutes,
+ seconds,
+ ]
+ .map((value) =>
+ String(value).padStart(
+ 2,
+ "0",
+ ),
+ )
+ .join(":");
+}
+
+export const CountdownTimer =
+ ({
+ endsAt,
+ onExpire,
+ }: CountdownTimerProps) => {
+ const [
+ remainingSeconds,
+ setRemainingSeconds,
+ ] = useState(() =>
+ getRemainingSeconds(
+ endsAt,
+ ),
+ );
+
+ const hasExpiredRef =
+ useRef(false);
+
+ useEffect(() => {
+ setRemainingSeconds(
+ getRemainingSeconds(
+ endsAt,
+ ),
+ );
+
+ hasExpiredRef.current =
+ false;
+ }, [endsAt]);
+
+ useEffect(() => {
+ const tick = () => {
+ const remaining =
+ getRemainingSeconds(
+ endsAt,
+ );
+
+ setRemainingSeconds(
+ remaining,
+ );
+
+ if (
+ remaining === 0 &&
+ !hasExpiredRef.current
+ ) {
+ hasExpiredRef.current =
+ true;
+
+ onExpire?.();
+ }
+ };
+
+ tick();
+
+ const intervalId =
+ window.setInterval(
+ tick,
+ 1000,
+ );
+
+ return () => {
+ window.clearInterval(
+ intervalId,
+ );
+ };
+ }, [endsAt, onExpire]);
+
+ const formattedTime =
+ useMemo(
+ () =>
+ formatTime(
+ remainingSeconds,
+ ),
+ [remainingSeconds],
+ );
+
+ const isWarning =
+ remainingSeconds <=
+ WARNING_THRESHOLD_SECONDS;
+
+ return (
+
+ {formattedTime}
+
+ );
+ };
+
+export default CountdownTimer;
\ No newline at end of file
diff --git a/frontend/cntr/CountdownTimer/index.ts b/frontend/cntr/CountdownTimer/index.ts
new file mode 100644
index 0000000..b0c8bc5
--- /dev/null
+++ b/frontend/cntr/CountdownTimer/index.ts
@@ -0,0 +1,4 @@
+export * from "./CountdownTimer";
+export {
+ default,
+} from "./CountdownTimer";
\ No newline at end of file
diff --git a/frontend/cntr/StepProgress/StepProgress.test.tsx b/frontend/cntr/StepProgress/StepProgress.test.tsx
new file mode 100644
index 0000000..526c78a
--- /dev/null
+++ b/frontend/cntr/StepProgress/StepProgress.test.tsx
@@ -0,0 +1,196 @@
+import React from "react";
+
+import {
+ render,
+ screen,
+} from "@testing-library/react";
+
+import {
+ StepProgress,
+} from "./StepProgress";
+
+describe(
+ "StepProgress",
+ () => {
+ const steps = [
+ {
+ label:
+ "Details",
+ },
+ {
+ label:
+ "Payment",
+ },
+ {
+ label:
+ "Review",
+ },
+ {
+ label:
+ "Complete",
+ },
+ ];
+
+ it(
+ "renders all step labels",
+ () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText(
+ "Details",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByText(
+ "Payment",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByText(
+ "Review",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByText(
+ "Complete",
+ ),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it(
+ "renders completed state",
+ () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByLabelText(
+ "Details - Completed",
+ ),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it(
+ "renders current state",
+ () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByLabelText(
+ "Payment - Current",
+ ),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it(
+ "renders upcoming state",
+ () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByLabelText(
+ "Review - Upcoming",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByLabelText(
+ "Complete - Upcoming",
+ ),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it(
+ "renders connectors",
+ () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByTestId(
+ "connector-0",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByTestId(
+ "connector-1",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByTestId(
+ "connector-2",
+ ),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it(
+ "renders proper aria labels",
+ () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByLabelText(
+ "Details - Completed",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByLabelText(
+ "Payment - Completed",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByLabelText(
+ "Review - Current",
+ ),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByLabelText(
+ "Complete - Upcoming",
+ ),
+ ).toBeInTheDocument();
+ },
+ );
+ },
+);
\ No newline at end of file
diff --git a/frontend/cntr/StepProgress/StepProgress.tsx b/frontend/cntr/StepProgress/StepProgress.tsx
new file mode 100644
index 0000000..fac2c89
--- /dev/null
+++ b/frontend/cntr/StepProgress/StepProgress.tsx
@@ -0,0 +1,148 @@
+import React from "react";
+
+export interface Step {
+ label: string;
+}
+
+export interface StepProgressProps {
+ steps: Step[];
+ currentStep: number;
+}
+
+type StepState =
+ | "completed"
+ | "current"
+ | "upcoming";
+
+export const StepProgress = ({
+ steps,
+ currentStep,
+}: StepProgressProps) => {
+ const getState = (
+ index: number,
+ ): StepState => {
+ if (index < currentStep) {
+ return "completed";
+ }
+
+ if (index === currentStep) {
+ return "current";
+ }
+
+ return "upcoming";
+ };
+
+ return (
+
+
+ {steps.map(
+ (step, index) => {
+ const state =
+ getState(index);
+
+ const isCompleted =
+ state ===
+ "completed";
+
+ const isCurrent =
+ state ===
+ "current";
+
+ const isUpcoming =
+ state ===
+ "upcoming";
+
+ return (
+
+
+
+ {isCompleted
+ ? "✓"
+ : index + 1}
+
+
+
+ {step.label}
+
+
+
+ {index <
+ steps.length -
+ 1 && (
+
+ )}
+
+ );
+ },
+ )}
+
+
+ );
+};
+
+export default StepProgress;
\ No newline at end of file
diff --git a/frontend/cntr/StepProgress/index.ts b/frontend/cntr/StepProgress/index.ts
new file mode 100644
index 0000000..c0563e4
--- /dev/null
+++ b/frontend/cntr/StepProgress/index.ts
@@ -0,0 +1,5 @@
+export * from "./StepProgress";
+
+export {
+ default,
+} from "./StepProgress";
\ No newline at end of file