Skip to main content

ASCII progress indicators

Written by on .

react
ascii
open source

I have always been fascinated by old terminal interfaces and wanted to bring some of their charm to the Glama chat UI. To indicate the progress of AI responses, I use ASCII progress indicators.

The animations are inspired by these two articles:

I transformed the concept into a React component.

Below is a collection of progress indicators:

𓃉𓃉𓃉
×
🕛
🌍
🌑
😐
💣

Each animation is a sequence of characters that rotate at a set interval. For example, the characters ⣾, ⣽, ⣻, ⢿, ⡿, ⣟, ⣯, ⣷ become

.

Here is the component code:

import { useEffect, useRef, useState } from 'react';

type ProgressIndicatorStyle = {
  frames: string[];
  interval: number;
};

const styles = {
  arrow: {
    frames: ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖'],
    interval: 100,
  },
  ball_wave: {
    frames: ['𓃉𓃉𓃉', '𓃉𓃉∘', '𓃉∘°', '∘°∘', '°∘𓃉', '∘𓃉𓃉'],
    interval: 100,
  },
  blocks1: {
    frames: ['░', '▒', '▓', '█'],
    interval: 100,
  },
  blocks2: {
    frames: ['▛','▜','▟','▙'],
    interval: 100,
  },
  cym: {
    frames: ['⊏', '⊓', '⊐', '⊔'],
    interval: 100,
  },
  dots1: {
    frames: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'],
    interval: 50,
  },
  dots2: {
    frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
    interval: 50,
  },
  dots3: {
    frames: ['⠋', '⠙', '⠚', '⠞', '⠖', '⠦', '⠴', '⠲', '⠳', '⠓'],
    interval: 50,
  },
  dots4: {
    frames: [
      '⠄', '⠆', '⠇', '⠋', '⠙', '⠸', '⠰', '⠠', '⠰', '⠸', '⠙', '⠋', '⠇', '⠆',
    ],
    interval: 50,
  },
  dots5: {
    frames: [
      '⠋', '⠙', '⠚', '⠒', '⠂', '⠂', '⠒', '⠲', '⠴', '⠦', '⠖', '⠒', '⠐', '⠐', '⠒', '⠓', '⠋',
    ],
    interval: 50,
  },
  dots6: {
    frames: [
      '⠁', '⠉', '⠙', '⠚', '⠒', '⠂', '⠂', '⠒', '⠲', '⠴', '⠤', '⠄', '⠄', '⠤', '⠴', '⠲', '⠒', '⠂', '⠂', '⠒', '⠚', '⠙', '⠉', '⠁',
    ],
    interval: 50,
  },
  dots7: {
    frames: [
      '⠈', '⠉', '⠋', '⠓', '⠒', '⠐', '⠐', '⠒', '⠖', '⠦', '⠤', '⠠', '⠠', '⠤', '⠦', '⠖', '⠒', '⠐', '⠐', '⠒', '⠓', '⠋', '⠉', '⠈',
    ],
    interval: 50,
  },
  dots8: {
    frames: [
      '⠁', '⠁', '⠉', '⠙', '⠚', '⠒', '⠂', '⠂', '⠒', '⠲', '⠴', '⠤', '⠄', '⠄', '⠤', '⠠', '⠠', '⠤', '⠦', '⠖', '⠒', '⠐', '⠐', '⠒', '⠓', '⠋', '⠉', '⠈', '⠈',
    ],
    interval: 50,
  },
  dots9: {
    frames: ['⢹', '⢺', '⢼', '⣸', '⣇', '⡧', '⡗', '⡏'],
    interval: 50,
  },
  dots10: {
    frames: ['⢄', '⢂', '⢁', '⡁', '⡈', '⡐', '⡠'],
    interval: 50,
  },
  dots11: {
    frames: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'],
    interval: 50,
  },
  emoji_blink: {
    frames: ['😐', '😐', '😐', '😐', '😐', '😐', '😐', '😐', '😐', '😐', '😑'],
    interval: 100,
  },
  emoji_bomb: {
    frames: [
      '💣   ', ' 💣  ', '  💣 ', '   💣', '   💣', '   💣', '   💣', '   💣', '   💥', '    ', '    ',
    ],
    interval: 100,
  },
  emoji_earth: {
    frames: ['🌍', '🌎', '🌏'],
    interval: 200,
  },
  emoji_hour: {
    frames: [
      '🕛', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚',
    ],
    interval: 100,
  },
  emoji_moon: {
    frames: ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'],
    interval: 200,
  },
  line: {
    frames: ['☰', '☱', '☳', '☷', '☶', '☴'],
    interval: 100,
  },
  old: {
    frames: ['—', '\\', '|', '/'],
    interval: 100,
  },
  x_plus: {
    frames: ['×', '+'],
    interval: 100,
  },
} satisfies Record<string, ProgressIndicatorStyle>;

const useInterval = (callback: () => void, delay: null | number) => {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay === null) {
      return undefined;
    }

    const id = setInterval(() => savedCallback.current(), delay);

    return () => {
      clearInterval(id);
    };
  }, [delay]);
};

export const ProgressIndicator = ({
  style,
}: {
  style: keyof typeof styles;
}) => {
  const { frames, interval } = styles[style];

  if (!style) {
    throw new Error('Invalid style index');
  }

  const [index, setIndex] = useState<number>(0);

  useInterval(() => {
    setIndex((index + 1) % frames.length);
  }, interval);

  return (
    <div
      style={{
        color: '#00d992',
        fontFamily: 'monospace',
        pointerEvents: 'none',
        textAlign: 'center',
        userSelect: 'none',
        whiteSpace: 'pre',
        width: '24px',
      }}
    >
      {frames[index]}
    </div>
  );
};