# Thinking

> Expandable agent trace for steps, reasoning, search or tool calls.

- **Slug:** `thinking-state`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/ThinkingState.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/thinking-state
- **All files in one bundle:** https://viewership.ai/ui/components/thinking-state.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/thinking-state.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/thinking-state/`:

   - `ThinkingState.astro` (raw: https://viewership.ai/ui/raw/thinking-state/ThinkingState.astro.txt)

npm packages: none.

## Usage

```astro
---
import ThinkingState from "../components/viewership/thinking-state/ThinkingState.astro";
---

<ThinkingState />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import ThinkingState from "./ThinkingState.astro";
import type { ThinkingRow } from "./ThinkingState.astro";

interface Props {
  variant?: string;
}
const { variant = "Steps" } = Astro.props;

const steps: ThinkingRow[] = [
  { primary: "Reading this week's GA4 data" },
  { primary: "Checking Google Ads spend" },
  { primary: "Comparing AI citations", secondary: "6 prompts" },
  { primary: "Drafting the weekly summary" },
];

const reasoning: ThinkingRow[] = [
  { primary: "ChatGPT cited the trail pack guide on 4 of 6 prompts, and the gear list section gets quoted most often." },
  { primary: "Before featuring it in the spring email, I should check whether Perplexity and Gemini cite it too." },
];

const sources: ThinkingRow[] = [
  { primary: "Best trail packs for 2026, thread", secondary: "r/Backpacking" },
  { primary: "Northwind pack review: 300 miles", secondary: "YouTube" },
  { primary: "Lightweight packs under $200", secondary: "Gear Lab Reviews" },
];

const tools: ThinkingRow[] = [
  { primary: "Read", secondary: "ga4-export.ts", mono: true },
  { primary: "Edit", secondary: "WeeklyReport.astro", mono: true, add: 62, del: 18 },
  { primary: "Run", secondary: "npm run weekly-report", mono: true },
];
---

{variant === "Steps" && <ThinkingState variant="steps" rows={steps} done="Thought for 4 seconds" />}
{variant === "Reasoning" && <ThinkingState variant="reasoning" rows={reasoning} done="Thought for 4 seconds" />}
{variant === "Search" && (
  <ThinkingState
    variant="search"
    rows={sources}
    query="best lightweight trail packs reddit"
    more="+5 more"
    active="Searching threads"
    done="Searched threads"
  />
)}
{variant === "Coding" && <ThinkingState variant="coding" rows={tools} />}
```

## Variants

Demo variants: `Steps`, `Reasoning`, `Search`, `Coding`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"steps" \| "reasoning" \| "search" \| "coding"` | `"steps"` | Checklist, prose, sources read, or tool calls. |
| `rows` | `{ primary: string; secondary?: string; mono?: boolean; add?: number; del?: number; href?: string }[]` |  | Trace rows. Search rows with href are links. |
| `active` | `string` | `per variant` | Header while working. |
| `done` | `string` | `per variant` | Header once settled. Announced politely. |
| `query` | `string` |  | Search only: the query line. |
| `more` | `string` |  | Search only: trailing note, e.g. "+5 more". |
| `open` | `boolean` | `false` | Stay expanded once settled. |
| `autoplay` | `boolean` | `true` | Play in view. When false or under reduced motion, the settled state renders. |
| `loop` | `boolean` | `false` | Replay after a pause. Stops once toggled. |

## Source

### `src/components/viewership/thinking-state/ThinkingState.astro`

```astro
---
/**
 * ThinkingState: expandable agent trace.
 *
 *   steps      step list with a spinner that settles into checks
 *   reasoning  prose reasoning that expands, then settles
 *   search     the query, then the sources read
 *   coding     tool trace: files read, edits (+/−), commands
 *
 * Plays once when scrolled into view, then stays expandable. The server
 * renders the settled state, which reduced motion and `autoplay={false}`
 * also show. Fires `vs-thinking-state:settled` (bubbles) when it settles.
 *
 * Ported from Beautiful UI `components/primitives/ThinkingState.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * @example
 * <ThinkingState variant="search" query="best trail running shoes 2026" rows={sources} />
 */
import type { HTMLAttributes } from "astro/types";

type Variant = "steps" | "reasoning" | "search" | "coding";

export interface ThinkingRow {
  /** Main text: the step, sentence, page title or tool name. */
  primary: string;
  /** Muted trailing text: a count, a domain, a file name. */
  secondary?: string;
  /** Set `secondary` in the mono face. */
  mono?: boolean;
  /** Lines added / removed (coding). */
  add?: number;
  del?: number;
  /** Search: link the source (opens in a new tab). */
  href?: string;
}

type Props = HTMLAttributes<"div"> & {
  variant?: Variant;
  rows: ThinkingRow[];
  /** Header while working. */
  active?: string;
  /** Header once settled. */
  done?: string;
  /** Search: the query line above the sources. */
  query?: string;
  /** Search: trailing note once settled, e.g. "+7 more". */
  more?: string;
  /** Leave the trace expanded once it settles. */
  open?: boolean;
  /** Play the sequence when scrolled into view. */
  autoplay?: boolean;
  /** Replay after a pause (stops once someone interacts). */
  loop?: boolean;
};

const {
  variant = "steps",
  rows = [],
  active: activeProp,
  done: doneProp,
  query,
  more,
  open = false,
  autoplay = true,
  loop = false,
  class: className,
  ...rest
} = Astro.props;

const DEFAULT_LABELS: Record<Variant, { active: string; done: string }> = {
  steps: { active: "Thinking", done: "Thought for a few seconds" },
  reasoning: { active: "Thinking", done: "Thought for a few seconds" },
  search: { active: "Searching the web", done: "Searched the web" },
  coding: { active: "Running tools", done: `Ran ${rows.length} tool${rows.length === 1 ? "" : "s"}` },
};
const active = activeProp ?? DEFAULT_LABELS[variant].active;
const done = doneProp ?? DEFAULT_LABELS[variant].done;
const panelId = `vs-thinking-${Math.random().toString(36).slice(2, 8)}`;
const TONES = ["accent", "orange", "green"];
---

<div
  class:list={["vs-thinking-state", `vs-thinking-state--${variant}`, className]}
  data-autoplay={autoplay ? "" : undefined}
  data-loop={loop ? "" : undefined}
  data-open={open ? "" : undefined}
  data-expanded={open ? "" : undefined}
  {...rest}
>
  <button type="button" class="header" aria-expanded={open ? "true" : "false"} aria-controls={panelId}>
    <svg class="glyph" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z"></path>
    </svg>
    <span class="ts-label ts-label--active">{active}</span>
    <span class="ts-label ts-label--done">{done}</span>
    <svg
      class="chevron"
      width="14"
      height="14"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2.2"
      stroke-linecap="round"
      stroke-linejoin="round"
      aria-hidden="true"
    >
      <path d="M6 9l6 6 6-6"></path>
    </svg>
  </button>
  <span class="visually-hidden" role="status" data-live></span>

  <div class="panel" id={panelId}>
    <div class="panel-clip" data-panel-clip>
      <div class="trace-wrap">
        <span class="trace-line" aria-hidden="true" data-line></span>
        <div class="trace" data-trace>
          {query && (
            <div class="query">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
                <circle cx="11" cy="11" r="7"></circle>
                <path d="M21 21l-4.3-4.3"></path>
              </svg>
              <span>{query}</span>
            </div>
          )}
          {rows.map((row, i) => {
            const Tag = variant === "search" && row.href ? "a" : variant === "coding" ? "button" : "div";
            return (
              <Tag
                class="row"
                data-row
                style={`--vs-ts-i: ${i}`}
                href={Tag === "a" ? row.href : undefined}
                target={Tag === "a" ? "_blank" : undefined}
                rel={Tag === "a" ? "noreferrer" : undefined}
                type={Tag === "button" ? "button" : undefined}
                aria-pressed={Tag === "button" ? "false" : undefined}
              >
                {variant === "search" && (
                  <span class={`dot dot--${TONES[i % 3]}`} aria-hidden="true">
                    <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
                      <circle cx="12" cy="12" r="9"></circle>
                      <path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"></path>
                    </svg>
                  </span>
                )}
                {variant === "steps" && (
                  <span class="status-icon" aria-hidden="true">
                    <svg class="check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
                      <path d="M20 6L9 17l-5-5"></path>
                    </svg>
                    <span class="spinner"></span>
                  </span>
                )}
                <span class="primary">
                  <span class="primary-text">{row.primary}</span>
                </span>
                {row.secondary && <span class:list={["secondary", row.mono && "is-mono"]}>{row.secondary}</span>}
                {row.add !== undefined && (
                  <span class="counts">
                    <span class="add">+{row.add}</span>{" "}
                    {row.del !== undefined && <span class="del">−{row.del}</span>}
                  </span>
                )}
              </Tag>
            );
          })}
          {more && <span class="more" data-more>{more}</span>}
        </div>
      </div>
    </div>
  </div>
</div>

<style>
  .vs-thinking-state {
    display: flex;
    width: 100%;
    max-width: 380px;
    flex-direction: column;
    transition: min-height 400ms var(--vs-ease-out);
  }
  .vs-thinking-state[data-working],
  .vs-thinking-state[data-expanded] {
    min-height: 176px;
  }

  /* ── header ── */
  .header {
    display: flex;
    width: fit-content;
    align-items: center;
    gap: 8px;
    margin: 0 -6px;
    padding: 4px 6px;
    border: none;
    border-radius: var(--vs-radius-control);
    background: transparent;
    color: inherit;
    font: inherit;
    cursor: pointer;
    transition: background-color 100ms ease-out;
  }
  .header:hover {
    background: var(--vs-hover-2);
  }
  .header:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .glyph {
    flex-shrink: 0;
    fill: var(--vs-ink-3);
    transition: fill 200ms ease-out;
  }
  [data-working] .glyph {
    fill: var(--vs-ink-2);
  }
  .ts-label {
    font-size: 13px;
    font-weight: 500;
    white-space: nowrap;
  }
  .ts-label--active {
    display: none;
    color: transparent;
    background-image: linear-gradient(90deg, var(--vs-ink-3) 35%, var(--vs-ink) 50%, var(--vs-ink-3) 65%);
    background-size: 200% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    animation: vs-shimmer-text 1.4s linear infinite;
  }
  .ts-label--done {
    color: var(--vs-ink-2);
    animation: vs-fade-in 350ms ease-out both;
  }
  [data-working] .ts-label--active {
    display: inline;
  }
  [data-working] .ts-label--done {
    display: none;
  }
  .chevron {
    flex-shrink: 0;
    color: var(--vs-ink-3);
    transition: transform 300ms ease-out;
  }
  [data-expanded] .chevron {
    transform: rotate(180deg);
  }

  /* ── expandable trace ── */
  .panel {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    transition:
      grid-template-rows 400ms var(--vs-ease-out),
      opacity 400ms var(--vs-ease-out);
  }
  [data-expanded] .panel {
    grid-template-rows: 1fr;
    opacity: 1;
  }
  .panel-clip {
    min-height: 0;
    overflow: hidden;
  }
  .trace-wrap {
    position: relative;
    margin: 4px 0 0 5px;
    padding-left: 16px;
  }
  .trace-line {
    position: absolute;
    top: -8px;
    left: 3px;
    width: 1px;
    height: calc(100% - 2px);
    background: var(--vs-line);
    transition: height 500ms var(--vs-ease-out);
  }
  .trace {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 4px 0;
  }

  .query {
    display: flex;
    height: 24px;
    align-items: center;
    gap: 8px;
    padding: 0 6px;
    font-size: 12.5px;
    color: var(--vs-ink-2);
  }
  .query svg {
    flex-shrink: 0;
    color: var(--vs-ink-3);
  }
  [data-expanded] .query {
    animation: vs-fade-up 300ms var(--vs-ease-out) both;
  }

  .row {
    display: flex;
    width: 100%;
    min-height: 28px;
    box-sizing: border-box;
    align-items: center;
    gap: 8px;
    padding: 2px 6px;
    border: none;
    border-radius: var(--vs-radius-chip);
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: left;
    text-decoration: none;
    animation: vs-fade-up 320ms var(--vs-ease-out) calc(var(--vs-ts-i) * 120ms) both;
  }
  .row[hidden] {
    display: none;
  }
  a.row,
  button.row {
    cursor: pointer;
    transition: background-color 150ms ease-out;
  }
  a.row:hover,
  button.row:hover {
    background: var(--vs-hover);
  }
  button.row[aria-pressed="true"] {
    background: var(--vs-inset);
  }
  a.row:focus-visible,
  button.row:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

  .primary {
    min-width: 0;
    overflow: hidden;
    font-size: 12.5px;
    font-weight: 500;
    color: var(--vs-ink);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .vs-thinking-state--reasoning .primary {
    font-weight: 400;
    line-height: 1.625;
    color: var(--vs-ink-2);
    white-space: normal;
  }
  /* search titles get the link underline that draws in on hover */
  .vs-thinking-state--search .primary-text {
    position: relative;
  }
  .vs-thinking-state--search .primary-text::after {
    position: absolute;
    right: 0;
    bottom: -1px;
    left: 0;
    height: 1px;
    content: "";
    background: currentColor;
    transform: scaleX(0);
    transform-origin: left center;
    transition: transform 280ms var(--vs-ease-link);
  }
  a.row:hover .primary-text::after,
  a.row:focus-visible .primary-text::after {
    transform: scaleX(1);
  }
  .secondary {
    flex-shrink: 0;
    font-size: 11.5px;
    color: var(--vs-ink-3);
  }
  .secondary.is-mono {
    font-family: var(--vs-font-mono);
  }
  .counts {
    flex-shrink: 0;
    font-family: var(--vs-font-sans);
    font-size: 11px;
    font-variant-numeric: tabular-nums;
  }
  .add {
    color: var(--vs-green);
  }
  .del {
    color: var(--vs-red);
  }

  .dot {
    display: flex;
    width: 14px;
    height: 14px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    color: var(--vs-on-accent);
  }
  .dot--accent {
    background: var(--vs-accent);
  }
  .dot--orange {
    background: var(--vs-orange);
  }
  .dot--green {
    background: var(--vs-green);
  }

  .status-icon {
    display: flex;
    width: 14px;
    height: 14px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    color: var(--vs-ink-3);
  }
  .spinner {
    display: none;
    width: 12px;
    height: 12px;
    box-sizing: border-box;
    border: 1.5px solid var(--vs-line-strong);
    border-top-color: var(--vs-ink-2);
    border-radius: 50%;
    animation: vs-spin 700ms linear infinite;
  }
  .row.is-active .spinner {
    display: block;
  }
  .row.is-active .check {
    display: none;
  }

  .more {
    font-size: 12px;
    color: var(--vs-ink-3);
    animation: vs-fade-in 300ms ease-out both;
  }
  .more[hidden] {
    display: none;
  }

  .visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* offscreen: hold the shimmer and spinner */
  [data-paused] .ts-label--active,
  [data-paused] .spinner {
    animation-play-state: paused;
  }

  /* hide the settled markup for the instant before the script rewinds it */
  @media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
    .vs-thinking-state[data-autoplay]:not([data-vs-ready]) {
      visibility: hidden;
    }
  }
  /* without script the header can't toggle, so show the trace */
  @media (scripting: none) {
    .panel {
      grid-template-rows: 1fr;
      opacity: 1;
    }
    .chevron {
      display: none;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .ts-label--active {
      animation: none;
      background: none;
      color: var(--vs-ink-2);
    }
    .ts-label--done,
    .row,
    .more,
    [data-expanded] .query {
      animation: none;
    }
    .spinner {
      animation: none;
    }
  }
  @media (forced-colors: active) {
    .ts-label--active {
      background: none;
      color: CanvasText;
    }
  }
</style>

<script>
  /* stage timings: start → open → first rows → settle → fold away */
  const STAGES = [800, 600, 1800, 2600];
  const FINAL = STAGES.length;
  const LOOP_HOLD = 3200;

  /** One pending timeout that can pause and resume where it left off. */
  function createTimer() {
    let id: number | undefined;
    let fn: (() => void) | undefined;
    let left = 0;
    let due = 0;
    let paused = true;
    const arm = () => {
      due = performance.now() + left;
      id = window.setTimeout(() => {
        const run = fn;
        id = fn = undefined;
        run?.();
      }, left);
    };
    return {
      set(ms: number, next: () => void) {
        window.clearTimeout(id);
        id = undefined;
        fn = next;
        left = ms;
        if (!paused) arm();
      },
      pause() {
        if (paused) return;
        paused = true;
        if (id === undefined) return;
        window.clearTimeout(id);
        id = undefined;
        left = Math.max(0, due - performance.now());
      },
      resume() {
        if (!paused) return;
        paused = false;
        if (fn) arm();
      },
      clear() {
        window.clearTimeout(id);
        id = fn = undefined;
      },
    };
  }

  function init(root: HTMLElement) {
    const header = root.querySelector<HTMLButtonElement>(".header")!;
    const live = root.querySelector<HTMLElement>("[data-live]")!;
    const clip = root.querySelector<HTMLElement>("[data-panel-clip]")!;
    const line = root.querySelector<HTMLElement>("[data-line]")!;
    const trace = root.querySelector<HTMLElement>("[data-trace]")!;
    const more = root.querySelector<HTMLElement>("[data-more]");
    const rows = [...root.querySelectorAll<HTMLElement>("[data-row]")];
    const doneLabel = root.querySelector(".ts-label--done")?.textContent ?? "";
    const settledOpen = root.hasAttribute("data-open");
    const isSteps = root.classList.contains("vs-thinking-state--steps");
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let loop = root.hasAttribute("data-loop");

    let stage = FINAL;
    let manual: boolean | null = null;
    let announced = false;
    const timer = createTimer();

    const render = () => {
      const working = stage < 3;
      const auto = stage === FINAL ? settledOpen : stage >= 1;
      const expanded = manual ?? auto;
      const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, rows.length) : rows.length;

      root.toggleAttribute("data-working", working);
      root.toggleAttribute("data-expanded", expanded);
      header.setAttribute("aria-expanded", String(expanded));
      clip.inert = !expanded;
      rows.forEach((row, i) => {
        row.hidden = i >= visible;
        if (isSteps) row.classList.toggle("is-active", working && i === visible - 1);
      });
      if (more) more.hidden = stage < 3;
      /* px heights let the line grow smoothly; unmeasurable (hidden) falls back to the CSS calc */
      const h = trace.offsetHeight;
      line.style.height = h ? `${h - 2}px` : "";
    };

    const settle = () => {
      if (!announced) {
        announced = true;
        live.textContent = doneLabel;
      }
      root.dispatchEvent(new CustomEvent("vs-thinking-state:settled", { bubbles: true }));
    };

    const step = () => {
      stage += 1;
      render();
      if (stage === 3) settle();
      if (stage < FINAL) timer.set(STAGES[stage]!, step);
      else if (loop) timer.set(LOOP_HOLD, restart);
    };

    const restart = () => {
      /* never rewind while someone is reading or using it */
      if (!loop) return;
      if (root.matches(":hover") || root.contains(document.activeElement)) return timer.set(LOOP_HOLD, restart);
      stage = 0;
      manual = null;
      rows.forEach((row) => row.setAttribute("aria-pressed", "false"));
      render();
      timer.set(STAGES[0]!, step);
    };

    header.addEventListener("click", () => {
      const current = header.getAttribute("aria-expanded") === "true";
      manual = !current;
      loop = false;
      render();
    });

    /* coding rows: select one tool call at a time */
    rows.forEach((row) => {
      if (row.tagName !== "BUTTON") return;
      row.addEventListener("click", () => {
        const pressed = row.getAttribute("aria-pressed") === "true";
        rows.forEach((other) => other.setAttribute("aria-pressed", "false"));
        row.setAttribute("aria-pressed", String(!pressed));
      });
    });

    if (reduced || !root.hasAttribute("data-autoplay")) {
      render();
      return;
    }

    stage = 0;
    render();
    timer.set(STAGES[0]!, step);

    const io = new IntersectionObserver(([entry]) => {
      if (!root.isConnected) return teardown();
      if (entry?.isIntersecting) {
        root.removeAttribute("data-paused");
        timer.resume();
      } else {
        root.setAttribute("data-paused", "");
        timer.pause();
      }
    }, { threshold: 0.2 });
    io.observe(root);

    function teardown() {
      timer.clear();
      io.disconnect();
    }
    document.addEventListener("astro:before-swap", teardown, { once: true });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-thinking-state:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>
```
