Thinking Indicator
Morphing SVG for async status. Thinking pulses a spring-driven ring around a filled dot; done pops a spring check; error pops a spring cross. Reduced-motion zeros every duration out.
Custom
States
thinking-indicator.svelte
<script lang="ts">
import { ThinkingIndicator } from '$lib/components/ui/thinking-indicator';
let status = $state<'thinking' | 'done' | 'error'>('thinking');
</script>
<ThinkingIndicator {status} />Sizes
thinking-indicator.svelte
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
import { cn } from '$lib/utils.js';
export const thinkingIndicatorVariants = tv({
base: 'inline-flex items-center justify-center',
variants: {
size: {
sm: 'size-4',
default: 'size-5',
lg: 'size-6'
}
},
defaultVariants: {
size: 'default'
}
});
export type ThinkingIndicatorSize = VariantProps<typeof thinkingIndicatorVariants>['size'];
export type ThinkingStatus = 'thinking' | 'done' | 'error';
export type ThinkingIndicatorProps = {
status?: ThinkingStatus;
size?: ThinkingIndicatorSize;
class?: string;
ref?: SVGSVGElement | null;
};
</script>
<script lang="ts">
import { animate } from 'motion';
import { springs, prefersReducedMotion } from '$lib/motion';
let {
status = 'thinking',
size = 'default',
class: className,
ref = $bindable(null)
}: ThinkingIndicatorProps = $props();
// SVG path data for each state
// thinking: filled circle
// done: checkmark circle (ring + check inside)
// error: X circle
let circleRef = $state<SVGCircleElement | null>(null);
let checkRef = $state<SVGPathElement | null>(null);
let crossRef = $state<SVGPathElement | null>(null);
let pulseRef = $state<SVGCircleElement | null>(null);
// Pulse animation for thinking state
let pulseControls: ReturnType<typeof animate> | null = null;
$effect(() => {
if (!circleRef || !checkRef || !crossRef || !pulseRef) return;
const reduced = prefersReducedMotion();
const spring = reduced ? { duration: 0 } : springs.snappy;
const springGentle = reduced ? { duration: 0 } : springs.gentle;
// Stop any running pulse
pulseControls?.cancel();
if (status === 'thinking') {
// Show filled circle, hide check and cross
animate(circleRef as unknown as HTMLElement, { opacity: 1, scale: 1 }, spring);
animate(checkRef as unknown as HTMLElement, { opacity: 0, scale: 0.6 }, spring);
animate(crossRef as unknown as HTMLElement, { opacity: 0, scale: 0.6 }, spring);
// Pulse the ring
if (!reduced) {
pulseControls = animate(
pulseRef as unknown as HTMLElement,
{ opacity: [0, 0.5, 0], scale: [0.8, 1.5, 0.8] },
{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }
);
animate(pulseRef as unknown as HTMLElement, { opacity: 0.3 }, { duration: 0 });
} else {
animate(pulseRef, { opacity: 0 }, { duration: 0 });
}
} else if (status === 'done') {
pulseControls = animate(pulseRef as unknown as HTMLElement, { opacity: 0, scale: 1 }, spring);
animate(circleRef as unknown as HTMLElement, { opacity: 1, scale: 1 }, spring);
animate(crossRef as unknown as HTMLElement, { opacity: 0, scale: 0.6 }, spring);
// Pop in the check
animate(
checkRef as unknown as HTMLElement,
{ opacity: 1, scale: [0.5, 1.1, 1] },
springGentle
);
} else if (status === 'error') {
pulseControls = animate(pulseRef, { opacity: 0, scale: 1 }, spring);
animate(circleRef as unknown as HTMLElement, { opacity: 1, scale: 1 }, spring);
animate(checkRef as unknown as HTMLElement, { opacity: 0, scale: 0.6 }, spring);
// Pop in the cross
animate(crossRef, { opacity: 1, scale: [0.5, 1.1, 1] }, springGentle);
}
return () => {
pulseControls?.cancel();
};
});
</script>
<svg
bind:this={ref}
data-slot="thinking-indicator"
data-status={status}
viewBox="0 0 20 20"
fill="none"
aria-label={status === 'thinking' ? 'Thinking…' : status === 'done' ? 'Done' : 'Error'}
role="img"
class={cn(thinkingIndicatorVariants({ size }), className)}
>
<!-- Pulse ring (thinking state) -->
<circle
bind:this={pulseRef}
cx="10"
cy="10"
r="8"
stroke="currentColor"
stroke-width="1.5"
class={status === 'thinking' ? 'text-primary' : 'text-transparent'}
style="opacity: 0; transform-origin: center;"
/>
<!-- Main circle fill — changes color by status -->
<circle
bind:this={circleRef}
cx="10"
cy="10"
r="7"
class={cn(
status === 'thinking' && 'fill-primary',
status === 'done' && 'fill-success',
status === 'error' && 'fill-destructive'
)}
style="transform-origin: center;"
/>
<!-- Checkmark (done) -->
<path
bind:this={checkRef}
d="M6.5 10.5 L9 13 L13.5 7.5"
stroke="white"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
style="opacity: 0; transform-origin: center;"
/>
<!-- Cross (error) -->
<path
bind:this={crossRef}
d="M7 7 L13 13 M13 7 L7 13"
stroke="white"
stroke-width="1.75"
stroke-linecap="round"
style="opacity: 0; transform-origin: center;"
/>
</svg>
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
'thinking' | 'done' | 'error' | 'thinking' | Drives the morph. Thinking pulses a ring; done springs a check; error springs a cross. | |
'sm' | 'default' | 'lg' | 'default' | Matches the icon scale of adjacent text at 14px, 16px, and 20px. | |
string | — | Merged onto the root SVG via tailwind-merge. | |
SVGSVGElement | null | null | Two-way-bindable element reference. |