SpiralLoader

Render the spiral loader. Use size to control the square canvas and className for layout styling.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Sizes
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

SpiralLoader/SpiralLoader.tsx
import React, { useCallback, useRef, useState } from 'react';
import { Box, BoxProps, ElementProps } from '@mantine/core';
import { cx } from '../utils/cx';
import classes from './SpiralLoader.module.css';

const FAST_REPEATS = 4;
const SLOW_REPEATS = 2;
const FAST_DURATION = 0.5;
const SLOW_DURATION = 1;

export interface SpiralLoaderProps extends BoxProps, ElementProps<'div'> {
  /** Width and height in px, `16` by default */
  size?: number;
}

const SPIRAL_PATH =
  'M-12 6C-7.548 6 -5.264 2.284 -4.975 -1.012C-4.745 -3.639 -5.782 -6 -8 -6C-10.218 -6 -11.255 -3.639 -11.025 -1.012C-10.736 2.284 -8.452 6 -4 6C0.452 6 2.736 2.284 3.025 -1.012C3.255 -3.639 2.218 -6 0 -6C-2.218 -6 -3.255 -3.639 -3.025 -1.012C-2.736 2.284 -0.452 6 4 6C8.452 6 10.736 2.284 11.025 -1.012C11.255 -3.639 10.218 -6 8 -6C5.782 -6 4.748 -3.639 4.98 -1.012C5.272 2.284 7.557 6 12 6';

/**
 * Animated squiggle shown while the assistant is processing.
 * Alternates between a fast phase and a slow phase like the original Lottie animation.
 */
export function SpiralLoader({ size = 16, className, style, ...others }: SpiralLoaderProps) {
  const [phase, setPhase] = useState<'fast' | 'slow'>('fast');
  const repeatCountRef = useRef(0);

  const handleIteration = useCallback(
    (event: React.AnimationEvent<SVGGElement>) => {
      if (event.target !== event.currentTarget) {
        return;
      }
      repeatCountRef.current += 1;
      const limit = phase === 'fast' ? FAST_REPEATS : SLOW_REPEATS;
      if (repeatCountRef.current >= limit) {
        repeatCountRef.current = 0;
        setPhase((prev) => (prev === 'fast' ? 'slow' : 'fast'));
      }
    },
    [phase]
  );

  return (
    <Box
      className={cx(classes.root, className)}
      style={{
        width: size,
        height: size,
        '--ae-spiral-duration': `${phase === 'fast' ? FAST_DURATION : SLOW_DURATION}s`,
        ...style,
      }}
      {...others}
    >
      <svg viewBox="0 0 16 16" className={classes.svg} aria-hidden>
        <g className={classes.group} onAnimationIteration={handleIteration}>
          <g transform="translate(8 8)">
            <path d={SPIRAL_PATH} pathLength={100} className={classes.path} />
          </g>
        </g>
      </svg>
    </Box>
  );
}

SpiralLoader.displayName = 'SpiralLoader';
API reference
Prop
Type
Required
size
number
No
Width and height in px, `16` by default