diff --git a/docs/TRANSACTION_TIMELINE.md b/docs/TRANSACTION_TIMELINE.md
new file mode 100644
index 00000000..1a4371df
--- /dev/null
+++ b/docs/TRANSACTION_TIMELINE.md
@@ -0,0 +1,21 @@
+# Transaction timeline
+
+The transaction progress modal now renders a four-step timeline that mirrors the transaction lifecycle:
+
+1. Build — prepare the transaction payload.
+2. Sign — wait for wallet confirmation.
+3. Submit — broadcast the transaction to the network.
+4. Confirm — wait for ledger confirmation.
+
+## Behavior
+
+- The active step is highlighted while the transaction is in progress.
+- Completed steps are marked done.
+- Failed steps remain visible so users can understand where the flow stopped.
+- The active step shows an elapsed-time indicator while the modal is in progress.
+- A live region announces the current phase for assistive technology.
+- The timeline respects `prefers-reduced-motion` by avoiding the elapsed timer animation.
+
+## Error state affordances
+
+When a transaction fails, the timeline remains visible and exposes the transaction hash with a copy action so the user can preserve the reference while retrying.
diff --git a/src/app/TransactionProgressModal.tsx b/src/app/TransactionProgressModal.tsx
index 2d471096..e4f559af 100644
--- a/src/app/TransactionProgressModal.tsx
+++ b/src/app/TransactionProgressModal.tsx
@@ -1,6 +1,7 @@
'use client';
import React from 'react';
+import TransactionStepTimeline, { type TransactionTimelinePhase } from '@/components/transaction/TransactionStepTimeline';
import { buildExplorerUrl, openExplorerUrl } from '@/utils/explorerLinks';
export type TransactionState =
@@ -86,10 +87,50 @@ export default function TransactionProgressModal({
onRetry,
onSuccessAction,
}: TransactionProgressModalProps) {
+ const [timelinePhase, setTimelinePhase] = React.useState('build');
+
+ React.useEffect(() => {
+ if (!isOpen || state === 'IDLE') {
+ setTimelinePhase('build');
+ return;
+ }
+
+ if (state === 'AWAITING_SIGNATURE') {
+ setTimelinePhase('sign');
+ return;
+ }
+
+ if (state === 'SUBMITTING') {
+ setTimelinePhase('submit');
+ return;
+ }
+
+ if (state === 'PROCESSING' || state === 'SUCCESS') {
+ setTimelinePhase('confirm');
+ return;
+ }
+
+ if (state === 'ERROR') {
+ setTimelinePhase((current) => (current === 'build' ? 'sign' : current));
+ }
+ }, [isOpen, state]);
+
if (!isOpen || state === 'IDLE') return null;
const txExplorerUrl = buildExplorerUrl('tx', txHash);
+ const handleCopyHash = async (hash: string) => {
+ if (!hash) return;
+
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(hash);
+ } catch {
+ // Ignore clipboard failures and keep the affordance available.
+ }
+ }
+ };
+
// -- State Configuration Helpers --
const getHeader = () => {
switch (state) {
@@ -294,6 +335,13 @@ export default function TransactionProgressModal({
{getHelperText()}
+
diff --git a/src/components/transaction/TransactionStepTimeline.test.tsx b/src/components/transaction/TransactionStepTimeline.test.tsx
new file mode 100644
index 00000000..0779bce4
--- /dev/null
+++ b/src/components/transaction/TransactionStepTimeline.test.tsx
@@ -0,0 +1,60 @@
+// @vitest-environment happy-dom
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import TransactionStepTimeline from './TransactionStepTimeline';
+
+describe('TransactionStepTimeline', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ });
+
+ it('renders the stepped lifecycle and marks the active step', () => {
+ render(
);
+
+ const list = screen.getByRole('list', { name: /transaction progress/i });
+ expect(list).toBeInTheDocument();
+
+ const signItem = screen.getByText('Sign').closest('li');
+ expect(signItem).toHaveAttribute('aria-current', 'step');
+ expect(signItem).toHaveTextContent(/Active/i);
+ });
+
+ it('updates elapsed time while the active step is running', () => {
+ render(
);
+
+ expect(screen.getByText(/elapsed 0s/i)).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ expect(screen.getByText(/elapsed 3s/i)).toBeInTheDocument();
+ });
+
+ it('renders failed state and copy action when a step fails', () => {
+ const onCopyHash = vi.fn();
+
+ render(
+
,
+ );
+
+ const submitItem = screen.getByText('Submit').closest('li');
+ expect(submitItem).toHaveTextContent(/Failed/i);
+
+ fireEvent.click(screen.getByRole('button', { name: /copy transaction hash/i }));
+
+ expect(onCopyHash).toHaveBeenCalledWith('abc123');
+ });
+});
diff --git a/src/components/transaction/TransactionStepTimeline.tsx b/src/components/transaction/TransactionStepTimeline.tsx
new file mode 100644
index 00000000..43bd4320
--- /dev/null
+++ b/src/components/transaction/TransactionStepTimeline.tsx
@@ -0,0 +1,170 @@
+import React, { useEffect, useMemo, useState } from 'react';
+
+export type TransactionTimelinePhase = 'build' | 'sign' | 'submit' | 'confirm';
+export type TransactionTimelineStatus = 'pending' | 'active' | 'done' | 'failed';
+
+export interface TransactionStepTimelineProps {
+ currentPhase: TransactionTimelinePhase;
+ state: 'in_progress' | 'success' | 'error';
+ txHash?: string;
+ onCopyHash?: (hash: string) => void;
+}
+
+interface TimelineStep {
+ key: TransactionTimelinePhase;
+ label: string;
+ description: string;
+}
+
+const STEPS: TimelineStep[] = [
+ { key: 'build', label: 'Build', description: 'Preparing the transaction payload' },
+ { key: 'sign', label: 'Sign', description: 'Waiting for wallet approval' },
+ { key: 'submit', label: 'Submit', description: 'Sending to the network' },
+ { key: 'confirm', label: 'Confirm', description: 'Waiting for ledger confirmation' },
+];
+
+function getStatus(step: TimelineStep, currentPhase: TransactionTimelinePhase, state: TransactionTimelineStatus): TransactionTimelineStatus {
+ if (state === 'error') {
+ return step.key === currentPhase ? 'failed' : step.key < currentPhase ? 'done' : 'pending';
+ }
+
+ if (step.key === currentPhase) {
+ return state === 'success' ? 'done' : 'active';
+ }
+
+ return step.key < currentPhase ? 'done' : 'pending';
+}
+
+function formatElapsed(seconds: number) {
+ return `Elapsed ${seconds}s`;
+}
+
+export default function TransactionStepTimeline({
+ currentPhase,
+ state,
+ txHash,
+ onCopyHash,
+}: TransactionStepTimelineProps) {
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
+ const [reducedMotion, setReducedMotion] = useState(false);
+
+ const phaseOrder = useMemo(() => ({ build: 0, sign: 1, submit: 2, confirm: 3 }), []);
+
+ const activeStep = STEPS.find((step) => step.key === currentPhase) ?? STEPS[0];
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const updatePreference = () => setReducedMotion(mediaQuery.matches);
+ updatePreference();
+ mediaQuery.addEventListener('change', updatePreference);
+
+ return () => mediaQuery.removeEventListener('change', updatePreference);
+ }, []);
+
+ useEffect(() => {
+ if (state !== 'in_progress' || reducedMotion) {
+ setElapsedSeconds(0);
+ return;
+ }
+
+ const interval = window.setInterval(() => {
+ setElapsedSeconds((value) => value + 1);
+ }, 1000);
+
+ return () => window.clearInterval(interval);
+ }, [currentPhase, state, reducedMotion]);
+
+ const statusForStep = (step: TimelineStep) => {
+ const currentIndex = phaseOrder[currentPhase];
+ const stepIndex = phaseOrder[step.key];
+
+ if (state === 'error') {
+ return step.key === currentPhase ? 'failed' : step.key < currentPhase ? 'done' : 'pending';
+ }
+
+ if (state === 'success') {
+ return stepIndex <= currentIndex ? 'done' : 'pending';
+ }
+
+ return stepIndex < currentIndex ? 'done' : stepIndex === currentIndex ? 'active' : 'pending';
+ };
+
+ const visibleSteps = STEPS.map((step) => ({ ...step, status: statusForStep(step) }));
+
+ const activeLabel = state === 'error' ? 'Failed' : state === 'success' ? 'Completed' : 'Active';
+
+ return (
+
+
+
+
+
Transaction timeline
+
{activeStep.label} • {activeLabel}
+
+ {state === 'in_progress' && !reducedMotion && (
+
{formatElapsed(elapsedSeconds)}
+ )}
+
+
+
+ {visibleSteps.map((step) => {
+ const isActive = step.status === 'active';
+ const isFailed = step.status === 'failed';
+ const isDone = step.status === 'done';
+ const baseClass = 'flex items-start gap-3 rounded-lg border px-3 py-3';
+ const statusClass = isFailed
+ ? 'border-[#FF4757]/20 bg-[#FF4757]/10'
+ : isActive
+ ? 'border-[#00C950]/25 bg-[#00C950]/10'
+ : isDone
+ ? 'border-white/10 bg-white/[0.04]'
+ : 'border-white/10 bg-transparent';
+
+ return (
+ -
+
+ {isFailed ? '!' : isDone ? '✓' : isActive ? '•' : '•'}
+
+
+
+
{step.label}
+
+ {isFailed ? 'Failed' : isDone ? 'Done' : isActive ? 'Active' : 'Pending'}
+
+
+
{step.description}
+
+
+ );
+ })}
+
+
+ {state === 'error' && txHash && (
+
+ {txHash}
+
+
+ )}
+
+
+ {state === 'error'
+ ? `${activeStep.label} failed.`
+ : state === 'success'
+ ? `${activeStep.label} completed.`
+ : `${activeStep.label} is ${activeLabel.toLowerCase()}.`}
+
+
+
+ );
+}