For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Task List 01

A progressive activity log for multi-step work.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/task-list-01

Usage

Provide tasks with a title and steps. Each step can include resource labels and optional icons. The list reveals one heading or step at a time; use startDelay and stepInterval to change the timing. Set collapseOnComplete to true to fold each finished task, or to "all" to fold them when the entire run finishes. Headings remain interactive so readers can reopen them.

For live events, pass revealed as the number of headings and steps to show. onComplete fires when all units have been revealed. Remount the list with a new React key to start a fresh timed run.

Source

"use client";

import {
  RiArrowDownSLine,
  RiSearchLine,
  RiSparklingLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Badge } from "@/components/ui/badge";
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";

export interface TaskListStep {
  label: string;
  resources?: { label: string; icon?: React.ReactNode }[];
}

export interface TaskListTask {
  title: string;
  steps: TaskListStep[];
  icon?: React.ReactNode;
}

export interface TaskListProps {
  tasks: TaskListTask[];
  startDelay?: number;
  stepInterval?: number;
  revealed?: number;
  collapseOnComplete?: boolean | "all";
  onComplete?: () => void;
  className?: string;
}

/** Each task heading and each step occupies one unit of progress. */
export function TaskList({
  tasks,
  startDelay = 320,
  stepInterval = 850,
  revealed,
  collapseOnComplete = false,
  onComplete,
  className,
}: TaskListProps) {
  const total = tasks.reduce((count, task) => count + 1 + task.steps.length, 0);
  const [internalProgress, setInternalProgress] = React.useState(0);
  const [expanded, setExpanded] = React.useState<Record<number, boolean>>({});
  const [elapsed, setElapsed] = React.useState(0);
  const completedRef = React.useRef(false);
  const progress = Math.max(0, Math.min(total, revealed ?? internalProgress));
  const complete = progress >= total;

  React.useEffect(() => {
    if (revealed !== undefined || internalProgress >= total) {
      return;
    }
    const timeout = window.setTimeout(
      () => setInternalProgress((value) => Math.min(value + 1, total)),
      internalProgress === 0
        ? Math.max(0, startDelay)
        : Math.max(0, stepInterval)
    );
    return () => window.clearTimeout(timeout);
  }, [internalProgress, revealed, startDelay, stepInterval, total]);

  React.useEffect(() => {
    if (complete) {
      return;
    }
    const started = Date.now();
    const timer = window.setInterval(
      () => setElapsed((Date.now() - started) / 1000),
      100
    );
    return () => window.clearInterval(timer);
  }, [complete]);

  React.useEffect(() => {
    if (complete && !completedRef.current) {
      completedRef.current = true;
      onComplete?.();
    } else if (!complete) {
      completedRef.current = false;
    }
  }, [complete, onComplete]);

  let offset = 0;
  return (
    <div
      className={cn("flex min-w-0 flex-col gap-3 text-sm leading-5", className)}
    >
      {tasks.map((task, index) => {
        const start = offset;
        offset += 1 + task.steps.length;
        if (progress <= start) {
          return null;
        }
        const visibleSteps = Math.min(task.steps.length, progress - start - 1);
        const taskComplete = visibleSteps === task.steps.length;
        const autoCollapsed =
          collapseOnComplete === "all"
            ? complete
            : collapseOnComplete && taskComplete;
        const open = expanded[index] ?? !autoCollapsed;
        return (
          <div key={index} className="task-list-01-enter min-w-0">
            <Collapsible
              open={open}
              onOpenChange={(next) =>
                setExpanded((previous) => ({ ...previous, [index]: next }))
              }
            >
              <CollapsibleTrigger className="w-full cursor-pointer text-left">
                <span className="flex min-w-0 items-center gap-2 rounded-[4px]">
                  <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
                    {task.icon ?? <RiSearchLine className="size-4" />}
                  </span>
                  <span className="min-w-0 flex-1 break-words">
                    {task.title}
                  </span>
                  <RiArrowDownSLine
                    className={cn(
                      "size-3.5 shrink-0 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none",
                      open && "rotate-180"
                    )}
                  />
                </span>
              </CollapsibleTrigger>
              <CollapsibleContent>
                <div className="pt-1">
                  {visibleSteps > 0 && (
                    <div className="ml-1.75 flex flex-col border-l border-border pl-4">
                      {task.steps
                        .slice(0, visibleSteps)
                        .map((step, stepIndex) => (
                          <div
                            key={stepIndex}
                            className="relative flex min-h-6 min-w-0 flex-wrap items-center gap-1.5 text-muted-foreground task-list-01-enter-delayed"
                            style={
                              {
                                "--delay": `${stepIndex * 40}ms`,
                              } as React.CSSProperties
                            }
                          >
                            <span className="absolute top-0 -left-4 h-3 w-3 rounded-bl-md border-b border-l border-border" />
                            <span className="min-w-0 max-w-full break-words">
                              {step.label}
                            </span>
                            {step.resources?.map((resource, resourceIndex) => (
                              <Badge
                                key={resourceIndex}
                                variant="counter"
                                tone="neutral"
                                className="max-w-full"
                              >
                                {resource.icon && (
                                  <span className="mr-1 shrink-0">
                                    {resource.icon}
                                  </span>
                                )}
                                <span className="truncate">
                                  {resource.label}
                                </span>
                              </Badge>
                            ))}
                          </div>
                        ))}
                    </div>
                  )}
                </div>
              </CollapsibleContent>
            </Collapsible>
          </div>
        );
      })}
      {!complete && progress > 0 && (
        <div
          role="status"
          className="flex items-center gap-2 text-xs text-muted-foreground"
        >
          <RiSparklingLine className="size-4" />
          <span>Working</span>
          <span>{elapsed.toFixed(1)}s</span>
        </div>
      )}
    </div>
  );
}