diff --git a/README.md b/README.md
index 103f62e3..8a075988 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,22 @@ bun run stories # Ladle workbench
A new component is not done until it has at least one story per meaningful state, checked in both themes. See [CONTRIBUTING.md](./CONTRIBUTING.md).
+## Thinking indicator
+
+The Corbits logo has three motion variants: `silk` (default), `strata`, and `echo`.
+
+```tsx
+import { ThinkingIndicator, ThinkingMark } from "@corbits/react-ui";
+
+
+
+
+```
+
+`ThinkingIndicator` supplies an accessible status label; `ThinkingMark` is decorative.
+Both accept `variant`, `paused`, `className`, and `style`. Reduced-motion users see a
+static highlight instead.
+
## License
LGPL-2.1-only. See [LICENSE](./LICENSE).
diff --git a/package.json b/package.json
index 3129f69a..362c44c2 100644
--- a/package.json
+++ b/package.json
@@ -503,6 +503,14 @@
"types": "./dist/ui/theme-toggle.d.ts",
"default": "./dist/ui/theme-toggle.js"
},
+ "./ui/thinking-indicator": {
+ "types": "./dist/ui/thinking-indicator.d.ts",
+ "default": "./dist/ui/thinking-indicator.js"
+ },
+ "./ui/thinking-mark": {
+ "types": "./dist/ui/thinking-mark.d.ts",
+ "default": "./dist/ui/thinking-mark.js"
+ },
"./ui/time-series-chart": {
"types": "./dist/ui/time-series-chart.d.ts",
"default": "./dist/ui/time-series-chart.js"
diff --git a/src/index.ts b/src/index.ts
index 2d6a55d8..0801b2fa 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -111,6 +111,8 @@ export * from "./ui/tabs.js";
export * from "./ui/textarea.js";
export * from "./ui/theme-provider.js";
export * from "./ui/theme-toggle.js";
+export * from "./ui/thinking-indicator.js";
+export * from "./ui/thinking-mark.js";
export * from "./ui/time-series-chart.js";
export * from "./ui/toast.js";
export * from "./ui/token-mosaic.js";
diff --git a/src/theme.css b/src/theme.css
index 04dd236f..d27c648b 100644
--- a/src/theme.css
+++ b/src/theme.css
@@ -69,6 +69,7 @@
--secondary-foreground: #2b2627;
--muted: #eceff1;
--muted-foreground: #5c5555;
+ --thinking-base: #77716a;
--accent: #c5d2de;
--accent-foreground: #2b2627;
--success: #c1d1be;
@@ -138,6 +139,7 @@
--secondary-foreground: #e4d5bc;
--muted: #1a1718;
--muted-foreground: #a99c86;
+ --thinking-base: #aaa39a;
--accent: #2d455c;
--accent-foreground: #e4d5bc;
--success: #425a3d;
@@ -238,6 +240,7 @@
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
+ --color-thinking-base: var(--thinking-base);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-success: var(--success);
@@ -533,3 +536,29 @@
outline-offset: 2px;
}
}
+
+/* Ridge-study motion: keep geometry and timing in the original 100 x 70 space. */
+@keyframes corbits-thinking-sweep {
+ 0% { transform: translateX(8px); opacity: 0; }
+ 8%, 86% { opacity: 1; }
+ 100% { transform: translateX(130px); opacity: 0; }
+}
+@keyframes corbits-thinking-rise {
+ 0% { transform: translateY(88px); }
+ 100% { transform: translateY(-30px); }
+}
+@keyframes corbits-thinking-wave {
+ 0% { transform: scale(.03); opacity: 0; }
+ 10% { opacity: 1; }
+ 75% { opacity: .9; }
+ 100% { transform: scale(1.35); opacity: 0; }
+}
+@media (prefers-reduced-motion: reduce) {
+ /* Static poses land the gradient mid-stroke. These offsets mirror the SVG
+ geometry in thinking-mark.tsx — change the rect/circle geometry there and
+ these poses have to be re-picked to match. */
+ .corbits-thinking-silk, .corbits-thinking-strata, .corbits-thinking-echo { animation: none; }
+ .corbits-thinking-silk { transform: translateX(44px); }
+ .corbits-thinking-strata { transform: translateY(30px); }
+ .corbits-thinking-echo { transform: scale(.6); }
+}
diff --git a/src/ui/thinking-indicator.test.tsx b/src/ui/thinking-indicator.test.tsx
new file mode 100644
index 00000000..dd4d521b
--- /dev/null
+++ b/src/ui/thinking-indicator.test.tsx
@@ -0,0 +1,37 @@
+import { describe, expect, test } from "bun:test";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { ThinkingIndicator } from "./thinking-indicator.js";
+import { ThinkingMark } from "./thinking-mark.js";
+
+describe("ThinkingIndicator", () => {
+ test("renders an accessible status with a decorative Silk mark", () => {
+ const html = renderToStaticMarkup(createElement(ThinkingIndicator));
+
+ expect(html).toContain('role="status"');
+ expect(html).toContain("Thinking...");
+ expect(html).toContain("aria-hidden=\"true\"");
+ expect(html).toContain("corbits-thinking-silk");
+ });
+
+ test("renders every motion variant", () => {
+ for (const variant of ["silk", "strata", "echo"] as const) {
+ const html = renderToStaticMarkup(createElement(ThinkingMark, { variant }));
+
+ expect(html).toContain(`corbits-thinking-${variant}`);
+ }
+ });
+
+ test("paused freezes every animated node", () => {
+ const html = renderToStaticMarkup(createElement(ThinkingMark, { paused: true }));
+
+ expect(html).toContain("animation-play-state:paused");
+ });
+
+ test("echo hides its second wave under reduced motion", () => {
+ const html = renderToStaticMarkup(createElement(ThinkingMark, { variant: "echo" }));
+
+ expect(html).toContain("motion-reduce:hidden");
+ });
+});
diff --git a/src/ui/thinking-indicator.tsx b/src/ui/thinking-indicator.tsx
new file mode 100644
index 00000000..1f22b367
--- /dev/null
+++ b/src/ui/thinking-indicator.tsx
@@ -0,0 +1,18 @@
+import { cn } from "../lib/utils.js";
+import { ThinkingMark, type ThinkingMarkProps } from "./thinking-mark.js";
+
+export interface ThinkingIndicatorProps extends ThinkingMarkProps {
+ /** Visible status text, also announced by assistive technology. */
+ label?: string;
+}
+
+/** A thinking status with Silk, Strata, or Echo motion. Defaults to Silk. */
+export function ThinkingIndicator({ label = "Thinking...", className, style, ...markProps }: ThinkingIndicatorProps) {
+ return (
+
+ {/* The mountain carries more visual weight below its SVG center. */}
+
+ {label}
+
+ );
+}
diff --git a/src/ui/thinking-mark.tsx b/src/ui/thinking-mark.tsx
new file mode 100644
index 00000000..f405f49e
--- /dev/null
+++ b/src/ui/thinking-mark.tsx
@@ -0,0 +1,69 @@
+import { useId, type CSSProperties } from "react";
+import { cn } from "../lib/utils.js";
+import { CORBITS_MARK_PATH } from "./corbits-mark.js";
+
+export type ThinkingVariant = "silk" | "strata" | "echo";
+
+export interface ThinkingMarkProps {
+ variant?: ThinkingVariant;
+ paused?: boolean;
+ className?: string;
+ style?: CSSProperties;
+}
+
+/** Decorative animated Corbits mark. Pair with visible text or ThinkingIndicator. */
+export function ThinkingMark({ variant = "silk", paused = false, className, style }: ThinkingMarkProps) {
+ const id = useId();
+ const gradient = `${id}-gradient`;
+ const mask = `${id}-mask`;
+ return (
+
+ );
+}
diff --git a/stories/primitives/thinking-indicator.stories.tsx b/stories/primitives/thinking-indicator.stories.tsx
new file mode 100644
index 00000000..e42f40b5
--- /dev/null
+++ b/stories/primitives/thinking-indicator.stories.tsx
@@ -0,0 +1,35 @@
+import { useState } from "react";
+import { ThinkingIndicator } from "../../src/ui/thinking-indicator.js";
+import { ThinkingMark, type ThinkingVariant } from "../../src/ui/thinking-mark.js";
+import { Button } from "../../src/ui/button.js";
+
+export default { title: "Primitives / Thinking indicator" };
+
+const variants: ThinkingVariant[] = ["silk", "strata", "echo"];
+
+export const AllVariants = () => {
+ const [paused, setPaused] = useState(false);
+ return (
+
+
+
+ {variants.map((variant) => (
+
+ ))}
+
+
+ );
+};
+
+export const Silk = () => ;
+export const Strata = () => ;
+export const Echo = () => ;
+export const CustomLabel = () => ;