AI

Reasoning

A collapsible thinking block above an assistant answer, with a duration label and optional tool steps.

Built on
<details>::details-contentinterpolate-size
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
2.6 kB
Dependencies
React
Thought for 8 seconds

Read deploy-412.log, done

Compared health checks with build 411, done

Found the probe timeout at 30s, done

Try its options

Loading its options.

Code
import { Reasoning, ReasoningStep } from "@/components/equip/reasoning";

export default function Demo() {
  return (
    <div style={{ width: 224 }}>
      <Reasoning state="done" seconds={8}>
        <ReasoningStep title="Read deploy-412.log" />
        <ReasoningStep title="Compared health checks with build 411" />
        <ReasoningStep title="Found the probe timeout at 30s" />
      </Reasoning>
    </div>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="width:224px">
  <details class="eq-reasoning" data-state="done">
    <summary class="eq-reasoning-summary">
      <span class="eq-reasoning-label">Thought for 8 seconds</span>
      <svg class="eq-reasoning-chevron" viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
        <path d="m6 4 4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
      </svg>
    </summary>
    <div class="eq-reasoning-body">
      <div class="eq-reasoning-step" data-status="done">
        <span class="eq-reasoning-step-mark" aria-hidden="true">
          <svg class="eq-reasoning-step-tick" viewBox="0 0 16 16" fill="none">
            <path d="m3.5 8.5 3 3 6-7" pathLength="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <span class="eq-reasoning-step-spinner"></span>
        </span>
        <div class="eq-reasoning-step-main">
          <p class="eq-reasoning-step-title">Read deploy-412.log<span class="eq-reasoning-step-status">, done</span></p>
        </div>
      </div>
      <div class="eq-reasoning-step" data-status="done">
        <span class="eq-reasoning-step-mark" aria-hidden="true">
          <svg class="eq-reasoning-step-tick" viewBox="0 0 16 16" fill="none">
            <path d="m3.5 8.5 3 3 6-7" pathLength="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <span class="eq-reasoning-step-spinner"></span>
        </span>
        <div class="eq-reasoning-step-main">
          <p class="eq-reasoning-step-title">Compared health checks with build 411<span class="eq-reasoning-step-status">, done</span></p>
        </div>
      </div>
      <div class="eq-reasoning-step" data-status="done">
        <span class="eq-reasoning-step-mark" aria-hidden="true">
          <svg class="eq-reasoning-step-tick" viewBox="0 0 16 16" fill="none">
            <path d="m3.5 8.5 3 3 6-7" pathLength="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <span class="eq-reasoning-step-spinner"></span>
        </span>
        <div class="eq-reasoning-step-main">
          <p class="eq-reasoning-step-title">Found the probe timeout at 30s<span class="eq-reasoning-step-status">, done</span></p>
        </div>
      </div>
    </div>
  </details>
</div>

Examples

Steps as they run

Each step reads its status to screen readers, for example "Queried the error rate, in progress", and the tick draws when a step finishes.

Thought for 4 seconds

Read deploy-412.log, done

1,204 lines, 3 warnings, 1 error.

Queried the error rate, done

4.2% since 14:02, up from 0.3%.

Compared configs with build 411, done

pool.max dropped from 50 to 20.
Code
"use client";

import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { Reasoning, ReasoningStep } from "@/components/equip/reasoning";

const steps = [
  { title: "Read deploy-412.log", detail: "1,204 lines, 3 warnings, 1 error." },
  { title: "Queried the error rate", detail: "4.2% since 14:02, up from 0.3%." },
  { title: "Compared configs with build 411", detail: "pool.max dropped from 50 to 20." },
];

export default function Example() {
  const [done, setDone] = useState(steps.length);
  const thinking = done < steps.length;

  useEffect(() => {
    if (!thinking) return;
    const timer = setTimeout(() => setDone((n) => n + 1), 1400);
    return () => clearTimeout(timer);
  }, [thinking, done]);

  return (
    <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 12, maxWidth: 480 }}>
      <Reasoning state={thinking ? "thinking" : "done"} seconds={4} defaultOpen>
        {steps.map((step, i) => (
          <ReasoningStep key={step.title} title={step.title} status={i < done ? "done" : i === done ? "active" : "pending"}>
            {i < done ? step.detail : null}
          </ReasoningStep>
        ))}
      </Reasoning>
      <Button variant="outline" size="s" onClick={() => setDone(0)}>
        Run again
      </Button>
    </div>
  );
}

