Agent Loading States
Three thoughtful loading states for AI interfaces: shimmering status text, live agent progress, and cycling reasoning phrases.
Reasoning Text
reasoning-text.tsxShimmering reasoning copy with an ASCII loader and cascade, phrase-swap, or per-letter scramble transitions.
"use client";
import {
ReasoningText,
type ReasoningTextVariant,
} from "@/components/agents/loading-states/reasoning-text";
const EXAMPLES: {
label: string;
variant: ReasoningTextVariant;
phrases: string[];
}[] = [
{
label: "Cascade",
variant: "cascade",
phrases: [
"Thinking",
"Reading the request",
"Working through the details",
"Preparing the answer",
],
},
{
label: "Swap",
variant: "swap",
phrases: [
"Thinking",
"Reading the request",
"Working through the details",
"Preparing the answer",
],
},
{
label: "Scramble",
variant: "scramble",
phrases: ["Thinking", "Searching", "Reasoning", "Composing"],
},
];
export function ReasoningTextPreview() {
return (
<div className="grid w-full max-w-sm gap-7">
{EXAMPLES.map(({ label, variant, phrases }) => (
<div key={variant} className="grid gap-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
{label}
</span>
<ReasoningText
variant={variant}
phrases={phrases}
className="text-base"
/>
</div>
))}
</div>
);
}
"use client";
// beui.dev/components/agents/loading-states
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { Loader } from "@/components/motion/loader";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import {
TEXT_SHIMMER_CLASS_NAME,
TEXT_SHIMMER_KEYFRAMES,
textShimmerStyle,
} from "@/lib/text-shimmer";
import { cn } from "@/lib/utils";
const DEFAULT_PHRASES = [
"Thinking",
"Reading the context",
"Connecting the details",
"Forming a response",
];
const SCRAMBLE_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/";
const CASCADE_STAGGER = 0.025;
export type ReasoningTextVariant = "cascade" | "swap" | "scramble";
export interface ReasoningTextProps {
/** Phrases cycled through while the agent works. */
phrases?: string[];
/** Animation used when the active phrase changes. */
variant?: ReasoningTextVariant;
/** Milliseconds each phrase remains visible. */
interval?: number;
/** Seconds taken for one shimmer pass. */
shimmerDuration?: number;
/** Optional leading visual. Defaults to a terminal-style ASCII loader. */
indicator?: ReactNode;
className?: string;
}
type PhraseProps = {
phrase: string;
reduce: boolean;
shimmerDuration: number;
};
function CascadePhrase({
phrase,
reduce,
shimmerDuration,
}: PhraseProps) {
const text = `${phrase}…`;
if (reduce) {
return (
<span
className={cn(
"col-start-1 row-start-1 inline-block justify-self-start whitespace-pre",
TEXT_SHIMMER_CLASS_NAME,
)}
style={textShimmerStyle(shimmerDuration)}
>
{text}
</span>
);
}
return (
<AnimatePresence initial={false}>
<motion.span
key={phrase}
className="col-start-1 row-start-1 inline-block justify-self-start whitespace-pre"
initial="initial"
animate="animate"
exit="exit"
>
{text.split("").map((character, characterIndex) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the stable cascade slot identity.
key={characterIndex}
custom={characterIndex * CASCADE_STAGGER}
variants={{
initial: { opacity: 0, y: "100%" },
animate: (delay: number) => ({
opacity: 1,
y: "0%",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number) => ({
opacity: 0,
y: "-100%",
transition: {
duration: 0.14,
ease: EASE_OUT,
delay: delay * 0.45,
},
}),
}}
className={cn(
"inline-block whitespace-pre will-change-[opacity,transform]",
TEXT_SHIMMER_CLASS_NAME,
)}
style={textShimmerStyle(shimmerDuration)}
>
{character}
</motion.span>
))}
</motion.span>
</AnimatePresence>
);
}
function SwapPhrase({ phrase, reduce, shimmerDuration }: PhraseProps) {
return (
<AnimatePresence initial={false}>
<motion.span
key={phrase}
className={cn(
"col-start-1 row-start-1 inline-block justify-self-start whitespace-nowrap will-change-[opacity,transform]",
TEXT_SHIMMER_CLASS_NAME,
)}
style={textShimmerStyle(shimmerDuration)}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 3 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={{
duration: reduce ? 0.12 : 0.2,
ease: EASE_OUT,
}}
>
{phrase}…
</motion.span>
</AnimatePresence>
);
}
function ScramblePhrase({
phrase,
reduce,
shimmerDuration,
}: PhraseProps) {
const target = `${phrase}…`;
const [display, setDisplay] = useState(target);
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
if (reduce) {
setDisplay(target);
return;
}
const characters = target.split("");
const startedAt = performance.now();
const duration = Math.min(760, Math.max(420, characters.length * 32));
let frame = 0;
let lastUpdate = 0;
const animate = (now: number) => {
if (now - lastUpdate >= 40) {
lastUpdate = now;
const progress = Math.min((now - startedAt) / duration, 1);
const settled = Math.floor(progress * characters.length);
setDisplay(
characters
.map((character, characterIndex) => {
if (characterIndex < settled || character === " ") {
return character;
}
return SCRAMBLE_GLYPHS[
Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)
];
})
.join(""),
);
}
if (now - startedAt < duration) {
frame = requestAnimationFrame(animate);
} else {
setDisplay(target);
}
};
frame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(frame);
}, [reduce, target]);
return (
<span
className={cn(
"col-start-1 row-start-1 inline-block justify-self-start whitespace-pre font-mono tabular-nums",
TEXT_SHIMMER_CLASS_NAME,
)}
style={textShimmerStyle(shimmerDuration)}
>
{display}
</span>
);
}
export function ReasoningText({
phrases = DEFAULT_PHRASES,
variant = "cascade",
interval = 1800,
shimmerDuration = 2.2,
indicator,
className,
}: ReasoningTextProps) {
const reduce = useReducedMotion() ?? false;
const [index, setIndex] = useState(0);
const statusId = useId();
const safePhrases = phrases.length > 0 ? phrases : DEFAULT_PHRASES;
const phrase = safePhrases[index % safePhrases.length];
const longestPhrase = safePhrases.reduce((longest, current) =>
current.length > longest.length ? current : longest,
);
const phraseProps = { phrase, reduce, shimmerDuration };
useEffect(() => {
if (safePhrases.length < 2) return;
const timer = window.setInterval(() => {
setIndex((current) => (current + 1) % safePhrases.length);
}, Math.max(600, interval));
return () => window.clearInterval(timer);
}, [interval, safePhrases.length]);
return (
<>
<style>{TEXT_SHIMMER_KEYFRAMES}</style>
<span
role="status"
aria-live="polite"
aria-labelledby={statusId}
className={cn(
"inline-flex items-center gap-2 text-sm font-medium text-muted-foreground",
className,
)}
>
<span aria-hidden="true" className="inline-flex size-3 shrink-0 items-center justify-center">
{indicator ?? (
<Loader
variant="ascii-line"
size={14}
speed={0.8}
label="Reasoning"
/>
)}
</span>
<span aria-hidden="true" className="grid overflow-hidden text-left">
<span className="invisible col-start-1 row-start-1 whitespace-nowrap">
{longestPhrase}…
</span>
{variant === "cascade" ? (
<CascadePhrase {...phraseProps} />
) : variant === "scramble" ? (
<ScramblePhrase {...phraseProps} />
) : (
<SwapPhrase {...phraseProps} />
)}
</span>
<span id={statusId} className="sr-only">
{phrase}
</span>
</span>
</>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import type { CSSProperties } from "react";
export const TEXT_SHIMMER_KEYFRAMES =
"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}";
export const TEXT_SHIMMER_CLASS_NAME =
"bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]";
export function textShimmerStyle(duration: number): CSSProperties {
return {
animation: `beui-text-shimmer ${duration}s linear infinite`,
};
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/agents/loading-states
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { Loader } from "@/components/motion/loader";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import {
TEXT_SHIMMER_CLASS_NAME,
TEXT_SHIMMER_KEYFRAMES,
textShimmerStyle,
} from "@/lib/text-shimmer";
import { cn } from "@/lib/utils";
const DEFAULT_PHRASES = [
"Thinking",
"Reading the context",
"Connecting the details",
"Forming a response",
];
const SCRAMBLE_GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/";
const CASCADE_STAGGER = 0.025;
export type ReasoningTextVariant = "cascade" | "swap" | "scramble";
export interface ReasoningTextProps {
/** Phrases cycled through while the agent works. */
phrases?: string[];
/** Animation used when the active phrase changes. */
variant?: ReasoningTextVariant;
/** Milliseconds each phrase remains visible. */
interval?: number;
/** Seconds taken for one shimmer pass. */
shimmerDuration?: number;
/** Optional leading visual. Defaults to a terminal-style ASCII loader. */
indicator?: ReactNode;
className?: string;
}
type PhraseProps = {
phrase: string;
reduce: boolean;
shimmerDuration: number;
};
function CascadePhrase({
phrase,
reduce,
shimmerDuration,
}: PhraseProps) {
const text = `${phrase}…`;
if (reduce) {
return (
<span
className={cn(
"col-start-1 row-start-1 inline-block justify-self-start whitespace-pre",
TEXT_SHIMMER_CLASS_NAME,
)}
style={textShimmerStyle(shimmerDuration)}
>
{text}
</span>
);
}
return (
<AnimatePresence initial={false}>
<motion.span
key={phrase}
className="col-start-1 row-start-1 inline-block justify-self-start whitespace-pre"
initial="initial"
animate="animate"
exit="exit"
>
{text.split("").map((character, characterIndex) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the stable cascade slot identity.
key={characterIndex}
custom={characterIndex * CASCADE_STAGGER}
variants={{
initial: { opacity: 0, y: "100%" },
animate: (delay: number) => ({
opacity: 1,
y: "0%",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number) => ({
opacity: 0,
y: "-100%",
transition: {
duration: 0.14,
ease: EASE_OUT,
delay: delay * 0.45,
},
}),
}}
className={cn(
"inline-block whitespace-pre will-change-[opacity,transform]",
TEXT_SHIMMER_CLASS_NAME,
)}
style={textShimmerStyle(shimmerDuration)}
>
{character}
</motion.span>
))}
</motion.span>
</AnimatePresence>
);
}
function SwapPhrase({ phrase, reduce, shimmerDuration }: PhraseProps) {
return (
<AnimatePresence initial={false}>
<motion.span
key={phrase}
className={cn(
"col-start-1 row-start-1 inline-block justify-self-start whitespace-nowrap will-change-[opacity,transform]",
TEXT_SHIMMER_CLASS_NAME,
)}
style={textShimmerStyle(shimmerDuration)}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 3 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={{
duration: reduce ? 0.12 : 0.2,
ease: EASE_OUT,
}}
>
{phrase}…
</motion.span>
</AnimatePresence>
);
}
function ScramblePhrase({
phrase,
reduce,
shimmerDuration,
}: PhraseProps) {
const target = `${phrase}…`;
const [display, setDisplay] = useState(target);
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
if (reduce) {
setDisplay(target);
return;
}
const characters = target.split("");
const startedAt = performance.now();
const duration = Math.min(760, Math.max(420, characters.length * 32));
let frame = 0;
let lastUpdate = 0;
const animate = (now: number) => {
if (now - lastUpdate >= 40) {
lastUpdate = now;
const progress = Math.min((now - startedAt) / duration, 1);
const settled = Math.floor(progress * characters.length);
setDisplay(
characters
.map((character, characterIndex) => {
if (characterIndex < settled || character === " ") {
return character;
}
return SCRAMBLE_GLYPHS[
Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)
];
})
.join(""),
);
}
if (now - startedAt < duration) {
frame = requestAnimationFrame(animate);
} else {
setDisplay(target);
}
};
frame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(frame);
}, [reduce, target]);
return (
<span
className={cn(
"col-start-1 row-start-1 inline-block justify-self-start whitespace-pre font-mono tabular-nums",
TEXT_SHIMMER_CLASS_NAME,
)}
style={textShimmerStyle(shimmerDuration)}
>
{display}
</span>
);
}
export function ReasoningText({
phrases = DEFAULT_PHRASES,
variant = "cascade",
interval = 1800,
shimmerDuration = 2.2,
indicator,
className,
}: ReasoningTextProps) {
const reduce = useReducedMotion() ?? false;
const [index, setIndex] = useState(0);
const statusId = useId();
const safePhrases = phrases.length > 0 ? phrases : DEFAULT_PHRASES;
const phrase = safePhrases[index % safePhrases.length];
const longestPhrase = safePhrases.reduce((longest, current) =>
current.length > longest.length ? current : longest,
);
const phraseProps = { phrase, reduce, shimmerDuration };
useEffect(() => {
if (safePhrases.length < 2) return;
const timer = window.setInterval(() => {
setIndex((current) => (current + 1) % safePhrases.length);
}, Math.max(600, interval));
return () => window.clearInterval(timer);
}, [interval, safePhrases.length]);
return (
<>
<style>{TEXT_SHIMMER_KEYFRAMES}</style>
<span
role="status"
aria-live="polite"
aria-labelledby={statusId}
className={cn(
"inline-flex items-center gap-2 text-sm font-medium text-muted-foreground",
className,
)}
>
<span aria-hidden="true" className="inline-flex size-3 shrink-0 items-center justify-center">
{indicator ?? (
<Loader
variant="ascii-line"
size={14}
speed={0.8}
label="Reasoning"
/>
)}
</span>
<span aria-hidden="true" className="grid overflow-hidden text-left">
<span className="invisible col-start-1 row-start-1 whitespace-nowrap">
{longestPhrase}…
</span>
{variant === "cascade" ? (
<CascadePhrase {...phraseProps} />
) : variant === "scramble" ? (
<ScramblePhrase {...phraseProps} />
) : (
<SwapPhrase {...phraseProps} />
)}
</span>
<span id={statusId} className="sr-only">
{phrase}
</span>
</span>
</>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type LoaderVariant =
| "spinner"
| "dots"
| "bars"
| "dot-matrix"
| "dither"
| "ascii"
| "ascii-line"
| "ascii-braille"
| "ascii-blocks"
| "ascii-bounce"
| "morph"
| "comet"
| "scramble"
| "metaballs"
| "newton"
| "helix"
| "percent";
// Terminal-style frame sets — the loaders CLI AI agents cycle through.
const ASCII_SETS: Record<string, string[]> = {
ascii: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
"ascii-line": ["|", "/", "-", "\\"],
"ascii-braille": ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
"ascii-blocks": ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"],
"ascii-bounce": ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
};
export interface LoaderProps {
/** Which animation to render. */
variant?: LoaderVariant;
/** Base square size in px. Everything scales from this. */
size?: number;
/** Seconds per animation cycle. */
speed?: number;
/** Accessible label announced to screen readers. */
label?: string;
className?: string;
}
// Reduced motion keeps a calm opacity pulse and drops every transform.
const REDUCED = {
animate: { opacity: [1, 0.4, 1] },
transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },
};
export function Loader({
variant = "spinner",
size = 32,
speed = 1,
label = "Loading",
className,
}: LoaderProps) {
const reduce = useReducedMotion() ?? false;
return (
<span
role="status"
aria-label={label}
className={cn(
"inline-flex items-center justify-center text-foreground",
className,
)}
>
{variant === "spinner" && <Spinner size={size} speed={speed} reduce={reduce} />}
{variant === "dots" && <Dots size={size} speed={speed} reduce={reduce} />}
{variant === "bars" && <Bars size={size} speed={speed} reduce={reduce} />}
{variant === "dot-matrix" && (
<DotMatrix size={size} speed={speed} reduce={reduce} />
)}
{variant === "dither" && <Dither size={size} speed={speed} reduce={reduce} />}
{ASCII_SETS[variant] && (
<Ascii frames={ASCII_SETS[variant]} size={size} speed={speed} reduce={reduce} />
)}
{variant === "morph" && <Morph size={size} speed={speed} reduce={reduce} />}
{variant === "comet" && <Comet size={size} speed={speed} reduce={reduce} />}
{variant === "scramble" && (
<Scramble size={size} speed={speed} reduce={reduce} />
)}
{variant === "metaballs" && (
<Metaballs size={size} speed={speed} reduce={reduce} />
)}
{variant === "newton" && <Newton size={size} speed={speed} reduce={reduce} />}
{variant === "helix" && <Helix size={size} speed={speed} reduce={reduce} />}
{variant === "percent" && (
<Percent size={size} speed={speed} reduce={reduce} />
)}
<span className="sr-only">{label}</span>
</span>
);
}
interface PartProps {
size: number;
speed: number;
reduce: boolean;
}
function Spinner({ size, speed, reduce }: PartProps) {
const stroke = Math.max(2, size * 0.09);
const r = (size - stroke) / 2;
return (
<motion.svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
animate={reduce ? REDUCED.animate : { rotate: 360 }}
transition={
reduce
? REDUCED.transition
: { duration: speed, ease: "linear", repeat: Infinity }
}
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeOpacity={0.2}
strokeWidth={stroke}
/>
<path
d={`M ${size / 2} ${size / 2 - r} A ${r} ${r} 0 0 1 ${size / 2 + r} ${size / 2}`}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
/>
</motion.svg>
);
}
function Dots({ size, speed, reduce }: PartProps) {
const dot = size * 0.24;
return (
<span className="flex items-center" style={{ gap: size * 0.14 }}>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: dot, height: dot }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: { y: [0, -size * 0.3, 0], opacity: [0.5, 1, 0.5] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: i * speed * 0.16,
}}
/>
))}
</span>
);
}
function Ascii({
frames,
size,
speed,
reduce,
}: PartProps & { frames: string[] }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
// Reduced motion slows the cycle rather than stopping it — it's a glyph
// swap, not on-screen movement.
const step = ((reduce ? speed * 2.5 : speed) / frames.length) * 1000;
const id = setInterval(
() => setFrame((f) => (f + 1) % frames.length),
step,
);
return () => clearInterval(id);
}, [frames.length, speed, reduce]);
return (
<span
className="font-mono leading-none tabular-nums"
style={{ fontSize: size, lineHeight: 1 }}
>
{frames[frame % frames.length]}
</span>
);
}
// Each shape is sampled at the same number of points and emitted as an SVG
// path with identical command structure, so framer tweens the `d` attribute
// point-to-point — a real morph, not a snap. (clip-path polygon strings don't
// interpolate reliably in framer, which left the shapes broken.)
const MORPH_POINTS = 24;
function ngonRadius(ang: number, n: number, phase = 0) {
const seg = (2 * Math.PI) / n;
const a = ang - phase;
const local = (((a % seg) + seg) % seg) - seg / 2;
return Math.cos(Math.PI / n) / Math.cos(local);
}
function morphPath(radiusAt: (ang: number) => number) {
const parts: string[] = [];
for (let i = 0; i < MORPH_POINTS; i++) {
const ang = (i / MORPH_POINTS) * 2 * Math.PI - Math.PI / 2;
const r = Math.min(1.05, radiusAt(ang));
const x = (50 + Math.cos(ang) * 46 * r).toFixed(2);
const y = (50 + Math.sin(ang) * 46 * r).toFixed(2);
parts.push(`${i === 0 ? "M" : "L"}${x} ${y}`);
}
return `${parts.join(" ")} Z`;
}
const MORPH_PATHS = [
morphPath(() => 1), // circle
morphPath((a) => ngonRadius(a, 4, Math.PI / 4)), // square
morphPath((a) => ngonRadius(a, 3)), // triangle
morphPath((a) => ngonRadius(a, 6)), // hexagon
morphPath((a) => ngonRadius(a, 4)), // diamond
];
// Each shape appears twice in a row so it fully forms and HOLDS before the
// next morph. Even keyframe spacing then alternates hold / morph segments.
const MORPH_SEQ = [...MORPH_PATHS.flatMap((p) => [p, p]), MORPH_PATHS[0]];
// Rotation and scale only change across the morph segments, staying put on the
// holds, so a settled shape sits still.
const MORPH_ROT = [0, 0, 72, 72, 144, 144, 216, 216, 288, 288, 360];
const MORPH_SCALE = [1, 1, 0.88, 0.88, 1, 1, 0.88, 0.88, 1, 1, 1];
function Morph({ size, speed, reduce }: PartProps) {
return (
<svg width={size} height={size} viewBox="0 0 100 100" role="img">
<title>Loading</title>
<motion.path
fill="currentColor"
d={MORPH_PATHS[0]}
initial={false}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
animate={
reduce
? { opacity: [1, 0.4, 1] }
: { d: MORPH_SEQ, rotate: MORPH_ROT, scale: MORPH_SCALE }
}
transition={
reduce
? { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }
: { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }
}
/>
</svg>
);
}
const COMET_TRAIL = [0, 1, 2, 3, 4, 5];
function Comet({ size, speed, reduce }: PartProps) {
const head = size * 0.2;
const r = size / 2 - head / 2;
return (
<span className="relative" style={{ width: size, height: size }}>
<motion.span
className="absolute inset-0"
animate={reduce ? REDUCED.animate : { rotate: 360 }}
transition={
reduce
? REDUCED.transition
: { duration: speed, ease: "linear", repeat: Infinity }
}
>
{COMET_TRAIL.map((i) => {
const scale = 1 - i * 0.13;
const sz = head * scale;
return (
<span
key={i}
className="absolute top-1/2 left-1/2 rounded-full bg-current"
style={{
width: sz,
height: sz,
marginLeft: -sz / 2,
marginTop: -sz / 2,
opacity: 1 - i * 0.16,
transform: `rotate(${-i * 15}deg) translateY(${-r}px)`,
}}
/>
);
})}
</motion.span>
</span>
);
}
const SCRAMBLE_TARGET = "LOADING";
const SCRAMBLE_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@";
function Scramble({ size, speed, reduce }: PartProps) {
const [text, setText] = useState(SCRAMBLE_TARGET);
useEffect(() => {
if (reduce) {
setText(SCRAMBLE_TARGET);
return;
}
let tick = 0;
const total = SCRAMBLE_TARGET.length + 4;
const id = setInterval(
() => {
const reveal = tick % total;
let s = "";
for (let i = 0; i < SCRAMBLE_TARGET.length; i++) {
s +=
i < reveal
? SCRAMBLE_TARGET[i]
: SCRAMBLE_GLYPHS[
Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)
];
}
setText(s);
tick++;
},
(speed / SCRAMBLE_TARGET.length) * 1000 * 0.55,
);
return () => clearInterval(id);
}, [speed, reduce]);
return (
<span
className="font-mono font-medium tracking-[0.2em] tabular-nums"
style={{ fontSize: size * 0.42 }}
>
{text}
</span>
);
}
function Metaballs({ size, speed, reduce }: PartProps) {
const id = useId().replace(/:/g, "");
return (
<svg width={size} height={size} viewBox="0 0 100 100" role="img">
<title>Loading</title>
<defs>
<filter id={id}>
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="b" />
<feColorMatrix
in="b"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8"
/>
</filter>
</defs>
<g filter={`url(#${id})`} fill="currentColor">
<motion.circle
cy="50"
r="15"
initial={false}
animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [30, 70, 30] }}
transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}
cx={reduce ? 40 : 30}
/>
<motion.circle
cy="50"
r="15"
initial={false}
animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [70, 30, 70] }}
transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}
cx={reduce ? 60 : 70}
/>
</g>
</svg>
);
}
const NEWTON_BALLS = [0, 1, 2, 3, 4];
function Newton({ size, speed, reduce }: PartProps) {
const d = size * 0.2;
const out = d * 1.1;
// Only the end balls move: the left slides out and back on the first half,
// then the right on the second half — the impact appears to jump the three
// still middle balls. Pure horizontal slide, no swing, no strings.
const moves: Record<number, { x: number[]; times: number[] }> = {
0: { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] },
4: { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] },
};
return (
<span className="flex items-center justify-center" style={{ height: d }}>
{NEWTON_BALLS.map((i) => {
const move = moves[i];
return (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: d, height: d }}
animate={reduce || !move ? undefined : { x: move.x }}
transition={
reduce || !move
? undefined
: {
duration: speed * 1.5,
ease: EASE_IN_OUT,
repeat: Infinity,
times: move.times,
}
}
/>
);
})}
</span>
);
}
function Helix({ size, speed, reduce }: PartProps) {
const rows = 7;
const dot = size * 0.14;
const amp = size * 0.32;
return (
<span className="relative" style={{ width: size, height: size }}>
{Array.from({ length: rows }, (_, r) => {
const top = (r / (rows - 1)) * (size - dot);
const delay = (r / rows) * speed;
return (
<span key={`row-${top}`}>
<motion.span
className="absolute rounded-full bg-current"
style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: {
x: [amp, -amp, amp],
scale: [1, 0.5, 1],
opacity: [1, 0.45, 1],
}
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
<motion.span
className="absolute rounded-full bg-current"
style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: {
x: [-amp, amp, -amp],
scale: [0.5, 1, 0.5],
opacity: [0.45, 1, 0.45],
}
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
</span>
);
})}
</span>
);
}
function Percent({ size, speed, reduce }: PartProps) {
const [p, setP] = useState(0);
useEffect(() => {
const dur = (reduce ? speed * 2 : speed) * 1000;
const start = { t: 0 };
const tickMs = 40;
const id = setInterval(() => {
start.t += tickMs;
const next = Math.min(100, Math.round((start.t / dur) * 100));
setP(next);
if (next >= 100) start.t = 0;
}, tickMs);
return () => clearInterval(id);
}, [speed, reduce]);
return (
<span
className="flex flex-col items-center"
style={{ gap: size * 0.14, width: size * 1.4 }}
>
<span
className="font-mono font-medium tabular-nums"
style={{ fontSize: size * 0.42, lineHeight: 1 }}
>
{p}%
</span>
<span
className="w-full overflow-hidden rounded-full bg-current/15"
style={{ height: Math.max(3, size * 0.1) }}
>
<span
className="block h-full rounded-full bg-current"
style={{ width: `${p}%` }}
/>
</span>
</span>
);
}
function Bars({ size, speed, reduce }: PartProps) {
const bar = size * 0.16;
return (
<span className="flex items-center" style={{ gap: size * 0.1, height: size }}>
{[0, 1, 2, 3].map((i) => (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: bar, height: size, originY: 1 }}
animate={
reduce ? { opacity: [0.4, 1, 0.4] } : { scaleY: [0.3, 1, 0.3] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: i * speed * 0.12,
}}
/>
))}
</span>
);
}
function DotMatrix({ size, speed, reduce }: PartProps) {
const n = 3;
const gap = size * 0.14;
const dot = (size - gap * (n - 1)) / n;
const cells = Array.from({ length: n * n }, (_, idx) => idx);
return (
<span
className="grid"
style={{
gap,
gridTemplateColumns: `repeat(${n}, ${dot}px)`,
}}
>
{cells.map((idx) => {
const x = idx % n;
const y = Math.floor(idx / n);
// Diagonal wave: cells light in order of their distance from the corner.
const delay = ((x + y) / (2 * (n - 1))) * speed;
return (
<motion.span
key={idx}
className="rounded-full bg-current"
style={{ width: dot, height: dot }}
animate={
reduce
? { opacity: [0.3, 1, 0.3] }
: { opacity: [0.2, 1, 0.2], scale: [0.7, 1, 0.7] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
);
})}
</span>
);
}
// Ordered Bayer 4x4 matrix — the classic dithering threshold pattern. Cells
// light in this order, so the fill shimmers like a dissolving halftone.
const BAYER_4 = [
0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,
];
function Dither({ size, speed, reduce }: PartProps) {
const n = 4;
const gap = Math.max(1, size * 0.05);
const cell = (size - gap * (n - 1)) / n;
return (
<span
className="grid"
style={{ gap, gridTemplateColumns: `repeat(${n}, ${cell}px)` }}
>
{BAYER_4.map((order, idx) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: fixed matrix cells, order never changes
key={idx}
className="bg-current"
style={{ width: cell, height: cell }}
animate={reduce ? { opacity: [0.3, 1, 0.3] } : { opacity: [0.1, 1, 0.1] }}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: (order / BAYER_4.length) * speed,
}}
/>
))}
</span>
);
}
API Reference
phrases?string[]Phrases cycled through while the agent works.
[
"Thinking",
"Reading the context",
"Connecting the details",
"Forming a response",
]variant?"cascade" | "swap" | "scramble"Animation used when the active phrase changes.
cascadeinterval?numberMilliseconds each phrase remains visible.
1800shimmerDuration?numberSeconds taken for one shimmer pass.
2.2indicator?ReactNodeOptional leading visual. Defaults to a terminal-style ASCII loader.
—className?string—Thinking Shimmer
thinking-shimmer.tsxA quiet shimmer that keeps the agent's current status readable while work continues.
"use client";
import { ThinkingShimmer } from "@/components/agents/loading-states/thinking-shimmer";
export function ThinkingShimmerPreview() {
return (
<ThinkingShimmer className="text-lg" duration={1.8}>
Thinking…
</ThinkingShimmer>
);
}
// beui.dev/components/agents/loading-states
import type { ReactNode } from "react";
import { TextShimmer } from "@/components/motion/text-shimmer";
import { cn } from "@/lib/utils";
export interface ThinkingShimmerProps {
/** Loading message shown to the user. */
children?: ReactNode;
/** Seconds taken for one shimmer pass. */
duration?: number;
className?: string;
}
export function ThinkingShimmer({
children = "Thinking…",
duration = 1.8,
className,
}: ThinkingShimmerProps) {
return (
<TextShimmer
as="span"
duration={duration}
className={cn("font-medium", className)}
>
{children}
</TextShimmer>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
import type { CSSProperties } from "react";
export const TEXT_SHIMMER_KEYFRAMES =
"@keyframes beui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}";
export const TEXT_SHIMMER_CLASS_NAME =
"bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]";
export function textShimmerStyle(duration: number): CSSProperties {
return {
animation: `beui-text-shimmer ${duration}s linear infinite`,
};
}
Copy the source code
// beui.dev/components/agents/loading-states
import type { ReactNode } from "react";
import { TextShimmer } from "@/components/motion/text-shimmer";
import { cn } from "@/lib/utils";
export interface ThinkingShimmerProps {
/** Loading message shown to the user. */
children?: ReactNode;
/** Seconds taken for one shimmer pass. */
duration?: number;
className?: string;
}
export function ThinkingShimmer({
children = "Thinking…",
duration = 1.8,
className,
}: ThinkingShimmerProps) {
return (
<TextShimmer
as="span"
duration={duration}
className={cn("font-medium", className)}
>
{children}
</TextShimmer>
);
}
import { cn } from "@/lib/utils";
import type { ElementType, ReactNode } from "react";
import {
TEXT_SHIMMER_CLASS_NAME,
TEXT_SHIMMER_KEYFRAMES,
textShimmerStyle,
} from "@/lib/text-shimmer";
export interface TextShimmerProps {
children: ReactNode;
as?: ElementType;
duration?: number;
className?: string;
}
export function TextShimmer({ children, as: Comp = "span", duration = 2.5, className }: TextShimmerProps) {
return (
<>
<style>
{TEXT_SHIMMER_KEYFRAMES}
</style>
<Comp
style={textShimmerStyle(duration)}
className={cn(
"inline-block",
TEXT_SHIMMER_CLASS_NAME,
className,
)}
>
{children}
</Comp>
</>
);
}
API Reference
children?ReactNodeLoading message shown to the user.
Thinking…duration?numberSeconds taken for one shimmer pass.
1.8className?string—Agent Progress
agent-progress.tsxA compact activity glyph, action verb, and live tabular timer for longer-running agent work.
"use client";
import { AgentProgress } from "@/components/agents/loading-states/agent-progress";
export function AgentProgressPreview() {
return (
<AgentProgress
label="Churning"
initialSeconds={151.6}
className="text-base"
/>
);
}
"use client";
// beui.dev/components/agents/loading-states
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
const GRID_CELLS = [
{ id: "top-left", delay: 0 },
{ id: "top-center", delay: 0.14 },
{ id: "top-right", delay: 0.28 },
{ id: "middle-left", delay: 0.42 },
{ id: "middle-center", delay: 0.56 },
{ id: "middle-right", delay: 0.7 },
{ id: "bottom-left", delay: 0.84 },
{ id: "bottom-center", delay: 0.98 },
{ id: "bottom-right", delay: 1.12 },
];
export interface AgentProgressProps {
/** Verb describing the agent's current activity. */
label?: string;
/** Controlled elapsed time in seconds. */
elapsedSeconds?: number;
/** Starting time for the internal timer, in seconds. */
initialSeconds?: number;
/** Whether the internal timer should advance. Ignored when elapsedSeconds is provided. */
running?: boolean;
className?: string;
}
function formatElapsed(totalSeconds: number) {
const safeSeconds = Math.max(0, totalSeconds);
const minutes = Math.floor(safeSeconds / 60);
const seconds = (safeSeconds % 60).toFixed(1);
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
export function AgentProgress({
label = "Churning",
elapsedSeconds,
initialSeconds = 0,
running = true,
className,
}: AgentProgressProps) {
const reduce = useReducedMotion() ?? false;
const [internalSeconds, setInternalSeconds] = useState(initialSeconds);
useEffect(() => {
if (elapsedSeconds !== undefined || !running) return;
const startedAt = performance.now() - initialSeconds * 1000;
const timer = window.setInterval(() => {
setInternalSeconds((performance.now() - startedAt) / 1000);
}, 100);
return () => window.clearInterval(timer);
}, [elapsedSeconds, initialSeconds, running]);
const elapsed = elapsedSeconds ?? internalSeconds;
return (
<span
role="status"
aria-label={`${label}, in progress`}
className={cn(
"inline-flex items-center gap-3 font-mono text-sm text-muted-foreground",
className,
)}
>
<span
aria-hidden="true"
className="grid size-5 shrink-0 grid-cols-3 gap-[2px]"
>
{GRID_CELLS.map(({ id, delay }) => (
<motion.span
key={id}
className="rounded-[1px] bg-current"
animate={
reduce
? { opacity: [0.35, 0.8, 0.35] }
: {
opacity: [0.28, 1, 0.28],
scale: [0.72, 1, 0.72],
}
}
transition={{
duration: 1.55,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
))}
</span>
<span className="font-sans font-medium">{label}</span>
<span
aria-hidden="true"
className="tabular-nums text-muted-foreground/70"
>
{formatElapsed(elapsed)}
</span>
</span>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/agents/loading-states
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
const GRID_CELLS = [
{ id: "top-left", delay: 0 },
{ id: "top-center", delay: 0.14 },
{ id: "top-right", delay: 0.28 },
{ id: "middle-left", delay: 0.42 },
{ id: "middle-center", delay: 0.56 },
{ id: "middle-right", delay: 0.7 },
{ id: "bottom-left", delay: 0.84 },
{ id: "bottom-center", delay: 0.98 },
{ id: "bottom-right", delay: 1.12 },
];
export interface AgentProgressProps {
/** Verb describing the agent's current activity. */
label?: string;
/** Controlled elapsed time in seconds. */
elapsedSeconds?: number;
/** Starting time for the internal timer, in seconds. */
initialSeconds?: number;
/** Whether the internal timer should advance. Ignored when elapsedSeconds is provided. */
running?: boolean;
className?: string;
}
function formatElapsed(totalSeconds: number) {
const safeSeconds = Math.max(0, totalSeconds);
const minutes = Math.floor(safeSeconds / 60);
const seconds = (safeSeconds % 60).toFixed(1);
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
export function AgentProgress({
label = "Churning",
elapsedSeconds,
initialSeconds = 0,
running = true,
className,
}: AgentProgressProps) {
const reduce = useReducedMotion() ?? false;
const [internalSeconds, setInternalSeconds] = useState(initialSeconds);
useEffect(() => {
if (elapsedSeconds !== undefined || !running) return;
const startedAt = performance.now() - initialSeconds * 1000;
const timer = window.setInterval(() => {
setInternalSeconds((performance.now() - startedAt) / 1000);
}, 100);
return () => window.clearInterval(timer);
}, [elapsedSeconds, initialSeconds, running]);
const elapsed = elapsedSeconds ?? internalSeconds;
return (
<span
role="status"
aria-label={`${label}, in progress`}
className={cn(
"inline-flex items-center gap-3 font-mono text-sm text-muted-foreground",
className,
)}
>
<span
aria-hidden="true"
className="grid size-5 shrink-0 grid-cols-3 gap-[2px]"
>
{GRID_CELLS.map(({ id, delay }) => (
<motion.span
key={id}
className="rounded-[1px] bg-current"
animate={
reduce
? { opacity: [0.35, 0.8, 0.35] }
: {
opacity: [0.28, 1, 0.28],
scale: [0.72, 1, 0.72],
}
}
transition={{
duration: 1.55,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
))}
</span>
<span className="font-sans font-medium">{label}</span>
<span
aria-hidden="true"
className="tabular-nums text-muted-foreground/70"
>
{formatElapsed(elapsed)}
</span>
</span>
);
}
API Reference
label?stringVerb describing the agent's current activity.
ChurningelapsedSeconds?numberControlled elapsed time in seconds.
—initialSeconds?numberStarting time for the internal timer, in seconds.
0running?booleanWhether the internal timer should advance. Ignored when elapsedSeconds is provided.
trueclassName?string—Composition
Choose one loading primitive for the amount of progress information the agent can truthfully expose.
Message
└── MessageContent
├── ThinkingShimmer
├── AgentProgress
└── ReasoningTextNote: Agent Activity takes over once structured execution events exist. Todo List shows durable progress when a plan becomes available. Streaming Response replaces loading once answer content begins arriving.
How it works
An agent loading state should communicate ongoing work without inventing progress. Choose the smallest signal that matches what the system actually knows and keep its language stable while work continues.
Updated