## Task Steps — Async
The system narrates its work.
Docs: https://www.interior.dev/docs/task-steps
Reference: https://www.interior.dev/reference/task-steps
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/task-steps.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
"use client";
import { useState } from "react";
import { TaskSteps } from "@/components/interior/task-steps";
const STEPS = [
{ id: "queue", label: "Queued" },
{ id: "build", label: "Building" },
{ id: "test", label: "Running checks" },
{ id: "deploy", label: "Deploying" },
];
export function DeployCard({ runId }: { runId: string }) {
const [current, setCurrent] = useState(0);
const [failed, setFailed] = useState(false);
useDeployEvents(runId, {
onStage: (index) => setCurrent(index),
onError: () => setFailed(true),
});
return (
Deploy {runId}
);
}
```
### Props
- `steps` (`TaskStep[]`)
The plan, in order. Each step is { id, label, meta? }; meta is a right-aligned mono aside — a duration, a count — revealed when its step completes.
- `current` (`number`)
Index of the step running now. Everything before it is done; at steps.length the run is complete.
- `failed` (`boolean`) — default: `false`
The run stopped at `current`. The active row becomes the failure, and nothing after it pretends to have run.
- `label` (`string`) — default: `"Task progress"`
Accessible name for the list.
- `className` (`string`) — default: `""`
Appended last to the root, so width and spacing are the caller's.
### Behavior notes
- The whole plan is mounted from the first paint with pending steps dimmed, so the run moves through existing rows — a step completing changes colours and marks, never geometry, and the panel never grows a pixel.
- The active label shimmers at one constant speed — the spinner's licence extended to type: an honest signal of an unknown wait, not decoration. Under reduced motion it is simply the medium-weight label.
- A completed step's check lands on an underdamped pop in a cell that was reserved all along, and its duration fades in beside it; a failure lands the same way in the flag colour, and nothing after it pretends to have run.
- State is two values — current and failed — so the component can be driven by anything that counts: a websocket, polling, or server-sent events, with no internal timer to fight.
- Screen readers get one settled sentence per stage — 'Building, step 2 of 4' — after a half-second hold, so a run that hops through three stages in a second is one announcement, not three.
- aria-current='step' rides the active row, and the finish is announced once — complete or failed — from its own polite region.
### Source (`components/interior/task-steps.tsx`)
```tsx
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const POP = { type: "spring", stiffness: 640, damping: 22, mass: 0.7 } as const;
const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const;
const STILL = { duration: 0 } as const;
export type TaskStep = {
id: string;
label: string;
meta?: string;
};
export type TaskStepStatus = "pending" | "active" | "done" | "error";
export type UseTaskStepsOptions = {
steps: TaskStep[];
current: number;
failed?: boolean;
};
export function useTaskSteps({ steps, current, failed = false }: UseTaskStepsOptions) {
const complete = !failed && current >= steps.length;
const rows = steps.map((step, i) => ({
...step,
status: (i < current
? "done"
: i === current && failed
? "error"
: i === current && !complete
? "active"
: "pending") as TaskStepStatus,
}));
const active = rows.find((r) => r.status === "active");
const sentence = failed
? `Failed at ${steps[Math.min(current, steps.length - 1)]?.label ?? "step"}`
: complete
? `All ${steps.length} steps complete`
: active
? `${active.label}, step ${current + 1} of ${steps.length}`
: "";
return { rows, complete, failed, sentence };
}
const Tick = (
);
const Cross = (
);
const Arc = ({ spin }: { spin: boolean }) => (
);
export type TaskStepsProps = UseTaskStepsOptions & {
label?: string;
className?: string;
};
export function TaskSteps({
steps,
current,
failed = false,
label = "Task progress",
className = "",
}: TaskStepsProps) {
const { rows, complete, sentence } = useTaskSteps({ steps, current, failed });
const reduced = useReducedMotion() === true;
const [spoken, setSpoken] = useState("");
useEffect(() => {
if (!sentence) return;
const t = setTimeout(() => setSpoken(sentence), 500);
return () => clearTimeout(t);
}, [sentence]);
return (