---
/**
* 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>