| name | progress-modal |
| description | Build a production-grade, accessible, reusable progress modal component for multi-step backend operations. Covers visual design, TypeScript interfaces, backend event contracts, cancellation architecture, accessibility (WCAG 2.1 AA), extensibility, and complete testing requirements. |
| user-invokable | true |
| args | [{"name":"target","description":"The project or component area to build the progress modal for (optional)","required":false}] |
First: Use the frontend-design skill for design principles and anti-patterns.
Component Type: Reusable, modular UI component
Scope: Frontend only (UI shell) + backend contract definition
Mandate: The component must be framework-agnostic in its contract, visually polished, fully accessible, and resilient to backend latency variance, cancellation, and partial failure.
0. Pre-Flight Checklist
Before writing any code the agent must:
- Locate and read the project's existing component library structure (atoms/molecules/organisms or equivalent).
- Identify the active state management solution (Zustand, Jotai, Redux, Context, etc.).
- Identify the active async transport layer (WebSocket, SSE, polling, etc.) used for long-running operations.
- Identify the design token file (colors, radii, spacing, motion) — the modal must consume tokens, never raw values.
- Confirm the cancellation mechanism available on the backend (abort signal, job ID endpoint, task revocation).
- State the implementation plan and wait for confirmation before creating files.
1. Component Purpose & Design Intent
This modal communicates the real-time progress of a multi-step, long-running backend operation to the end user. It must:
- Reduce perceived wait time by showing forward momentum and naming each step in plain language.
- Give the user agency: they can cancel at any time and the backend must honor that.
- Never leave the user guessing — every state transition is reflected within 100ms of the backend event.
- Be reusable across any multi-step operation — the component has zero knowledge of what the steps represent. All content is injected via props/config.
2. Visual Design Specification
2.1 Layout
┌─────────────────────────────────────────┐
│ [Title] │ ← H2, semibold, ~18px
│ │
│ ████████████████░░░░░░░░░░░░░░░░░░░░░ │ ← Progress bar, full width
│ XX% — N of M steps │ ← Caption beneath bar
│ │
│ ◉ Step label [detail] │ ← Completed step
│ ◉ Step label [detail] │ ← Completed step
│ ◌~ Step label [inline status text…] │ ← Active step (spinner)
│ ○ Step label │ ← Pending step
│ ○ Step label │ ← Pending step
│ │
│ [ Cancel ] │ ← Destructive button, full width
└─────────────────────────────────────────┘
2.2 Size & Positioning
- Width:
min(480px, calc(100vw - 32px)) — never wider than viewport minus gutters.
- Max height:
80vh with internal scroll on the step list if content overflows.
- Position: Centered horizontally and vertically in the viewport with a dimmed backdrop (
rgba(0,0,0,0.45)).
- Border radius: Consume
--radius-lg token (typically 12–16px).
- Padding:
24px interior on all sides.
- Box shadow: Layered, e.g.
0 4px 6px rgb(0 0 0/.07), 0 20px 40px rgb(0 0 0/.12).
2.3 Step Indicator States
Every step renders one of exactly four visual states. The indicator is a 24×24px circular element:
| State | Indicator | Color |
|---|
completed | Filled circle with centered checkmark icon | Brand success green (e.g. --color-success-500) |
active | Spinning arc / indeterminate ring | Brand primary (e.g. --color-primary-600) |
pending | Hollow circle outline, no fill | Neutral mid-tone (e.g. --color-neutral-300) |
failed | Filled circle with × icon | Danger red (e.g. --color-danger-500) |
The active step's label is rendered at full opacity and medium weight. Its optional inline status string renders in a muted color directly after the label on the same line, truncated with ellipsis at the container edge.
Completed step labels render at reduced opacity (0.6) to de-emphasize them and reinforce forward direction. Their summary result string renders right-aligned, muted.
Pending step labels render at full opacity but in the muted neutral color.
2.4 Progress Bar
- Full-width, height
8px, border-radius: 9999px.
- Track:
--color-neutral-200.
- Fill: Brand primary or success green — consider an animated shimmer on the fill while
active.
- Progress is calculated as
(completedStepCount / totalStepCount) × 100. Do not use elapsed time.
- Caption format:
{percent}% — {completedCount} of {totalCount} steps
2.5 Cancel Button
- Full width, height
44px (meets touch target minimum).
- Color: Destructive red (
--color-danger-500 background or outlined variant).
- Label: A short, imperative verb phrase — injected via prop, never hardcoded.
- On click: immediately disabled + shows a brief loading spinner to indicate the cancellation signal is being sent. Reverts to enabled if the cancellation is rejected by the backend.
- Do not show a confirmation dialog before cancelling — the user's intent is clear.
2.6 Motion
Modal enter: fadeIn + translateY(8px → 0), duration 220ms, ease-out
Modal exit: fadeOut + translateY(0 → 6px), duration 160ms, ease-in
Step complete: indicator crossfades from spinner to checkmark, 200ms
Active step row: subtle slide-in from 4px below, 180ms, ease-out
Progress bar fill: width transition 400ms ease-in-out
All motion must be suppressed when prefers-reduced-motion: reduce is active.
3. Component API (Props/Config)
3.1 TypeScript Interface
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed';
export interface ProgressStep {
id: string;
label: string;
status: StepStatus;
detail?: string;
}
export interface ProgressModalProps {
open: boolean;
title: string;
steps: ProgressStep[];
cancelLabel?: string;
onCancel: () => void | Promise<void>;
isCancelling?: boolean;
onComplete?: () => void;
}
3.2 Derived State (computed inside the component, not passed in)
const completedCount = steps.filter(s => s.status === 'completed').length;
const totalCount = steps.length;
const progressPct = Math.round((completedCount / totalCount) * 100);
const activeStep = steps.find(s => s.status === 'active') ?? null;
const hasFailed = steps.some(s => s.status === 'failed');
const isComplete = completedCount === totalCount && !hasFailed;
4. Backend Contract
The component is decoupled from the transport layer. Whatever drives steps state updates must conform to this event shape:
interface StepProgressEvent {
type: 'step_update';
stepId: string;
status: StepStatus;
detail?: string;
}
interface OperationCompleteEvent {
type: 'complete';
result?: unknown;
}
interface OperationErrorEvent {
type: 'error';
stepId: string;
message: string;
}
interface CancellationAckEvent {
type: 'cancelled';
}
The backend must:
- Emit
step_update with status: 'active' when it begins each step.
- Emit
step_update with status: 'completed' and optional detail when each step finishes.
- Emit
step_update with status: 'failed' if a step errors, with a detail string the user can understand.
- Honor a cancellation request within 2 seconds and emit
cancelled.
- Never assume step order is enforced on the frontend — steps advance only when the corresponding event arrives.
5. Cancellation Architecture
User clicks Cancel
│
▼
onCancel() callback fires
│
├─► isCancelling = true (disables button, shows spinner)
│
▼
Caller sends cancellation signal to backend
• REST: DELETE /api/v1/jobs/{jobId}
• SSE: EventSource.close() + POST /api/v1/jobs/{jobId}/cancel
• WS: ws.send({ type: 'cancel', jobId })
• Fetch: AbortController.abort()
│
▼
Backend emits CancellationAckEvent OR HTTP 200
│
├─► open = false → modal unmounts
└─► On timeout (5s): show error state, re-enable Cancel button
The component must never self-close. It always defers to the caller via onCancel.
6. Integration Pattern (Usage Example)
import { useState, useCallback, useRef } from 'react';
import type { ProgressStep, StepStatus } from '@/types/progressModal';
interface UseJobProgressOptions {
initialSteps: Array<{ id: string; label: string }>;
jobId: string | null;
}
export function useJobProgress({ initialSteps, jobId }: UseJobProgressOptions) {
const [steps, setSteps] = useState<ProgressStep[]>(
initialSteps.map(s => ({ ...s, status: 'pending' }))
);
const [isCancelling, setIsCancelling] = useState(false);
const updateStep = useCallback((id: string, status: StepStatus, detail?: string) => {
setSteps(prev => prev.map(s => s.id === id ? { ...s, status, detail } : s));
}, []);
const handleCancel = useCallback(async () => {
if (!jobId) return;
setIsCancelling(true);
try {
await fetch(`/api/v1/jobs/${jobId}/cancel`, { method: 'POST' });
} catch {
setIsCancelling(false);
}
}, [jobId]);
return { steps, updateStep, isCancelling, handleCancel };
}
7. Accessibility Requirements
The modal must meet WCAG 2.1 AA:
role="dialog", aria-modal="true", aria-labelledby pointing to the title element.
- Focus is trapped inside the modal while open.
- On open, focus moves to the modal container or the title (not the cancel button).
- On close, focus returns to the element that triggered the modal.
- The progress bar uses
role="progressbar", aria-valuenow={progressPct}, aria-valuemin={0}, aria-valuemax={100}, aria-label="Overall progress".
- A visually hidden
aria-live="polite" region announces step transitions.
- The active step's spinner has
role="status" and aria-label="In progress".
- Backdrop click does not cancel.
- Escape key does not cancel.
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{activeStep
? `Step ${completedCount + 1} of ${totalCount}: ${activeStep.label}`
: isComplete
? 'Process complete.'
: ''}
</div>
8. Extensibility Requirements
8.1 Step Icon Customization
Optional icon field on ProgressStep — replaces the default circle indicator.
8.2 Step Groups / Sections
Support an optional ProgressStepGroup[] variant for 8+ steps with labeled section headers.
8.3 Estimated Time Remaining
Optional estimatedSecondsRemaining prop — shows muted caption below the progress bar.
8.4 Non-Blocking / Minimizable Variant
Optional minimizable boolean prop — collapses to a persistent status bar at the bottom of the viewport.
8.5 Multiple Concurrent Operations
useJobProgress hook supports independent instantiation for multiple simultaneous operations.
9. File Structure
components/
└── organisms/
└── ProgressModal/
├── index.ts
├── ProgressModal.tsx
├── ProgressModal.test.tsx
├── StepList.tsx
├── StepRow.tsx
├── StepIndicator.tsx
├── ProgressBar.tsx
└── CancelButton.tsx
hooks/
└── useJobProgress.ts
types/
└── progressModal.ts
10. Testing Requirements
Coverage thresholds: 85% lines / 85% functions / 75% branches.
10.1 Unit Tests
describe('ProgressModal', () => {
it('renders the title passed via prop');
it('does not render when open=false');
it('traps focus when opened');
it('does NOT close when backdrop is clicked');
it('does NOT close when Escape is pressed');
it('calls onCancel when Cancel button is clicked');
it('disables Cancel button when isCancelling=true');
it('calls onComplete when all steps reach completed status');
});
describe('ProgressBar', () => {
it('calculates percentage from completed/total step count');
it('sets aria-valuenow to the correct percentage');
it('shows 0% when no steps are complete');
it('shows 100% when all steps are complete');
});
describe('StepRow', () => {
it('renders checkmark indicator for completed step');
it('renders spinner indicator for active step');
it('renders hollow circle for pending step');
it('renders error indicator for failed step');
it('shows detail string when provided');
it('truncates long detail strings at container edge');
});
describe('CancelButton', () => {
it('renders with the cancelLabel prop text');
it('shows spinner and is disabled when isCancelling=true');
it('re-enables when isCancelling returns to false');
it('meets 44px minimum touch target height');
});
10.2 Accessibility Tests
test('ProgressModal has no axe violations when open', async ({ page }) => {
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toHaveLength(0);
});
10.3 Integration Test: Cancellation Flow
it('sends cancellation and disables button while in-flight', async () => {
});
11. Agent Behavior Rules
- No hardcoded strings. Every user-visible string comes from props.
- No hardcoded colors or spacing. Every visual value references a design token.
- The spinner is a real CSS animation, not a GIF or emoji.
- Steps only advance forward. Log a warning and ignore if a step regresses.
- The cancel button is never hidden, only disabled while
isCancelling=true.
- No
setTimeout to simulate progress. All state changes driven by real backend events.
- Export the TypeScript interfaces from
types/progressModal.ts.
- Storybook stories required: one per step-status combination, one full flow, one with
isCancelling=true.