# Thinking Bar

> The line above a reasoning model's answer: the label shimmers while a seconds counter rolls, then "Thinking" morphs into "Thought for 12s" and the reasoning folds away a second after the answer starts.

- Tier: Free (MIT)
- Interaction: Loading
- Page: https://eased.dev/components/thinking-bar
- Installs: motion, @base-ui/react
- Registry: https://eased.dev/r/thinking-bar.json

## Install

```bash
npx shadcn add https://eased.dev/r/thinking-bar.json
```

The line above a reasoning model's answer: "Thinking 4s" while it works, "Thought for 12s" when it's done, with the reasoning folded inside.

```tsx
import { ThinkingBar } from "@/components/eased/thinking-bar/thinking-bar";

<ThinkingBar status={isReasoning ? "thinking" : "done"} lines={["Reading your plan", "Comparing plans"]}>
  {reasoningText}
</ThinkingBar>

// A message loaded from history shows its real duration.
<ThinkingBar status="done" startedAt={msg.reasoningStartedAt} endedAt={msg.reasoningEndedAt}>{msg.reasoning}</ThinkingBar>
```

With the AI SDK, `status` is `"thinking"` while the message's last part is a streaming `reasoning` part.

## Props

| Prop | Type | Default | |
|---|---|---|---|
| `status` | `"thinking" \| "done"` | | |
| `startedAt` / `endedAt` | `number \| Date` | | Omit `startedAt` to count from when `status` turns `thinking`. |
| `label` | `string` | `"Thinking"` | |
| `lines` | `string[]` | | Status lines to rotate through after `label` (2–4s each by length; holds on the last). |
| `doneLabel` | `string` | `"Thought for"` | Under one second it reads "Thought for a moment". |
| `icon` | `ReactNode` | | Before the label, e.g. `<AgentOrb size="sm" label={false} />`. |
| `children` | `ReactNode` | | The reasoning. With it the bar is a disclosure; without it, a plain line. |
| `open` / `defaultOpen` / `onOpenChange` | | | Controlled or uncontrolled. `onOpenChange` also reports the automatic changes. |
| `autoOpen` | `boolean` | `true` | Open while thinking. |
| `autoClose` | `number \| false` | `1000` | Close this many ms after `done`. Both automatic behaviours stop once the reader toggles it, and come back on the next thinking run. |
| `className` | `string` | | |

Base UI Collapsible supplies the button, `aria-expanded` and `aria-controls`. The rolling digits are hidden from assistive tech; the button's name is "Thinking…" or "Thought for 12 seconds", and a polite status announces the result once.

**Motion signature:** the label shimmers while a seconds counter rolls beside it; on completion "Thinking" swaps to "Thought for" and the counter, the same element, glides along the springing width to read "Thought for 12s", while the reasoning folds away on Auto Height and the chevron turns on the same smooth spring.

## Example

`components/eased/thinking-bar/thinking-bar-demo.tsx`

```tsx
"use client";

import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { enter, useSafeReducedMotion } from "../lib/motion";
import { ThinkingBar } from "./thinking-bar";

const TRACE = [
  "The team is on monthly at $14 a seat, 18 seats, renewing 1 March.",
  "Six seats added in March prorate on either plan, so they cancel out.",
  "Yearly is $140 a seat: 24 × 140 = $3,360, against 24 × 14 × 12 = $4,032.",
  "Yearly saves $672 even with the mid-year seats. Worth saying it locks the count until renewal.",
];

const ANSWER =
  "Yes. With 24 seats, yearly comes to $3,360 against $4,032 on monthly, so you save $672. The six March seats are prorated either way. The one catch: on yearly you can add seats any time, but you can't drop below 24 until renewal.";

const THINK_MS = 6400;

export function ThinkingBarDemo() {
  const [run, setRun] = useState(0);
  const [status, setStatus] = useState<"thinking" | "done">("thinking");
  const [traced, setTraced] = useState(1);
  const [words, setWords] = useState(0);
  const reduced = useSafeReducedMotion();
  const answer = ANSWER.split(" ");

  // The script: trace lines land while thinking, then the answer streams in word by word.
  useEffect(() => {
    const timers = [
      ...TRACE.slice(1).map((_, i) => window.setTimeout(() => setTraced(i + 2), ((i + 1) * THINK_MS) / TRACE.length)),
      window.setTimeout(() => setStatus("done"), THINK_MS),
    ];
    return () => timers.forEach(clearTimeout);
  }, [run]);

  useEffect(() => {
    if (status !== "done" || words >= answer.length) return;
    const t = window.setTimeout(() => setWords((w) => Math.min(answer.length, w + 1 + Math.round(Math.random() * 2))), 45 + Math.random() * 70);
    return () => clearTimeout(t);
  }, [status, words, answer.length]);

  const replay = () => {
    setStatus("thinking");
    setTraced(1);
    setWords(0);
    setRun((r) => r + 1);
  };

  return (
    <DemoFrame
      title="Billing assistant"
      actions={
        <Button variant="ghost" size="sm" edge="end" onClick={replay}>
          Replay
        </Button>
      }
    >
      <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 [scrollbar-width:thin]">
        {/* The turn before, so the new question lands in a conversation rather than an empty panel. */}
        <p className="ml-auto max-w-[85%] rounded-panel bg-surface-sunken px-3.5 py-2 text-ui text-text text-pretty">
          What are we paying now?
        </p>
        <p className="text-ui text-text text-pretty">$252 a month: 18 seats on monthly at $14 each, renewing 1 March.</p>
        <p className="ml-auto max-w-[85%] rounded-panel bg-surface-sunken px-3.5 py-2 text-ui text-text text-pretty">
          If we add 6 seats in March, is yearly still cheaper?
        </p>

        <div className="flex flex-col gap-3">
          <ThinkingBar
            key={run}
            status={status}
            lines={["Reading your plan", "Prorating 6 seats from March", "Comparing monthly and yearly"]}
          >
            <div className="flex flex-col gap-1.5">
              {TRACE.slice(0, traced).map((line) => (
                <motion.p
                  key={line}
                  initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: "blur(4px)" }}
                  animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                  transition={enter}
                >
                  {line}
                </motion.p>
              ))}
            </div>
          </ThinkingBar>

          {words > 0 && (
            <p className="text-ui text-text text-pretty" aria-live="polite">
              {answer.slice(0, words).map((w, i) => (
                <motion.span
                  key={i}
                  initial={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(4px)" }}
                  animate={{ opacity: 1, filter: "blur(0px)" }}
                  transition={enter}
                >
                  {w}{" "}
                </motion.span>
              ))}
            </p>
          )}
        </div>
      </div>
    </DemoFrame>
  );
}
```
