shadimage
dominik-ui
uibraille-loadersource: ok · hit

Braille Loader

An animated braille loading indicator with wave, typewriter, shimmer, and pulse variants.

npx shadcn add "https://dominikkoch.dev/r/braille-loader.json"
free

Interaction

text-animation

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files2 · source cached 10/14/2026, 1:42:43 PM

components/ui/braille-loader.tsx
import type { CSSProperties } from "react";
import { getBrailleCharacters } from "@/lib/text-to-braille";
import { cn } from "@/lib/utils";

interface BrailleLoaderProps {
  text: string;
  className?: string;
  variant?: "wave" | "typewriter" | "shimmer" | "pulse";
  speed?: number;
}

const STEP_MS = 120;
const MIN_SPEED = 0.1;

const variantStyles: Record<
  NonNullable<BrailleLoaderProps["variant"]>,
  string
> = {
  wave: `@keyframes braille-wave {
    0%, 100% { opacity: 0.15; }
    12%, 25% { opacity: 1; }
    37% { opacity: 0.15; }
  }`,
  typewriter: `@keyframes braille-typewriter {
    0%, 10% { opacity: 0; }
    12% { opacity: 1; }
    80% { opacity: 1; }
    90%, 100% { opacity: 0; }
  }`,
  shimmer: `@keyframes braille-shimmer {
    0%, 100% { opacity: 0.15; }
    12%, 25% { opacity: 1; }
    37% { opacity: 0.4; }
    50% { opacity: 0.15; }
  }`,
  pulse: `@keyframes braille-pulse {
    0%, 100% { opacity: 0.15; }
    50% { opacity: 1; }
  }`,
};

function getAnimation(
  variant: NonNullable<BrailleLoaderProps["variant"]>,
  index: number,
  total: number,
  speed: number
): CSSProperties {
  const stepMs = STEP_MS / Math.max(speed, MIN_SPEED);
  const totalDuration = (total + 2) * stepMs;
  const delay = index * stepMs;

  if (variant === "pulse") {
    return {
      animation: `braille-pulse ${total * stepMs * 1.5}ms ease-in-out ${delay}ms infinite`,
    };
  }

  return {
    animation: `braille-${variant} ${totalDuration}ms ease-in-out ${delay}ms infinite`,
  };
}

export function BrailleLoader({
  text,
  className,
  variant = "wave",
  speed = 1,
}: BrailleLoaderProps) {
  const chars = getBrailleCharacters(text);

  return (
    <span
      aria-hidden="true"
      className={cn("inline-flex font-mono text-muted-foreground", className)}
    >
      <style>{variantStyles[variant]}</style>
      {chars.map(({ char, key }, i) => (
        <span key={key} style={getAnimation(variant, i, chars.length, speed)}>
          {char === " " ? "\u00A0" : char}
        </span>
      ))}
    </span>
  );
}
lib/text-to-braille.ts
const braille: Record<string, string> = {
  a: "⠁",
  b: "⠃",
  c: "⠉",
  d: "⠙",
  e: "⠑",
  f: "⠋",
  g: "⠛",
  h: "⠓",
  i: "⠊",
  j: "⠚",
  k: "⠅",
  l: "⠇",
  m: "⠍",
  n: "⠝",
  o: "⠕",
  p: "⠏",
  q: "⠟",
  r: "⠗",
  s: "⠎",
  t: "⠞",
  u: "⠥",
  v: "⠧",
  w: "⠺",
  x: "⠭",
  y: "⠽",
  z: "⠵",
  " ": " ",
  ".": "⠲",
  ",": "⠂",
  "?": "⠦",
  "!": "⠖",
};

export function textToBraille(text: string): string {
  return [...text]
    .map((char) => {
      const lower = char.toLowerCase();
      const value = braille[lower] ?? char;

      return char !== lower && braille[lower] ? `⠠${value}` : value;
    })
    .join("");
}

export function getBrailleCharacters(text: string) {
  const seen = new Map<string, number>();

  return [...textToBraille(text)].map((char) => {
    const occurrence = seen.get(char) ?? 0;
    seen.set(char, occurrence + 1);

    return {
      char,
      key: `${char}-${occurrence}`,
    };
  });
}

More from dominik-ui

Browse all components in this library →