## 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 (
    {rows.map((row) => { const tone = row.status === "done" ? "text-stone-600 dark:text-stone-300" : row.status === "active" ? "font-medium text-stone-800 dark:text-stone-100" : row.status === "error" ? "font-medium text-red-600 dark:text-red-400" : "text-stone-400 dark:text-stone-500"; return (
  1. {row.status === "done" ? ( {Tick} ) : row.status === "error" ? ( {Cross} ) : row.status === "active" ? ( ) : ( )} {row.status === "active" && !reduced ? ( {row.label} ) : ( {row.label} )} {row.meta ? ( {row.meta} ) : null}
  2. ); })}
{spoken} {complete ? "Run complete" : failed ? "Run failed" : ""}
); } ```