diff --git a/docs/react-hooks.md b/docs/react-hooks.md
index 2d67266..b83adf5 100644
--- a/docs/react-hooks.md
+++ b/docs/react-hooks.md
@@ -267,6 +267,63 @@ also exposes `refetch()` and `reset()`.
---
+## useStreamCountdown
+
+Live countdown to a cliff, cancellation, or completion deadline. The hook
+ticks once per second, so a vesting or payroll card can render the remaining
+time without wiring up its own timer.
+
+`targetTimestamp` and the optional `startTimestamp` are Unix timestamps in
+**seconds** (the same unit as `StreamInfo.startTime` / `StreamInfo.endTime`),
+not milliseconds.
+
+```tsx
+import { useStreamCountdown } from '@streamfi/react';
+
+function CliffCountdown({ stream }) {
+ const { days, hours, minutes, seconds, isPast, progressFraction } =
+ useStreamCountdown(stream.endTime, stream.startTime);
+
+ if (isPast) return
Stream complete — everything has unlocked.
;
+
+ return (
+
+
+ {days}d {hours}h {minutes}m {seconds}s remaining
+
+ {progressFraction !== null && (
+
+ )}
+
+ );
+}
+```
+
+### Return Type
+
+```ts
+interface UseStreamCountdownResult {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+ isPast: boolean;
+ progressFraction: number | null;
+}
+```
+
+### Parameters
+
+- `targetTimestamp`: The deadline to count down to, in Unix seconds. Pass
+ `null` or `undefined` to render a zeroed, not-yet-started state (handy
+ while a stream is still loading).
+- `startTimestamp` (optional): The start of the window, in Unix seconds.
+ Supplying it adds `progressFraction` — the elapsed share of the window,
+ clamped to `0`-`1`. Without it `progressFraction` is `null`, since a
+ deadline alone cannot define a range.
+
+---
+
## Error Handling
Each hook exposes an `error` field that captures:
diff --git a/packages/react/src/__tests__/useStreamCountdown.test.tsx b/packages/react/src/__tests__/useStreamCountdown.test.tsx
new file mode 100644
index 0000000..3e45f15
--- /dev/null
+++ b/packages/react/src/__tests__/useStreamCountdown.test.tsx
@@ -0,0 +1,177 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useStreamCountdown } from '../hooks/useStreamCountdown.js';
+
+const BASE_MS = Date.UTC(2026, 0, 1, 0, 0, 0); // 2026-01-01T00:00:00Z
+const BASE_SEC = Math.floor(BASE_MS / 1000);
+
+describe('useStreamCountdown', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(BASE_MS));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('splits the remaining time into days, hours, minutes and seconds', () => {
+ const target = BASE_SEC + 86_400 + 2 * 3_600 + 3 * 60 + 4;
+
+ const { result } = renderHook(() => useStreamCountdown(target));
+
+ expect(result.current).toEqual({
+ days: 1,
+ hours: 2,
+ minutes: 3,
+ seconds: 4,
+ isPast: false,
+ progressFraction: null,
+ });
+ });
+
+ it('auto-ticks once per second', () => {
+ const target = BASE_SEC + 5;
+ const { result } = renderHook(() => useStreamCountdown(target));
+
+ expect(result.current.seconds).toBe(5);
+
+ act(() => {
+ vi.advanceTimersByTime(1_000);
+ });
+ expect(result.current.seconds).toBe(4);
+
+ act(() => {
+ vi.advanceTimersByTime(2_000);
+ });
+ expect(result.current.seconds).toBe(2);
+ });
+
+ it('reports isPast once the target is reached and clamps at zero', () => {
+ const target = BASE_SEC + 1;
+ const { result } = renderHook(() => useStreamCountdown(target));
+
+ expect(result.current.isPast).toBe(false);
+
+ act(() => {
+ vi.advanceTimersByTime(5_000);
+ });
+
+ expect(result.current.isPast).toBe(true);
+ expect(result.current).toMatchObject({ days: 0, hours: 0, minutes: 0, seconds: 0 });
+ });
+
+ it('derives progressFraction from the start/end window', () => {
+ const start = BASE_SEC;
+ const target = BASE_SEC + 100;
+ const { result } = renderHook(() => useStreamCountdown(target, start));
+
+ expect(result.current.progressFraction).toBe(0);
+
+ act(() => {
+ vi.advanceTimersByTime(50_000);
+ });
+ expect(result.current.progressFraction).toBe(0.5);
+
+ act(() => {
+ vi.advanceTimersByTime(50_000);
+ });
+ expect(result.current.progressFraction).toBe(1);
+ });
+
+ it('clamps progressFraction to 0 before the window starts', () => {
+ const { result } = renderHook(() =>
+ useStreamCountdown(BASE_SEC + 150, BASE_SEC + 50),
+ );
+
+ expect(result.current.progressFraction).toBe(0);
+ expect(result.current.isPast).toBe(false);
+ });
+
+ it('treats a zero-length window as complete only once past', () => {
+ const target = BASE_SEC + 10;
+ const { result } = renderHook(() => useStreamCountdown(target, target));
+
+ expect(result.current.progressFraction).toBe(0);
+ expect(result.current.isPast).toBe(false);
+
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+
+ expect(result.current.progressFraction).toBe(1);
+ expect(result.current.isPast).toBe(true);
+ });
+
+ it('returns a zeroed, not-started result when the target is missing', () => {
+ const { result } = renderHook(() => useStreamCountdown(null, BASE_SEC));
+
+ expect(result.current).toEqual({
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ isPast: false,
+ progressFraction: null,
+ });
+ });
+
+ it('treats non-finite targets as missing', () => {
+ const { result } = renderHook(() => useStreamCountdown(Number.NaN, BASE_SEC));
+
+ expect(result.current.isPast).toBe(false);
+ expect(result.current.progressFraction).toBeNull();
+ });
+
+ it('ignores a non-finite start timestamp for progressFraction', () => {
+ const { result } = renderHook(() =>
+ useStreamCountdown(BASE_SEC + 60, Number.POSITIVE_INFINITY),
+ );
+
+ expect(result.current.progressFraction).toBeNull();
+ });
+
+ it('re-syncs when the target changes', () => {
+ const { result, rerender } = renderHook(
+ ({ target }: { target: number }) => useStreamCountdown(target),
+ { initialProps: { target: BASE_SEC + 10 } },
+ );
+
+ expect(result.current.seconds).toBe(10);
+
+ rerender({ target: BASE_SEC + 3 });
+ expect(result.current.seconds).toBe(3);
+ });
+
+ it('clears its interval on unmount', () => {
+ const { unmount } = renderHook(() => useStreamCountdown(BASE_SEC + 60));
+ expect(vi.getTimerCount()).toBeGreaterThan(0);
+
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('decomposes long durations into whole units', () => {
+ const target = BASE_SEC + 2 * 86_400 + 5 * 3_600 + 59 * 60 + 59;
+ const { result } = renderHook(() => useStreamCountdown(target));
+
+ expect(result.current).toMatchObject({
+ days: 2,
+ hours: 5,
+ minutes: 59,
+ seconds: 59,
+ });
+ });
+
+ it('flags isPast exactly at the target', () => {
+ const target = BASE_SEC + 1;
+ const { result } = renderHook(() => useStreamCountdown(target));
+
+ act(() => {
+ vi.advanceTimersByTime(1_000);
+ });
+
+ expect(result.current.isPast).toBe(true);
+ expect(result.current.progressFraction).toBeNull();
+ });
+});
diff --git a/packages/react/src/hooks/useCircuitState.ts b/packages/react/src/hooks/useCircuitState.ts
index c98db3a..80fc24c 100644
--- a/packages/react/src/hooks/useCircuitState.ts
+++ b/packages/react/src/hooks/useCircuitState.ts
@@ -4,7 +4,7 @@ import {
onCircuitChange,
resetCircuit,
type CircuitStatus,
-} from '../../src/rpc-circuit-state.js';
+} from '../../../../src/rpc-circuit-state.js';
export interface UseCircuitStateResult {
/** Current circuit status for the given scope. */
diff --git a/packages/react/src/hooks/useStreamCountdown.ts b/packages/react/src/hooks/useStreamCountdown.ts
new file mode 100644
index 0000000..d474640
--- /dev/null
+++ b/packages/react/src/hooks/useStreamCountdown.ts
@@ -0,0 +1,111 @@
+import { useEffect, useMemo, useState } from 'react';
+
+const SECONDS_PER_MINUTE = 60;
+const SECONDS_PER_HOUR = 3_600;
+const SECONDS_PER_DAY = 86_400;
+
+export interface UseStreamCountdownResult {
+ /** Whole days remaining (0 once the target has passed). */
+ days: number;
+ /** Whole hours remaining within the day, 0-23. */
+ hours: number;
+ /** Whole minutes remaining within the hour, 0-59. */
+ minutes: number;
+ /** Whole seconds remaining within the minute, 0-59. */
+ seconds: number;
+ /** True once the current time is at or past `targetTimestamp`. */
+ isPast: boolean;
+ /**
+ * Fraction (0-1) of the window between `startTimestamp` and
+ * `targetTimestamp` that has elapsed, clamped to [0, 1].
+ *
+ * `null` when no `startTimestamp` is supplied (or when either timestamp is
+ * not a finite number), since a fraction needs both ends of the window.
+ */
+ progressFraction: number | null;
+}
+
+function currentUnixSeconds(): number {
+ return Math.floor(Date.now() / 1000);
+}
+
+function isFiniteTimestamp(value: number | null | undefined): value is number {
+ return value != null && Number.isFinite(value);
+}
+
+/**
+ * Live countdown to a cliff or cancellation/completion deadline.
+ *
+ * `targetTimestamp` and the optional `startTimestamp` are Unix timestamps in
+ * **seconds** (the same unit as `StreamInfo.startTime`/`endTime`), not
+ * milliseconds. The hook re-renders every second so a vesting or payroll card
+ * can render `{days}d {hours}h {minutes}m {seconds}s` without wiring up its
+ * own timer.
+ *
+ * Pass `startTimestamp` as well to also get `progressFraction`, the share of
+ * the window that has already elapsed. Without it `progressFraction` is
+ * `null`; a countdown does not have enough information to derive a fraction
+ * from the deadline alone.
+ *
+ * @example
+ * ```tsx
+ * const { days, hours, minutes, seconds, isPast, progressFraction } =
+ * useStreamCountdown(stream.endTime, stream.startTime);
+ * ```
+ */
+export function useStreamCountdown(
+ targetTimestamp: number | null | undefined,
+ startTimestamp?: number | null,
+): UseStreamCountdownResult {
+ const [now, setNow] = useState(currentUnixSeconds);
+
+ const hasTarget = isFiniteTimestamp(targetTimestamp);
+
+ // Re-sync on mount and whenever the target changes, then tick once per
+ // second. `targetTimestamp` is intentionally a dependency: a changed
+ // deadline must not keep counting down against the old value.
+ useEffect(() => {
+ if (!hasTarget) return;
+
+ setNow(currentUnixSeconds());
+ const interval = setInterval(() => {
+ setNow(currentUnixSeconds());
+ }, 1_000);
+
+ return () => clearInterval(interval);
+ }, [hasTarget, targetTimestamp]);
+
+ return useMemo(() => {
+ if (!hasTarget) {
+ return {
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ isPast: false,
+ progressFraction: null,
+ };
+ }
+
+ const remaining = Math.max(0, targetTimestamp - now);
+ const isPast = now >= targetTimestamp;
+
+ const days = Math.floor(remaining / SECONDS_PER_DAY);
+ const hours = Math.floor((remaining % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
+ const minutes = Math.floor((remaining % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
+ const seconds = remaining % SECONDS_PER_MINUTE;
+
+ let progressFraction: number | null = null;
+ if (isFiniteTimestamp(startTimestamp)) {
+ const span = targetTimestamp - startTimestamp;
+ progressFraction =
+ span <= 0
+ ? isPast
+ ? 1
+ : 0
+ : Math.min(1, Math.max(0, (now - startTimestamp) / span));
+ }
+
+ return { days, hours, minutes, seconds, isPast, progressFraction };
+ }, [hasTarget, targetTimestamp, startTimestamp, now]);
+}
diff --git a/packages/react/src/hooks/useTransactionHistory.ts b/packages/react/src/hooks/useTransactionHistory.ts
index 90f5e9d..8a6a345 100644
--- a/packages/react/src/hooks/useTransactionHistory.ts
+++ b/packages/react/src/hooks/useTransactionHistory.ts
@@ -13,7 +13,7 @@ import {
selectVisibleTransactions,
selectTotalPages,
selectViewStatus,
-} from '../../src/dashboard/transaction-history.js';
+} from '../../../../src/dashboard/transaction-history.js';
export interface UseTransactionHistoryOptions {
/** Wallet address used to derive transaction direction. */
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index c088551..09a76ca 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -66,6 +66,8 @@ export { useTransactionHistory } from './hooks/useTransactionHistory.js';
export type { UseTransactionHistoryResult, UseTransactionHistoryOptions } from './hooks/useTransactionHistory.js';
export { useCircuitState } from './hooks/useCircuitState.js';
export type { UseCircuitStateResult } from './hooks/useCircuitState.js';
+export { useStreamCountdown } from './hooks/useStreamCountdown.js';
+export type { UseStreamCountdownResult } from './hooks/useStreamCountdown.js';
export { useTokenAllowance } from './hooks/useTokenAllowance.js';
export type {
ApproveTokenAllowanceFn,