Thinking, with plain text

While thinking the block sets aria-busy, and the dots are decoration: the word Thinking carries the state.

Thinking

The error started at 14:02, the same minute build 412 rolled out. Before blaming the build I want to rule out traffic: the request rate at 14:02 was normal, so the change is the likelier cause.

Code
import { Reasoning } from "@/components/equip/reasoning";

export default function Example() {
  return (
    <div style={{ maxWidth: 480 }}>
      <Reasoning state="thinking">
        <p>
          The error started at 14:02, the same minute build 412 rolled out. Before blaming the build I want to rule out
          traffic: the request rate at 14:02 was normal, so the change is the likelier cause.
        </p>
      </Reasoning>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="max-width:480px">
  <details class="eq-reasoning" data-state="thinking" aria-busy="true">
    <summary class="eq-reasoning-summary">
      <span class="eq-reasoning-label">Thinking</span>
      <span class="eq-reasoning-dots" aria-hidden="true"><span></span><span></span><span></span></span>
      <svg class="eq-reasoning-chevron" viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
        <path d="m6 4 4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
      </svg>
    </summary>
    <div class="eq-reasoning-body">
      <p>The error started at 14:02, the same minute build 412 rolled out. Before blaming the build I want to rule out traffic: the request rate at 14:02 was normal, so the change is the likelier cause.</p>
    </div>
  </details>
</div>

Props

PropTypeDefaultWhat it does
state"thinking" | "done"noneThinking shows animated dots and sets aria-busy; done shows the duration.
secondsnumbernoneShown when done, as "Thought for 8 seconds".
labelReactNodenoneReplaces the generated label.
defaultOpenbooleanfalseStarts expanded.
childrenReactNodenoneThe reasoning text, or a run of ReasoningStep.
...restComponentProps<"details">noneEvery native details attribute, including name for an exclusive group.
ReasoningStep titleReactNodenoneWhat the step did, e.g. "Read deploy-412.log".
ReasoningStep status"done" | "active" | "pending""done"A drawn tick, a small spinner or an empty ring.

Anatomy

root.eq-reasoningThe details element.
summary.eq-reasoning-summary
label.eq-reasoning-label
dots.eq-reasoning-dotsThree dots shown while thinking.
chevron.eq-reasoning-chevron
body.eq-reasoning-body
step.eq-reasoning-step
step mark.eq-reasoning-step-mark
step tick.eq-reasoning-step-tickDraws its stroke when the step turns done.
step spinner.eq-reasoning-step-spinner
step main.eq-reasoning-step-main
step title.eq-reasoning-step-title
step status.eq-reasoning-step-statusVisually hidden status words.
step body.eq-reasoning-step-body

Keyboard

EnterSpaceOn the summary, opens or closes the block.

Notes

  • Built on details and summary, so it opens and closes without JavaScript and renders on the server.
  • While thinking the block sets aria-busy, and the word Thinking carries the state; the dots are decoration.
  • Each step's status is spoken after its title, such as "in progress", since the tick and spinner are hidden from assistive tech.
  • The dots and the step spinner are progress indicators and may loop; with reduced motion they stand still.
  • Steps are a run of blocks rather than a list; wrap them in your own ol if their count matters to the reader.
  • ::details-content is not yet in Firefox or Safari. There the block opens and closes at once, without the unfolding motion.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/reasoning.tsx92 lines
import type { ComponentProps, ReactNode } from "react";
import "./reasoning.css";

export type ReasoningProps = Omit<ComponentProps<"details">, "title"> & {
  /** "thinking" while the model is still working, "done" once the answer starts. */
  state: "thinking" | "done";
  /** How long it took, shown when done as "Thought for 8 seconds". */
  seconds?: number;
  /** Replaces the generated label, e.g. "Checked 3 sources". */
  label?: ReactNode;
  /** Starts expanded. */
  defaultOpen?: boolean;
  /** The reasoning text, or a run of ReasoningStep. */
  children?: ReactNode;
};

function doneLabel(seconds?: number) {
  if (seconds == null) return "Thought it through";
  const n = Math.max(1, Math.round(seconds));
  return `Thought for ${n} ${n === 1 ? "second" : "seconds"}`;
}

export function Reasoning({
  state,
  seconds,
  label,
  defaultOpen = false,
  className,
  children,
  ...rest
}: ReasoningProps) {
  const thinking = state === "thinking";
  return (
    <details
      open={defaultOpen}
      {...rest}
      className={["eq-reasoning", className].filter(Boolean).join(" ")}
      data-state={state}
      aria-busy={thinking || undefined}
    >
      <summary className="eq-reasoning-summary">
        <span className="eq-reasoning-label">{label ?? (thinking ? "Thinking" : doneLabel(seconds))}</span>
        {thinking && (
          <span className="eq-reasoning-dots" aria-hidden="true">
            <span />
            <span />
            <span />
          </span>
        )}
        <svg className="eq-reasoning-chevron" viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
          <path d="m6 4 4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </summary>
      <div className="eq-reasoning-body">{children}</div>
    </details>
  );
}

export type ReasoningStepProps = Omit<ComponentProps<"div">, "title"> & {
  /** What the step did, e.g. "Read deploy-412.log". */
  title: ReactNode;
  status?: "done" | "active" | "pending";
};

const statusWords = { done: "done", active: "in progress", pending: "not started" } as const;

export function ReasoningStep({ title, status = "done", className, children, ...rest }: ReasoningStepProps) {
  return (
    <div {...rest} className={["eq-reasoning-step", className].filter(Boolean).join(" ")} data-status={status}>
      <span className="eq-reasoning-step-mark" aria-hidden="true">
        <svg className="eq-reasoning-step-tick" viewBox="0 0 16 16" fill="none">
          <path
            d="m3.5 8.5 3 3 6-7"
            pathLength={1}
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
        <span className="eq-reasoning-step-spinner" />
      </span>
      <div className="eq-reasoning-step-main">
        <p className="eq-reasoning-step-title">
          {title}
          <span className="eq-reasoning-step-status">, {statusWords[status]}</span>
        </p>
        {children != null && <div className="eq-reasoning-step-body">{children}</div>}
      </div>
    </div>
  );
}
components/equip/reasoning.css233 lines
@layer equip {
  .eq-reasoning {
    min-width: 0;
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-s) / 1.5 var(--eq-font);
    interpolate-size: allow-keywords;
  }

  .eq-reasoning-summary {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    min-height: var(--eq-h-s);
    margin-inline-start: calc(-1 * var(--eq-space-2));
    padding-inline: var(--eq-space-2);
    border-radius: var(--eq-radius-s);
    color: var(--eq-ink-2);
    font-weight: var(--eq-weight-strong);
    list-style: none;
    cursor: pointer;
    user-select: none;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-reasoning-summary::-webkit-details-marker {
    display: none;
  }

  @media (hover: hover) {
    .eq-reasoning-summary:hover {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }
  }

  .eq-reasoning-summary:active {
    scale: var(--eq-press);
  }

  .eq-reasoning-summary:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-reasoning-chevron {
    flex: none;
    color: var(--eq-ink-3);
    transition: rotate var(--eq-dur-move) var(--eq-spring);
  }

  .eq-reasoning[open] .eq-reasoning-chevron {
    rotate: 90deg;
  }

  /* A progress indicator, so it may loop. With motion off the duration is zero and the dots sit still. */
  .eq-reasoning-dots {
    display: inline-flex;
    align-items: flex-end;
    gap: 2px;
    height: 1em;
    padding-block-end: 0.2em;
  }

  .eq-reasoning-dots > span {
    width: 3px;
    height: 3px;
    border-radius: var(--eq-radius-full);
    background: currentColor;
    animation: eq-reasoning-dot calc(var(--eq-dur-move) * 3) var(--eq-ease) infinite;
  }

  .eq-reasoning-dots > span:nth-child(2) {
    animation-delay: calc(var(--eq-dur-move) * 0.4);
  }

  .eq-reasoning-dots > span:nth-child(3) {
    animation-delay: calc(var(--eq-dur-move) * 0.8);
  }

  @keyframes eq-reasoning-dot {
    30% {
      opacity: 0.2;
    }
    60% {
      opacity: 1;
    }
  }

  .eq-reasoning-body {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-2);
    margin-block-start: var(--eq-space-1);
    padding-inline-start: var(--eq-space-3);
    border-inline-start: 1px solid var(--eq-line);
  }

  .eq-reasoning-body > :where(p) {
    margin: 0;
  }

  /* Where the content slot can be styled, the steps unfold with the spring and fold back faster. */
  @supports selector(::details-content) {
    .eq-reasoning::details-content {
      block-size: 0;
      overflow: clip;
      opacity: 0;
      transition:
        block-size var(--eq-dur) var(--eq-ease),
        opacity var(--eq-dur) var(--eq-ease),
        content-visibility var(--eq-dur) allow-discrete;
      transition-behavior: allow-discrete;
    }

    .eq-reasoning[open]::details-content {
      block-size: auto;
      opacity: 1;
      transition:
        block-size var(--eq-dur-move) var(--eq-spring),
        opacity var(--eq-dur) var(--eq-ease),
        content-visibility var(--eq-dur-move) allow-discrete;
      transition-behavior: allow-discrete;
    }
  }

  .eq-reasoning-step {
    display: flex;
    gap: var(--eq-space-2);
    min-width: 0;
  }

  .eq-reasoning-step-mark {
    position: relative;
    flex: none;
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
    margin-block-start: 2px;
    color: var(--eq-ink-2);
  }

  /* Pending: a quiet empty ring. */
  .eq-reasoning-step[data-status="pending"] .eq-reasoning-step-mark::before {
    content: "";
    width: 10px;
    height: 10px;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-full);
  }

  .eq-reasoning-step-tick {
    grid-area: 1 / 1;
    width: 16px;
    height: 16px;
  }

  .eq-reasoning-step-tick > path {
    stroke-dasharray: 1;
    stroke-dashoffset: 1;
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
  }

  .eq-reasoning-step[data-status="done"] .eq-reasoning-step-tick > path {
    stroke-dashoffset: 0;
  }

  .eq-reasoning-step-spinner {
    display: none;
    grid-area: 1 / 1;
    width: 11px;
    height: 11px;
    border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
    border-top-color: var(--eq-ink);
    border-radius: var(--eq-radius-full);
    animation: eq-reasoning-spin calc(var(--eq-dur-move) * 2) linear infinite;
  }

  .eq-reasoning-step[data-status="active"] .eq-reasoning-step-spinner {
    display: block;
  }

  @keyframes eq-reasoning-spin {
    to {
      rotate: 1turn;
    }
  }

  .eq-reasoning-step-main {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
  }

  .eq-reasoning-step-title {
    margin: 0;
    color: var(--eq-ink);
    transition: color var(--eq-dur) var(--eq-ease);
  }

  .eq-reasoning-step[data-status="pending"] .eq-reasoning-step-title {
    color: var(--eq-ink-3);
  }

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

  .eq-reasoning-step-body {
    color: var(--eq-ink-2);
    overflow-wrap: anywhere;
  }

  .eq-reasoning-step-body > :where(p) {
    margin: 0;
  }

  .eq-reasoning-step-body :where(code) {
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: 0.92em;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index