shadimage
baraile-loader
uibraille-loadersource: ok · refreshed

Braille Loader

Accessible braille-inspired loading indicator with 25 animation variants.

npx shadcn add "https://shadcn-braille-loader.vercel.app/r/braille-loader.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files2 · source cached 10/8/2026, 3:41:40 AM

registry/new-york/ui/braille-loader.tsx
"use client";

import * as React from "react";

import {
  type BrailleLoaderSpeed,
  type BrailleLoaderVariant,
  generateFrames,
  getVariantGridSize,
  normalizeVariant,
} from "@/lib/braille-loader";

type BrailleLoaderProps = React.ComponentProps<"div"> & {
  variant?: BrailleLoaderVariant;
  speed?: BrailleLoaderSpeed;
  label?: string;
  fontSize?: number;
};

const speedMultiplier: Record<BrailleLoaderSpeed, number> = {
  slow: 1.5,
  normal: 1,
  fast: 0.6,
};

function getPrefersReducedMotion() {
  return (
    typeof window !== "undefined" &&
    "matchMedia" in window &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

function usePrefersReducedMotion() {
  const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(getPrefersReducedMotion);

  React.useEffect(() => {
    if (!("matchMedia" in window)) return;

    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    const handleChange = () => setPrefersReducedMotion(mediaQuery.matches);

    handleChange();
    mediaQuery.addEventListener("change", handleChange);

    return () => {
      mediaQuery.removeEventListener("change", handleChange);
    };
  }, []);

  return prefersReducedMotion;
}

function BrailleLoader({
  variant = "breathe",
  speed = "normal",
  className,
  label = "Loading",
  fontSize = 28,
  style,
  ...props
}: BrailleLoaderProps) {
  const resolvedVariant = normalizeVariant(variant);
  const [width, height] = getVariantGridSize(resolvedVariant);
  const spanRef = React.useRef<HTMLSpanElement>(null);
  const prefersReducedMotion = usePrefersReducedMotion();

  const framesData = React.useMemo(() => {
    return generateFrames(resolvedVariant, width, height);
  }, [resolvedVariant, width, height]);

  React.useEffect(() => {
    if (prefersReducedMotion || !spanRef.current) return;

    const frames = framesData.frames;
    let frameIndex = 0;
    const baseInterval = framesData.interval;
    const interval = baseInterval * speedMultiplier[speed];

    const updateFrame = () => {
      if (spanRef.current) {
        spanRef.current.textContent = frames[frameIndex];
      }
      frameIndex = (frameIndex + 1) % frames.length;
    };

    updateFrame();
    const intervalId = setInterval(updateFrame, interval);

    return () => {
      clearInterval(intervalId);
    };
  }, [framesData, prefersReducedMotion, speed]);

  return (
    <div
      role="status"
      aria-live="polite"
      className={["inline-flex items-center text-current", className].filter(Boolean).join(" ")}
      style={style}
      {...props}
    >
      <span className="sr-only">{label}</span>
      <span
        ref={spanRef}
        aria-hidden="true"
        style={{
          fontFamily: "monospace",
          whiteSpace: "pre",
          fontSize: `${fontSize}px`,
          lineHeight: 1,
          letterSpacing: 0,
        }}
      >
        {framesData.frames[0]}
      </span>
    </div>
  );
}

export { BrailleLoader, type BrailleLoaderProps };
lib/braille-loader.ts
export const brailleLoaderVariants = [
  "breathe",
  "pulse",
  "orbit",
  "snake",
  "fill-sweep",
  "scan",
  "rain",
  "cascade",
  "checkerboard",
  "columns",
  "wave-rows",
  "diagonal-swipe",
  "sparkle",
  "helix",
  "braille",
  "reflected-ripple",
  "pendulum",
  "compress",
  "sort",
  "equalizer",
  "chase",
  "bars",
  "marquee",
  "typing",
  "spiral",
] as const;

export type BrailleLoaderVariant = (typeof brailleLoaderVariants)[number];
export type BrailleLoaderSpeed = "slow" | "normal" | "fast";

export const speedToDuration: Record<BrailleLoaderSpeed, number> = {
  slow: 3000,
  normal: 2400,
  fast: 1200,
};

const DOT_BITS = [
  [0x01, 0x08],
  [0x02, 0x10],
  [0x04, 0x20],
  [0x40, 0x80],
];

const BRAILLE_BASE = 0x2800;

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

export function seededRandom(seed: number): () => number {
  let s = seed;
  return () => {
    s = (s * 1664525 + 1013904223) & 0xffffffff;
    return (s >>> 0) / 0xffffffff;
  };
}

function smoothstep(t: number): number {
  return t * t * (3 - 2 * t);
}

function setDot(brailleChar: number, row: number, col: number): number {
  if (row < 0 || row > 3) return brailleChar;
  return brailleChar | DOT_BITS[row][col];
}

function createFieldBuffer(width: number): number[] {
  return Array.from({ length: width }, () => 0);
}

function fieldToString(field: number[]): string {
  return field.map((mask) => String.fromCharCode(BRAILLE_BASE + mask)).join("");
}

type VariantConfig = {
  totalFrames: number;
  interval: number;
  gridSize: [number, number];
  compute: (frame: number, totalFrames: number, width: number, height: number, context: PrecomputeContext) => number[];
};

type PrecomputeContext = {
  importance: number[];
  shuffled: number[];
  target: number[];
  colRandom: number[];
};

const contextCache = new Map<string, PrecomputeContext>();

export function getPrecomputeContext(width: number, height: number): PrecomputeContext {
  const key = `${width}x${height}`;
  let ctx = contextCache.get(key);
  if (!ctx) {
    const pixelCols = width * 2;
    const totalDots = pixelCols * height;

    const rand42 = seededRandom(42);
    const importance = Array.from({ length: totalDots }, () => rand42());

    const rand19 = seededRandom(19);
    const shuffled: number[] = [];
    const target: number[] = [];
    for (let i = 0; i < pixelCols; i++) {
      shuffled.push(rand19() * (height - 1));
      target.push((1 - i / (pixelCols - 1)) * (height - 1));
    }

    const rand123 = seededRandom(123);
    const colRandom: number[] = [];
    for (let pc = 0; pc < pixelCols; pc++) {
      colRandom.push(rand123());
    }

    ctx = {
      importance,
      shuffled,
      target,
      colRandom,
    };
    contextCache.set(key, ctx);
  }
  return ctx;
}

export const VARIANT_CONFIGS: Record<string, VariantConfig> = {
  pendulum: {
    totalFrames: 120,
    interval: 12,
    gridSize: [5, 4],

    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);

      const progress = frame / totalFrames;
      const pixelCols = width * 2;

      // ✅ fast natural swing
      const basePhase = progress * Math.PI * 8;

      // ✅ dynamic spatial sine (CRITICAL)
      const spread = Math.sin(progress * Math.PI) * 1.1;

      const threshold = 0.7;

      for (let pc = 0; pc < pixelCols; pc++) {
        // ⭐ sine exists INSIDE braille cell
        const swing = Math.sin(basePhase + pc * spread);

        const center = ((1 - swing) * (height - 1)) / 2;

        for (let row = 0; row < height; row++) {
          if (Math.abs(row - center) < threshold) {
            const charIdx = Math.floor(pc / 2);
            field[charIdx] = setDot(field[charIdx], row, pc % 2);
          }
        }
      }

      return field;
    },
  },
  compress: {
    totalFrames: 100,
    interval: 40,
    gridSize: [5, 5],
    compute: (frame, totalFrames, width, height, ctx) => {
      const progress = frame / totalFrames;
      const sieveThreshold = Math.max(0.1, 1 - progress * 1.2);
      const squeeze = Math.min(1, progress / 0.85);
      const activeWidth = Math.max(1, width * 2 * (1 - squeeze * 0.95));
      const field = createFieldBuffer(width);

      for (let pc = 0; pc < width * 2; pc++) {
        const mappedPc = (pc / (width * 2)) * activeWidth;
        if (mappedPc >= activeWidth) continue;
        const targetPc = Math.round(mappedPc);
        if (targetPc >= width * 2) continue;
        const charIdx = Math.floor(targetPc / 2);
        const dc = targetPc % 2;

        for (let row = 0; row < height; row++) {
          const importanceIdx = pc * height + row;
          if (ctx.importance[importanceIdx] < sieveThreshold) {
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }
      return field;
    },
  },

  sort: {
    totalFrames: 100,
    interval: 40,
    gridSize: [5, 6],

    compute: (frame, totalFrames, width, height, ctx) => {
      const progress = frame / totalFrames;
      const pixelCols = width * 2;

      const field = createFieldBuffer(width);

      // sorting front
      const cursor = progress * pixelCols;

      for (let pc = 0; pc < pixelCols; pc++) {
        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;

        let fillHeight;

        // =========================
        // ✅ SORTED REGION
        // =========================
        if (pc < cursor - 1) {
          fillHeight = ctx.target[pc];
        }

        // =========================
        // ✅ ACTIVE FRONT
        // =========================
        else if (Math.abs(pc - cursor) < 2) {
          const blend = 1 - Math.abs(pc - cursor) / 2;

          const ease = blend * blend * (3 - 2 * blend);

          fillHeight = ctx.shuffled[pc] + (ctx.target[pc] - ctx.shuffled[pc]) * ease;

          // flash = comparison
          if (blend > 0.7) {
            for (let r = 0; r < height; r++) {
              field[charIdx] = setDot(field[charIdx], r, dc);
            }
            continue;
          }
        }

        // =========================
        // ✅ UNSORTED REGION
        // =========================
        else {
          fillHeight = ctx.shuffled[pc] + Math.sin(progress * Math.PI * 12 + pc * 2.3) * 0.8;
        }

        fillHeight = Math.max(0, Math.min(height - 1, fillHeight));

        // ✅ IMPORTANT FIX:
        // cumulative stacking (NO ERASURE)
        for (let r = Math.floor(fillHeight); r < height; r++) {
          field[charIdx] = setDot(field[charIdx], r, dc);
        }
      }

      return field;
    },
  },

  breathe: {
    totalFrames: 40,
    interval: 40,
    gridSize: [1, 6],

    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const progress = frame / totalFrames;

      const alpha = 0.5 + 0.5 * Math.sin(progress * Math.PI * 2);

      // -----------------------------
      // loop index (changes ONLY after animation ends)
      // -----------------------------
      const loopIndex = Math.floor(frame / totalFrames);

      // -----------------------------
      // checker dots
      // -----------------------------
      const dots: { pc: number; row: number }[] = [];

      for (let pass = 0; pass < 2; pass++) {
        for (let pc = 0; pc < width * 2; pc++) {
          for (let row = 0; row < height; row++) {
            if ((pc + row) % 2 === pass) {
              dots.push({ pc, row });
            }
          }
        }
      }

      // -----------------------------
      // ✅ hole chosen once per loop
      // -----------------------------
      const rand = seededRandom(9001 + loopIndex);
      const holeIndex = Math.floor(rand() * dots.length);

      const maxDots = dots.length - 1;
      const activeDots = Math.floor(alpha * maxDots);

      let placed = 0;

      for (let i = 0; i < dots.length; i++) {
        if (i === holeIndex) continue;
        if (placed >= activeDots) break;

        const { pc, row } = dots[i];

        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;

        field[charIdx] = setDot(field[charIdx], row, dc);

        placed++;
      }

      return field;
    },
  },

  pulse: {
    totalFrames: 23,
    interval: 60,
    gridSize: [4, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const period = 900;
      const t = frame * 40;
      const scale = 1 + 0.06 * Math.sin((2 * Math.PI * t) / period);
      const field = createFieldBuffer(width);
      const centerX = (width * 2 - 1) / 2;
      const centerY = (height - 1) / 2;

      for (let pc = 0; pc < width * 2; pc++) {
        for (let row = 0; row < height; row++) {
          const dx = (pc - centerX) / scale;
          const dy = (row - centerY) / scale;
          const dist = Math.sqrt(dx * dx + dy * dy);
          const maxDist = Math.sqrt(centerX * centerX + centerY * centerY);
          const ringWidth = 0.8;
          const ringPos = ((Math.sin(((2 * Math.PI * t) / period) * 2) + 1) / 2) * maxDist;

          if (Math.abs(dist - ringPos) < ringWidth) {
            const charIdx = Math.floor(pc / 2);
            const dc = pc % 2;
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }
      return field;
    },
  },

  waveRows: {
    totalFrames: 20,
    interval: 40,
    gridSize: [4, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const progress = frame / totalFrames;
      const field = createFieldBuffer(width);
      const pixelCols = width * 2;

      const basePhase = progress * Math.PI * 2;
      const colPhaseStep = (Math.PI * 2) / Math.max(2, pixelCols);
      const bandWidth = 0.9;

      for (let pc = 0; pc < pixelCols; pc++) {
        const colWave = Math.sin(basePhase + pc * colPhaseStep);
        const centerRow = ((colWave + 1) / 2) * (height - 1);

        for (let row = 0; row < height; row++) {
          const dist = Math.abs(row - centerRow);
          if (dist <= bandWidth) {
            const charIdx = Math.floor(pc / 2);
            const dc = pc % 2;
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }
      return field;
    },
  },

  snake: {
    totalFrames: 25,
    interval: 80,
    gridSize: [2, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const pixelCols = width * 2;

      // Build serpentine path: left→right on even rows, right→left on odd rows
      const path: Array<{ pc: number; row: number }> = [];
      for (let row = 0; row < height; row++) {
        if (row % 2 === 0) {
          for (let pc = 0; pc < pixelCols; pc++) path.push({ pc, row });
        } else {
          for (let pc = pixelCols - 1; pc >= 0; pc--) path.push({ pc, row });
        }
      }

      const progress = frame / totalFrames;
      const trailingCells = 3;
      const headPos = Math.floor(progress * path.length) % path.length;
      const deadGap = 0;

      const drawPathDot = (idx: number) => {
        const { pc, row } = path[idx];
        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;
        field[charIdx] = setDot(field[charIdx], row, dc);
      };

      drawPathDot(headPos);

      for (let i = deadGap + 1; i < deadGap + 1 + trailingCells; i++) {
        const idx = (headPos - i + path.length) % path.length;
        drawPathDot(idx);
      }

      return field;
    },
  },

  orbit: {
    totalFrames: 40,
    interval: 50,
    gridSize: [4, 4],
    compute: (frame: number, totalFrames: number, width: number, height: number, _ctx: PrecomputeContext) => {
      const progress = frame / totalFrames;
      const field = createFieldBuffer(width);

      // Edge positions in clockwise order around one braille character
      const edgePositions: Array<{ row: number; dc: number }> = [
        { row: 0, dc: 0 }, // top-left
        { row: 0, dc: 1 }, // top-right
        { row: 1, dc: 1 }, // right-1
        { row: 2, dc: 1 }, // right-2
        { row: 3, dc: 1 }, // bottom-right
        { row: 3, dc: 0 }, // bottom-left
        { row: 2, dc: 0 }, // left-2
        { row: 1, dc: 0 }, // left-1
      ];

      // 3-dot trail moving clockwise
      const leadPos = Math.floor(progress * edgePositions.length) % edgePositions.length;
      const trailLength = 3;

      // Only use first character
      const charIdx = 0;

      for (let i = 0; i < trailLength; i++) {
        const idx = (leadPos - i + edgePositions.length) % edgePositions.length;
        const pos = edgePositions[idx];

        if (pos.row < height) {
          field[charIdx] = setDot(field[charIdx], pos.row, pos.dc);
        }
      }

      return field;
    },
  },

  rain: {
    totalFrames: 90,
    interval: 40,
    gridSize: [5, 5],
    compute: (frame, totalFrames, width, height, ctx) => {
      const field = createFieldBuffer(width);
      const pixelCols = width * 2;
      const t = frame * 40;
      let activeDrops = 0;

      for (let pc = 0; pc < pixelCols; pc++) {
        const rand = ctx.colRandom[pc] ?? 0;

        const period = 1200 + rand * 1000;
        const cyclePos = t / period + rand * 0.91 + pc * 0.07;
        const cycleIndex = Math.floor(cyclePos);
        const phase = cyclePos - cycleIndex;

        const seedA = cycleIndex * 173 + pc * 37 + Math.floor(rand * 1009);
        const noiseA = Math.sin(seedA * 12.9898) * 43758.5453;
        const rollA = noiseA - Math.floor(noiseA);

        const seedB = cycleIndex * 257 + pc * 61 + Math.floor(rand * 881);
        const noiseB = Math.sin(seedB * 78.233) * 12345.6789;
        const rollB = noiseB - Math.floor(noiseB);

        const seedC = cycleIndex * 97 + pc * 149 + Math.floor(rand * 733);
        const noiseC = Math.sin(seedC * 39.3467) * 31337.4242;
        const rollC = noiseC - Math.floor(noiseC);

        const missChance = 0.02 + rand * 0.08;
        if (rollA < missChance) continue;

        const spawnDelay = 0.0 + rollB * 0.2;
        const fallDuration = 0.48 + rollC * 0.42;
        const endPhase = spawnDelay + fallDuration;

        if (phase < spawnDelay || phase > endPhase) continue;

        const localPhase = (phase - spawnDelay) / fallDuration;
        const gravityCurve = 1.6 + rollC * 1.2;
        const accelerated = Math.pow(localPhase, gravityCurve);

        const midWeight = Math.max(0, 1 - Math.abs(localPhase - 0.5) * 2);
        const wobbleSeed = cycleIndex * 0.73 + pc * 1.31 + rand * 4.7;
        const midWobble = Math.sin(wobbleSeed + localPhase * Math.PI * 6) * 0.1 * midWeight;

        const y = Math.floor((accelerated + midWobble) * (height + 1)) - 1;
        if (y < 0 || y >= height) continue;

        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;
        field[charIdx] = setDot(field[charIdx], y, dc);
        activeDrops++;
      }

      if (activeDrops === 0) {
        const fallbackPos = (t / 1600) % 1;
        const fallbackPc = Math.floor(fallbackPos * pixelCols) % pixelCols;
        const fallbackPhase = fallbackPos * (height + 1);
        const fallbackY = clamp(Math.floor(fallbackPhase), 0, height - 1);
        const fallbackCharIdx = Math.floor(fallbackPc / 2);
        const fallbackDc = fallbackPc % 2;
        field[fallbackCharIdx] = setDot(field[fallbackCharIdx], fallbackY, fallbackDc);
      }

      return field;
    },
  },

  sparkle: {
    totalFrames: 60,
    interval: 40,
    gridSize: [5, 5],

    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);

      const pixelCols = width * 2;
      const drawableHeight = Math.min(height, 4);

      /* ---------------------------------- */
      /* HASH */
      /* ---------------------------------- */
      const hash = (x: number, y: number, t: number) => {
        let n = x * 374761393 + y * 668265263 + t * 1442695041;
        n = (n ^ (n >> 13)) * 1274126177;
        return ((n ^ (n >> 16)) >>> 0) / 4294967295;
      };

      /* ---------------------------------- */
      /* PARAMETERS */
      /* ---------------------------------- */
      const density = 0.095;
      const lifetime = 4;
      const phase = Math.floor(frame / 2);

      const regionSize = 2;

      for (let row = 0; row < drawableHeight; row++) {
        for (let col = 0; col < pixelCols; col++) {
          let v = hash(col, row, phase);

          /* ---------------------------------- */
          /* ⭐ EDGE COMPENSATION (NEW) */
          /* prevents center bias */
          /* ---------------------------------- */
          const edgeBias =
            0.12 * ((col === 0 || col === pixelCols - 1 ? 1 : 0) + (row === 0 || row === drawableHeight - 1 ? 1 : 0));

          v -= edgeBias;

          if (v > density) continue;

          /* ---------------------------------- */
          /* REGIONAL COMPETITION */
          /* ---------------------------------- */
          const rx = Math.floor(col / regionSize);
          const ry = Math.floor(row / regionSize);

          let winner = true;

          for (let oy = 0; oy < regionSize && winner; oy++) {
            for (let ox = 0; ox < regionSize; ox++) {
              const nx = rx * regionSize + ox;
              const ny = ry * regionSize + oy;

              if (nx === col && ny === row) continue;
              if (nx >= pixelCols || ny >= drawableHeight) continue;

              let nv = hash(nx, ny, phase);

              const nEdgeBias =
                0.12 * ((nx === 0 || nx === pixelCols - 1 ? 1 : 0) + (ny === 0 || ny === drawableHeight - 1 ? 1 : 0));

              nv -= nEdgeBias;

              if (nv < v) {
                winner = false;
                break;
              }
            }
          }

          if (!winner) continue;

          /* ---------------------------------- */
          /* LIFECYCLE */
          /* ---------------------------------- */
          const offset = Math.floor(hash(col, row, 999) * lifetime);

          const age = (frame + offset) % lifetime;

          if (age > 3) continue;

          /* ---------------------------------- */
          /* SHIMMER */
          /* ---------------------------------- */
          let r = row;
          let c = col;

          if (hash(col, row, 777) < 0.18 && age === 1) {
            const dirs = [
              [-1, -1],
              [-1, 0],
              [-1, 1],
              [0, -1],
              [0, 1],
              [1, -1],
              [1, 0],
              [1, 1],
            ];

            const d = dirs[Math.floor(hash(col, row, 555) * dirs.length)];

            r = clamp(r + d[0], 0, drawableHeight - 1);
            c = clamp(c + d[1], 0, pixelCols - 1);
          }

          const charIdx = Math.floor(c / 2);
          const dc = c % 2;

          field[charIdx] = setDot(field[charIdx], r, dc);
        }
      }

      return field;
    },
  },

  checkerboard: {
    totalFrames: 60,
    interval: 40,
    gridSize: [4, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const drawableHeight = Math.min(height, 4);
      const phaseFrames = Math.max(2, Math.floor(totalFrames / 8));
      const phase = Math.floor(frame / phaseFrames) % 2;
      const field = createFieldBuffer(width);

      for (let pc = 0; pc < width * 2; pc++) {
        for (let row = 0; row < drawableHeight; row++) {
          // True checkerboard: alternate both X (pc) and Y (row) positions
          if ((pc + row) % 2 === phase) {
            const charIdx = Math.floor(pc / 2);
            const dc = pc % 2;
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }
      return field;
    },
  },

  columns: {
    totalFrames: 48,
    interval: 40,
    gridSize: [4, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const pixelCols = width * 2;
      const stepsPerColumn = height + 1;
      const totalSteps = pixelCols * stepsPerColumn;

      const progress = frame / totalFrames;
      const step = Math.floor(progress * totalSteps) % totalSteps;
      const activePc = Math.floor(step / stepsPerColumn);
      const activeFill = step % stepsPerColumn;

      const fillColumnToTop = (pc: number) => {
        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;
        for (let row = 0; row < height; row++) {
          field[charIdx] = setDot(field[charIdx], row, dc);
        }
      };

      const fillColumnBottomUp = (pc: number, count: number) => {
        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;
        const dotsToFill = Math.max(0, Math.min(height, count));
        for (let i = 0; i < dotsToFill; i++) {
          const row = height - 1 - i;
          field[charIdx] = setDot(field[charIdx], row, dc);
        }
      };

      for (let pc = 0; pc < pixelCols; pc++) {
        if (pc < activePc) {
          fillColumnToTop(pc);
          continue;
        }

        if (pc === activePc) {
          fillColumnBottomUp(pc, activeFill);
        }
      }

      return field;
    },
  },

  cascade: {
    totalFrames: 60,
    interval: 40,
    gridSize: [5, 5],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const progress = frame / totalFrames;
      const field = createFieldBuffer(width);
      const leadingEdge = progress * 2;

      for (let pc = 0; pc < width * 2; pc++) {
        const normalizedX = pc / (width * 2);
        for (let row = 0; row < height; row++) {
          const normalizedY = row / height;
          const diagonalSum = normalizedX + normalizedY;
          const delta = Math.abs(diagonalSum - leadingEdge);

          if (delta < 0.2) {
            const charIdx = Math.floor(pc / 2);
            const dc = pc % 2;
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }
      return field;
    },
  },

  diagonalSwipe: {
    totalFrames: 60,
    interval: 30,
    gridSize: [3, 6],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const pixelCols = width * 2;

      const maxDiag = pixelCols - 1 + (height - 1);
      const cycleFrame = frame % totalFrames;
      const clearFrames = Math.max(2, Math.floor(totalFrames / 2));
      const fillFrames = Math.max(2, totalFrames - clearFrames);

      const clearPhase = cycleFrame < clearFrames;
      const localFrame = clearPhase ? cycleFrame : cycleFrame - clearFrames;
      const localTotal = (clearPhase ? clearFrames : fillFrames) - 1;
      const phaseProgress = localTotal > 0 ? localFrame / localTotal : 1;
      const sweepFront = phaseProgress * (maxDiag + 1);

      for (let pc = 0; pc < pixelCols; pc++) {
        for (let row = 0; row < height; row++) {
          const diag = pc + row;
          const show = clearPhase ? diag >= sweepFront : diag < sweepFront;
          if (show) {
            const charIdx = Math.floor(pc / 2);
            const dc = pc % 2;
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }
      return field;
    },
  },

  scan: {
    totalFrames: 60,
    interval: 40,
    gridSize: [4, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const period = 900;
      const t = frame * 40;
      const progress = (t / period) % 1;
      const field = createFieldBuffer(width);
      const scanX = progress * (width * 2 - 1);
      const sigma = 0.45;

      for (let pc = 0; pc < width * 2; pc++) {
        const dist = Math.abs(pc - scanX);
        const alpha = Math.exp(-(dist * dist) / (2 * sigma * sigma));
        if (alpha > 0.1) {
          const charIdx = Math.floor(pc / 2);
          const dc = pc % 2;
          for (let row = 0; row < height; row++) {
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }
      return field;
    },
  },

  fillSweep: {
    totalFrames: 80,
    interval: 60,
    gridSize: [4, 4],

    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);

      const framesPerStep = 2;

      const rawStep = frame / framesPerStep;
      const baseStep = Math.floor(rawStep);
      const phase = rawStep - baseStep;

      const maxFill = height;
      const cycle = maxFill * 2;

      const triangle = (s: number) => maxFill - Math.abs((s % cycle) - maxFill);

      const levelA = triangle(baseStep);
      const levelB = triangle(baseStep + 1);

      // ✅ temporal smoothing
      const fillLevel = phase < 0.5 ? levelA : levelB;

      const maxRow = height - 1;

      for (let i = 0; i < fillLevel; i++) {
        const row = maxRow - i;

        for (let charIdx = 0; charIdx < width; charIdx++) {
          field[charIdx] = setDot(field[charIdx], row, 0);
          field[charIdx] = setDot(field[charIdx], row, 1);
        }
      }

      return field;
    },
  },
  helix: {
    totalFrames: 64,
    interval: 40,
    gridSize: [5, 5],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const pixelCols = width * 2;
      const drawableHeight = Math.min(height, 4);
      if (drawableHeight < 2) return field;
      const maxRow = drawableHeight - 1;

      const progress = (frame % totalFrames) / Math.max(1, totalFrames - 1);
      const ramp = smoothstep(progress);
      const speedFactor = 0.7 + ramp * 0.7;
      const scrollSteps = progress * (pixelCols + 8) * speedFactor;
      const shiftA = Math.floor(scrollSteps);
      const shiftB = shiftA + 1;
      const shiftBlend = scrollSteps - shiftA;

      const levelScale = maxRow / 3;
      const mapLevel = (level: number) => clamp(Math.round(level * levelScale), 0, maxRow);

      const chainStates: Array<{ a: number; b: number; bridge: boolean }> = [
        { a: 0, b: 3, bridge: false },
        { a: 1, b: 2, bridge: true },
        { a: 2, b: 1, bridge: true },
        { a: 3, b: 0, bridge: false },
      ];

      const drawDot = (pc: number, row: number) => {
        if (pc < 0 || pc >= pixelCols || row < 0 || row >= drawableHeight) return;
        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;
        field[charIdx] = setDot(field[charIdx], row, dc);
      };

      for (let pc = 0; pc < pixelCols; pc++) {
        const idxA = (((pc + shiftA) % chainStates.length) + chainStates.length) % chainStates.length;
        const idxB = (((pc + shiftB) % chainStates.length) + chainStates.length) % chainStates.length;

        const columnPhase = (pc + 0.5) / pixelCols;
        const useB = columnPhase < shiftBlend;
        const state = useB ? chainStates[idxB] : chainStates[idxA];

        const rowA = mapLevel(state.a);
        const rowB = mapLevel(state.b);

        drawDot(pc, rowA);
        drawDot(pc, rowB);

        if (state.bridge) {
          const bridge = clamp(Math.round((rowA + rowB) / 2), 0, maxRow);
          drawDot(pc, bridge);
        }
      }

      return field;
    },
  },

  braille: {
    totalFrames: 60,
    interval: 40,
    gridSize: [4, 4],
    compute: (frame: number, totalFrames: number, width: number, height: number, _ctx: PrecomputeContext) => {
      const progress = frame / totalFrames;
      const field = createFieldBuffer(width);

      // Braille pattern positions within one character
      const braillePath: Array<{ row: number; dc: number }> = [
        { row: 0, dc: 0 }, // top-left
        { row: 0, dc: 1 }, // top-right
        { row: 1, dc: 1 }, // right-top
        { row: 2, dc: 1 }, // right-bottom
        { row: 3, dc: 1 }, // bottom-right
        { row: 3, dc: 0 }, // bottom-left
        { row: 2, dc: 0 }, // left-bottom
        { row: 1, dc: 0 }, // left-top
      ];

      // 2 moving dots (opposite positions) with 2-dot trails each
      const lead1Index = Math.floor(progress * braillePath.length) % braillePath.length;
      const lead2Index = Math.floor((progress + 0.5) * braillePath.length) % braillePath.length;

      // Only use first character
      const charIdx = 0;

      // First moving dot with 2-dot trail
      for (let i = 0; i < 2; i++) {
        const idx = (lead1Index - i + braillePath.length) % braillePath.length;
        const pos = braillePath[idx];
        if (pos.row < height) {
          field[charIdx] = setDot(field[charIdx], pos.row, pos.dc);
        }
      }

      // Second moving dot with 2-dot trail (opposite)
      for (let i = 0; i < 2; i++) {
        const idx = (lead2Index - i + braillePath.length) % braillePath.length;
        const pos = braillePath[idx];
        if (pos.row < height) {
          field[charIdx] = setDot(field[charIdx], pos.row, pos.dc);
        }
      }

      return field;
    },
  },

  phaseShift: {
    totalFrames: 60,
    interval: 40,
    gridSize: [5, 5],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const progress = totalFrames > 1 ? frame / (totalFrames - 1) : 0;
      const drawableHeight = Math.min(height, 4);
      const centerX = (width * 2 - 1) / 2;
      const centerY = (drawableHeight - 1) / 2;

      const phasePosition = (progress * 8) % 4;

      for (let pc = 0; pc < width * 2; pc++) {
        for (let row = 0; row < drawableHeight; row++) {
          const isLeft = pc < centerX;
          const isTop = row < centerY;

          let quadrantIndex = 0;
          if (isTop && isLeft) quadrantIndex = 0;
          else if (isTop && !isLeft) quadrantIndex = 1;
          else if (!isTop && !isLeft) quadrantIndex = 2;
          else quadrantIndex = 3;

          const phaseDeltaRaw = Math.abs(phasePosition - quadrantIndex);
          const phaseDelta = Math.min(phaseDeltaRaw, 4 - phaseDeltaRaw);

          const primary = Math.max(0, 1 - phaseDelta / 0.7);
          const secondary = Math.max(0, 1 - phaseDelta / 1.35) * 0.45;
          const intensity = primary + secondary;

          if (intensity > 0.42) {
            const charIdx = Math.floor(pc / 2);
            const dc = pc % 2;
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }
      return field;
    },
  },

  reflectedRipple: {
    totalFrames: 60,
    interval: 40,
    gridSize: [6, 6],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const progress = totalFrames > 1 ? frame / (totalFrames - 1) : 0;
      const drawableHeight = Math.min(height, 4);
      const centerX = (width * 2 - 1) / 2;
      const outward = progress < 0.5;
      const local = outward ? progress / 0.5 : (progress - 0.5) / 0.5;
      const localClamped = clamp(local, 0, 1);
      const easedLocal = smoothstep(localClamped) * 0.2 + localClamped * 0.8;
      const radius = outward ? easedLocal * centerX : (1 - easedLocal) * centerX;
      const ringThickness = 1.1;

      for (let pc = 0; pc < width * 2; pc++) {
        const distFromCenter = Math.abs(pc - centerX);
        const bandStrength = Math.max(0, 1 - Math.abs(distFromCenter - radius) / ringThickness);

        if (bandStrength > 0.3) {
          const charIdx = Math.floor(pc / 2);
          const dc = pc % 2;

          for (let row = 0; row < drawableHeight; row++) {
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }

      return field;
    },
  },

  equalizer: {
    totalFrames: 90,
    interval: 40,
    gridSize: [5, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const progress = frame / totalFrames;
      const pixelCols = width * 2;
      const t = progress * Math.PI * 2;

      for (let pc = 0; pc < pixelCols; pc++) {
        const seed = pc * 1.618033988749895;
        const freq1 = 3 + (seed % 3);
        const freq2 = 5 + ((seed * 1.3) % 4);
        const phase1 = seed * 0.7;
        const phase2 = seed * 1.3;
        const amp1 = 0.6 + ((seed * 0.17) % 0.4);
        const amp2 = 0.4 + ((seed * 0.23) % 0.3);

        const wave1 = Math.sin(t * freq1 + phase1) * amp1;
        const wave2 = Math.sin(t * freq2 + phase2) * amp2;
        const wave3 = Math.sin(t * 7 + seed) * 0.2;

        const combined = wave1 + wave2 + wave3;
        const normalized = (combined + amp1 + amp2 + 0.2) / (2 * (amp1 + amp2 + 0.2));
        const fillHeight = Math.max(0, Math.min(height, Math.floor(normalized * height)));

        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;

        for (let row = height - 1; row >= height - fillHeight; row--) {
          if (row >= 0 && row < height) {
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }

      return field;
    },
  },

  chase: {
    totalFrames: 48,
    interval: 60,
    gridSize: [5, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const progress = frame / totalFrames;
      const pixelCols = width * 2;
      const drawableHeight = Math.min(height, 4);
      const row = Math.floor((drawableHeight - 1) / 2);
      const head = Math.floor(progress * pixelCols) % pixelCols;

      for (let i = 0; i < 4; i++) {
        const pc = (head - i + pixelCols) % pixelCols;
        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;
        field[charIdx] = setDot(field[charIdx], row, dc);

        if (i === 0) {
          field[charIdx] = setDot(field[charIdx], Math.max(0, row - 1), dc);
          field[charIdx] = setDot(field[charIdx], Math.min(drawableHeight - 1, row + 1), dc);
        }
      }

      return field;
    },
  },

  bars: {
    totalFrames: 64,
    interval: 50,
    gridSize: [5, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const progress = frame / totalFrames;
      const pixelCols = width * 2;
      const drawableHeight = Math.min(height, 4);
      const phase = progress * Math.PI * 2;

      for (let pc = 0; pc < pixelCols; pc++) {
        const distanceFromCenter = Math.abs(pc - (pixelCols - 1) / 2) / Math.max(1, pixelCols / 2);
        const wave = (Math.sin(phase - distanceFromCenter * Math.PI * 1.7) + 1) / 2;
        const barHeight = clamp(Math.round(1 + wave * (drawableHeight - 1)), 1, drawableHeight);
        const charIdx = Math.floor(pc / 2);
        const dc = pc % 2;

        for (let i = 0; i < barHeight; i++) {
          const row = drawableHeight - 1 - i;
          field[charIdx] = setDot(field[charIdx], row, dc);
        }
      }

      return field;
    },
  },

  marquee: {
    totalFrames: 48,
    interval: 55,
    gridSize: [5, 4],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const pixelCols = width * 2;
      const drawableHeight = Math.min(height, 4);
      const offset = Math.floor((frame / totalFrames) * 8);

      for (let pc = 0; pc < pixelCols; pc++) {
        for (let row = 0; row < drawableHeight; row++) {
          const stripe = (pc + row + offset) % 4;
          if (stripe < 2) {
            const charIdx = Math.floor(pc / 2);
            field[charIdx] = setDot(field[charIdx], row, pc % 2);
          }
        }
      }

      return field;
    },
  },

  typing: {
    totalFrames: 80,
    interval: 50,
    gridSize: [4, 3],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const totalCells = width * height;

      const cycleLength = totalCells + 6;
      const currentCell = Math.floor((frame / totalFrames) * cycleLength * 2) % cycleLength;

      for (let cell = 0; cell < currentCell && cell < totalCells; cell++) {
        const charIdx = cell % width;
        const row = Math.floor(cell / width);
        field[charIdx] = setDot(field[charIdx], row, 0);
        field[charIdx] = setDot(field[charIdx], row, 1);
      }

      const cursorPos = Math.min(currentCell, totalCells - 1);
      const blinkOn = Math.floor(frame / 1) % 2 === 0;

      if (blinkOn && cursorPos >= 0) {
        const cursorCharIdx = cursorPos % width;
        const cursorRow = Math.floor(cursorPos / width);
        field[cursorCharIdx] = setDot(field[cursorCharIdx], cursorRow, 0);
        field[cursorCharIdx] = setDot(field[cursorCharIdx], cursorRow, 1);
      }

      return field;
    },
  },

  spiral: {
    totalFrames: 60,
    interval: 40,
    gridSize: [5, 5],
    compute: (frame, totalFrames, width, height, _ctx) => {
      const field = createFieldBuffer(width);
      const progress = frame / totalFrames;
      const pixelCols = width * 2;
      const drawableHeight = Math.min(height, 4);
      const centerX = (pixelCols - 1) / 2;
      const centerY = (drawableHeight - 1) / 2;

      const arms = 3;
      const armLength = Math.max(pixelCols, drawableHeight) / 2;
      const rotationOffset = progress * Math.PI * 4;

      for (let arm = 0; arm < arms; arm++) {
        const armAngle = (arm / arms) * Math.PI * 2 + rotationOffset;

        for (let r = 0; r < armLength; r++) {
          const t = r / armLength;
          const spiralAngle = armAngle + t * Math.PI * 1.5;
          const x = centerX + Math.cos(spiralAngle) * r * 0.8;
          const y = centerY + Math.sin(spiralAngle) * r * 0.8;

          const pc = Math.round(x);
          const row = Math.round(y);

          if (pc >= 0 && pc < pixelCols && row >= 0 && row < drawableHeight) {
            const charIdx = Math.floor(pc / 2);
            const dc = pc % 2;
            field[charIdx] = setDot(field[charIdx], row, dc);
          }
        }
      }

      return field;
    },
  },
};

function toCamelCase(str: string): string {
  return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}

const frameCache = new Map<string, string[]>();

export function generateFrames(variant: string, width: number, height: number): { frames: string[]; interval: number } {
  const key = `${variant}-${width}x${height}`;
  const cached = frameCache.get(key);
  if (cached) {
    return { frames: cached, interval: VARIANT_CONFIGS[toCamelCase(variant)]?.interval || 40 };
  }

  const config = VARIANT_CONFIGS[toCamelCase(variant)];
  if (!config) {
    return { frames: [fieldToString(createFieldBuffer(width))], interval: 40 };
  }

  const defaultArea = config.gridSize[0] * config.gridSize[1];
  const customArea = width * height;
  const scaleFactor = customArea / defaultArea;
  const clampedScale = Math.max(0.5, Math.min(3, scaleFactor));
  const scaledFrames = Math.round(config.totalFrames * clampedScale);
  const totalFrames = Math.max(30, scaledFrames);

  const context = getPrecomputeContext(width, height);
  const frames: string[] = [];

  for (let frame = 0; frame < totalFrames; frame++) {
    const field = config.compute(frame, totalFrames, width, height, context);
    frames.push(fieldToString(field));
  }

  frameCache.set(key, frames);
  return { frames, interval: config.interval };
}

export function getVariantGridSize(variant: string): [number, number] {
  const config = VARIANT_CONFIGS[toCamelCase(variant)];
  if (config?.gridSize) {
    return config.gridSize;
  }
  return [4, 4];
}

export function normalizeVariant(variant?: string): BrailleLoaderVariant {
  if (!variant) return "breathe";
  return brailleLoaderVariants.includes(variant as BrailleLoaderVariant) ? (variant as BrailleLoaderVariant) : "breathe";
}