Getting Started

Manual Setup Guide

Before you paste any individual loader component, add these shared runtime files once. After this setup, you can copy any loader source snippet from the gallery and use it directly.

components/ui/spinnerkit-core.tsx

"use client";

import type { CSSProperties } from "react";

import "@/components/spinnerkit-loader.css";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef } from "react";
import { useSpinnerkitPhases, usePrefersReducedMotion, useCyclePhase } from "./spinnerkit-hooks";

export type DotGridPattern = "diamond" | "full" | "outline" | "rose" | "cross" | "rings" | "circle";
export type DotShape = "circle" | "square" | "diamond" | "hearts";
export type SpinnerkitPhase = "idle" | "collapse" | "hoverRipple" | "loadingRipple";
export type SpinnerkitColorPreset =
  | "solid-theme"
  | "solid-mint"
  | "grad-sunset"
  | "grad-ocean"
  | "grad-neon"
  | "grad-aurora"
  | "grad-fire"
  | "grad-prism";

const SPINNERKIT_COLOR_PRESETS: Record<
  SpinnerkitColorPreset,
  {
    fill: string;
    glow: string;
  }
> = {
  "solid-theme": {
    fill: "var(--color-dot-on)",
    glow: "var(--color-dot-on)"
  },
  "solid-mint": {
    fill: "#34d399",
    glow: "#34d399"
  },
  "grad-sunset": {
    fill: "linear-gradient(135deg, #ff5f6d 0%, #ffc371 52%, #ffe29a 100%)",
    glow: "#ff8b73"
  },
  "grad-ocean": {
    fill: "linear-gradient(140deg, #00c6ff 0%, #0072ff 48%, #4facfe 100%)",
    glow: "#2f8fff"
  },
  "grad-neon": {
    fill: "linear-gradient(145deg, #b4ff39 0%, #39ffb6 46%, #00d4ff 100%)",
    glow: "#59ffc8"
  },
  "grad-aurora": {
    fill: "linear-gradient(145deg, #ff3cac 0%, #784ba0 45%, #2b86c5 100%)",
    glow: "#9c64bf"
  },
  "grad-fire": {
    fill: "linear-gradient(145deg, #ff512f 0%, #dd2476 45%, #ffb347 100%)",
    glow: "#f96a5f"
  },
  "grad-prism": {
    fill: "linear-gradient(145deg, #12c2e9 0%, #c471ed 45%, #f64f59 100%)",
    glow: "#9e7de8"
  }
};

export function resolveSkColorTokens(color: string, colorPreset?: SpinnerkitColorPreset): {
  resolvedColor: string;
  dotFill: string;
} {
  if (!colorPreset) {
    return { resolvedColor: color, dotFill: color };
  }

  const preset = SPINNERKIT_COLOR_PRESETS[colorPreset];
  if (!preset) {
    return { resolvedColor: color, dotFill: color };
  }

  return { resolvedColor: preset.glow, dotFill: preset.fill };
}

export interface SpinnerkitCommonProps {
  size?: number;
  dotSize?: number;
  color?: string;
  colorPreset?: SpinnerkitColorPreset;
  speed?: number;
  ariaLabel?: string;
  className?: string;
  pattern?: DotGridPattern;
  muted?: boolean;
  /**
   * Adds a glow on dots from opacity 0.6 (weakest) through 1 (strongest), after remapping.
   */
  bloom?: boolean;
  /** Uniform glow on every active dot (0…1); slightly wider falloff than selective `bloom`. */
  halo?: number;
  animated?: boolean;
  hoverAnimated?: boolean;
  dotClassName?: string;
  dotShape?: DotShape;
  opacityBase?: number;
  opacityMid?: number;
  opacityPeak?: number;
  cellPadding?: number;
  boxSize?: number;
  minSize?: number;
  /**
   * When true, overrides the loader's own dot choreography with a radar-style sweep computed
   * from each dot's real angle/radius around the shape's center — a genuine per-dot spin, not a
   * whole-frame CSS rotation. The active dot pattern/shape is unchanged.
   */
  spinVariant?: boolean;
}

export interface DotAnimationContext {
  index: number;
  row: number;
  col: number;
  distanceFromCenter: number;
  angleFromCenter: number;
  radiusNormalized: number;
  manhattanDistance: number;
  phase: SpinnerkitPhase;
  isActive: boolean;
  reducedMotion: boolean;
}

export interface DotAnimationState {
  className?: string;
  style?: CSSProperties;
}

export type DotAnimationResolver = (ctx: DotAnimationContext) => DotAnimationState;

export function cx(...values: Array<string | undefined | null | false>): string {
  return values.filter(Boolean).join(" ");
}

/**
 * Real per-dot "Spin" motion: a radar-style sweep computed from each dot's actual
 * polar angle/radius around the loader's own center — not a whole-frame rotation.
 * The sweep is given a slight radius-dependent twist so outer rings visibly lag
 * inner ones, reading as a spiral vortex rather than a rigid rotating disc.
 */
const SPIN_BASE_OPACITY = 0.08;
const SPIN_PEAK_OPACITY = 0.94;
const SPIN_BEAM_WIDTH = Math.PI / 2.4;
const SPIN_TWIST = Math.PI * 0.55;

function spinSmoothstep01(t: number): number {
  const clamped = Math.max(0, Math.min(1, t));
  return clamped * clamped * (3 - 2 * clamped);
}

/** Shortest signed distance between two angles, wrapped into [0, PI]. */
function spinAngularDistance(a: number, b: number): number {
  const twoPi = Math.PI * 2;
  let diff = (a - b) % twoPi;
  diff = ((diff % twoPi) + twoPi) % twoPi;
  return diff > Math.PI ? twoPi - diff : diff;
}

export function spinSweepOpacity(angleFromCenter: number, radiusNormalized: number, sweepAngle: number): number {
  const effectiveAngle = angleFromCenter + radiusNormalized * SPIN_TWIST;
  const distance = spinAngularDistance(effectiveAngle, sweepAngle);
  const glow = spinSmoothstep01(1 - distance / SPIN_BEAM_WIDTH);
  return SPIN_BASE_OPACITY + glow * (SPIN_PEAK_OPACITY - SPIN_BASE_OPACITY);
}

/** Shared by every sk-hex-* file — all ten use this exact row/col hexagon layout. */
const HEX_ROW_COUNTS_FOR_SPIN = [3, 4, 5, 4, 3] as const;
const HEX_SPIN_CENTER_ROW = 2;
const HEX_SPIN_MAX_RADIUS = Math.hypot(HEX_SPIN_CENTER_ROW, (HEX_ROW_COUNTS_FOR_SPIN[2] - 1) / 2);

function hexSpinCenterColForRow(row: number): number {
  return (HEX_ROW_COUNTS_FOR_SPIN[row]! - 1) / 2;
}

export function hexSpinOpacity(row: number, col: number, sweepAngle: number): number {
  const dy = row - HEX_SPIN_CENTER_ROW;
  const dx = col - hexSpinCenterColForRow(row);
  const angle = Math.atan2(dy, dx);
  const radiusNormalized = Math.min(1, Math.hypot(dy, dx) / HEX_SPIN_MAX_RADIUS);
  return spinSweepOpacity(angle, radiusNormalized, sweepAngle);
}

/** Shared by every sk-triangle-* file — all twenty use this 7×7 grid centered at (3,3). */
const TRIANGLE_SPIN_CENTER_ROW = 3;
const TRIANGLE_SPIN_CENTER_COL = 3;
const TRIANGLE_SPIN_MAX_RADIUS = Math.hypot(TRIANGLE_SPIN_CENTER_ROW, TRIANGLE_SPIN_CENTER_COL);

export function triangleSpinOpacity(row: number, col: number, sweepAngle: number): number {
  const dy = row - TRIANGLE_SPIN_CENTER_ROW;
  const dx = col - TRIANGLE_SPIN_CENTER_COL;
  const angle = Math.atan2(dy, dx);
  const radiusNormalized = Math.min(1, Math.hypot(dy, dx) / TRIANGLE_SPIN_MAX_RADIUS);
  return spinSweepOpacity(angle, radiusNormalized, sweepAngle);
}

export const GRID_SIZE = 5;
const CENTER = Math.floor(GRID_SIZE / 2);
const RANGE = Array.from({ length: GRID_SIZE }, (_, index) => index);
const MAX_RADIUS = Math.hypot(CENTER, CENTER);

export const FULL_INDEXES = RANGE.flatMap((row) => RANGE.map((col) => rowMajorIndex(row, col)));

export const DIAMOND_INDEXES = FULL_INDEXES.filter((index) => {
  const { row, col } = indexToCoord(index);
  return Math.abs(row - CENTER) + Math.abs(col - CENTER) <= 2;
});

export const OUTLINE_INDEXES = FULL_INDEXES.filter((index) => {
  const { row, col } = indexToCoord(index);
  return row === 0 || row === GRID_SIZE - 1 || col === 0 || col === GRID_SIZE - 1;
});

export const CROSS_INDEXES = FULL_INDEXES.filter((index) => {
  const { row, col } = indexToCoord(index);
  return row === CENTER || col === CENTER;
});

export const RINGS_INDEXES = FULL_INDEXES.filter((index) => {
  const { row, col } = indexToCoord(index);
  const radius = Math.hypot(row - CENTER, col - CENTER);
  return Math.round(radius) === 1 || Math.round(radius) === 2;
});

export const ROSE_INDEXES = FULL_INDEXES.filter((index) => {
  const { row, col } = indexToCoord(index);
  const dx = col - CENTER;
  const dy = row - CENTER;
  const angle = Math.atan2(dy, dx);
  const radius = Math.hypot(dx, dy);
  const rose = Math.abs(Math.sin(3 * angle));
  return rose > 0.6 && radius >= 1;
});

/** Full 5×5 grid minus the 4 corners — the `sk-circular-*` family's silhouette. */
export const CIRCLE_INDEXES = FULL_INDEXES.filter((index) => {
  const { row, col } = indexToCoord(index);
  const isCorner = (row === 0 || row === GRID_SIZE - 1) && (col === 0 || col === GRID_SIZE - 1);
  return !isCorner;
});

const PATTERN_INDEXES: Record<DotGridPattern, number[]> = {
  diamond: DIAMOND_INDEXES,
  full: FULL_INDEXES,
  outline: OUTLINE_INDEXES,
  rose: ROSE_INDEXES,
  cross: CROSS_INDEXES,
  rings: RINGS_INDEXES,
  circle: CIRCLE_INDEXES
};

export function getPatternIndexes(pattern: DotGridPattern = "diamond"): number[] {
  return PATTERN_INDEXES[pattern];
}

export function rowMajorIndex(row: number, col: number): number {
  return row * GRID_SIZE + col;
}

export function indexToCoord(index: number): { row: number; col: number } {
  return {
    row: Math.floor(index / GRID_SIZE),
    col: index % GRID_SIZE
  };
}

export function distanceFromCenter(index: number): number {
  const { row, col } = indexToCoord(index);
  return Math.hypot(row - CENTER, col - CENTER);
}

export function rowDistance(index: number): number {
  const { row } = indexToCoord(index);
  return Math.abs(row - CENTER);
}

export function polarAngle(index: number): number {
  const { row, col } = indexToCoord(index);
  return Math.atan2(row - CENTER, col - CENTER);
}

export function normalizedRadius(index: number): number {
  const { row, col } = indexToCoord(index);
  return Math.hypot(row - CENTER, col - CENTER) / MAX_RADIUS;
}

export function manhattanDistance(index: number): number {
  const { row, col } = indexToCoord(index);
  return Math.abs(row - CENTER) + Math.abs(col - CENTER);
}

export function harmonicPhase(row: number, col: number, a: number, b: number): number {
  return Math.sin((row + 1) * a + (col + 1) * b);
}

export function lissajousOffset(
  row: number,
  col: number,
  amplitude = 2.25
): { x: number; y: number; phase: number } {
  const x = Math.sin((row + 1) * 1.15 + (col + 1) * 2.2) * amplitude;
  const y = Math.cos((row + 1) * 2.45 + (col + 1) * 0.95) * amplitude;
  const phase = Math.abs(Math.sin((row + 1) * 0.7 + (col + 1) * 1.1));
  return { x, y, phase };
}

export function spiralOffset(
  angle: number,
  radiusNormalizedValue: number,
  amplitude = 2.8
): { x: number; y: number; phase: number } {
  const spin = angle + radiusNormalizedValue * Math.PI * 2.1;
  const radius = radiusNormalizedValue * amplitude;
  const x = Math.cos(spin) * radius;
  const y = Math.sin(spin) * radius;
  const phase = Math.abs(Math.sin(spin * 0.5));
  return { x, y, phase };
}

export function isPrime(value: number): boolean {
  if (value <= 1) {
    return false;
  }
  if (value === 2) {
    return true;
  }
  if (value % 2 === 0) {
    return false;
  }

  const limit = Math.floor(Math.sqrt(value));
  for (let divisor = 3; divisor <= limit; divisor += 2) {
    if (value % divisor === 0) {
      return false;
    }
  }

  return true;
}

const N = GRID_SIZE;
const C = Math.floor(GRID_SIZE / 2);
const CELLS = N * N;
const MAX_TRBL = (N - 1) * 2;

export function trBlPathNormFromIndex(index: number): number {
  const { row, col } = indexToCoord(index);
  return (row + (N - 1 - col)) / MAX_TRBL;
}

function buildSnakeOrderToIndexMap(): number[] {
  const pathOrder = new Array<number>(CELLS);
  const key = (row: number, col: number) => rowMajorIndex(row, col);
  let t = 0;
  for (let row = 0; row < N; row += 1) {
    if (row % 2 === 0) {
      for (let col = 0; col < N; col += 1) {
        pathOrder[key(row, col)] = t;
        t += 1;
      }
    } else {
      for (let col = N - 1; col >= 0; col -= 1) {
        pathOrder[key(row, col)] = t;
        t += 1;
      }
    }
  }
  return pathOrder;
}

const SNAKE_ORDER: readonly number[] = buildSnakeOrderToIndexMap();

export function snakePathNormFromIndex(index: number): number {
  return SNAKE_ORDER[index]! / (CELLS - 1);
}

export function snakePathOrderValue(index: number): number {
  return SNAKE_ORDER[index]!;
}

function buildSpiralInwardOrderToIndexMap(): number[] {
  const order = new Array<number>(CELLS);
  let top = 0;
  let bottom = N - 1;
  let left = 0;
  let right = N - 1;
  let t = 0;

  while (top <= bottom && left <= right) {
    for (let col = left; col <= right; col += 1) {
      order[rowMajorIndex(top, col)] = t;
      t += 1;
    }

    for (let row = top + 1; row <= bottom; row += 1) {
      order[rowMajorIndex(row, right)] = t;
      t += 1;
    }

    if (top < bottom) {
      for (let col = right - 1; col >= left; col -= 1) {
        order[rowMajorIndex(bottom, col)] = t;
        t += 1;
      }
    }

    if (left < right) {
      for (let row = bottom - 1; row > top; row -= 1) {
        order[rowMajorIndex(row, left)] = t;
        t += 1;
      }
    }

    top += 1;
    bottom -= 1;
    left += 1;
    right -= 1;
  }

  return order;
}

const SPIRAL_INWARD_ORDER: readonly number[] = buildSpiralInwardOrderToIndexMap();

export function spiralInwardNormFromIndex(index: number): number {
  return SPIRAL_INWARD_ORDER[index]! / (CELLS - 1);
}

export function spiralInwardOrderValue(index: number): number {
  return SPIRAL_INWARD_ORDER[index]!;
}

function buildOuterRingClockwiseOrderToIndexMap(): number[] {
  const order = new Array<number>(CELLS).fill(-1);
  const coords: Array<[number, number]> = [
    [0, 0],
    [0, 1],
    [0, 2],
    [0, 3],
    [0, 4],
    [1, 4],
    [2, 4],
    [3, 4],
    [4, 4],
    [4, 3],
    [4, 2],
    [4, 1],
    [4, 0],
    [3, 0],
    [2, 0],
    [1, 0]
  ];

  for (let t = 0; t < coords.length; t += 1) {
    const [row, col] = coords[t]!;
    order[rowMajorIndex(row, col)] = t;
  }

  return order;
}

function buildMiddleRingAntiClockwiseOrderToIndexMap(): number[] {
  const order = new Array<number>(CELLS).fill(-1);
  const coords: Array<[number, number]> = [
    [1, 1],
    [2, 1],
    [3, 1],
    [3, 2],
    [3, 3],
    [2, 3],
    [1, 3],
    [1, 2]
  ];

  for (let t = 0; t < coords.length; t += 1) {
    const [row, col] = coords[t]!;
    order[rowMajorIndex(row, col)] = t;
  }

  return order;
}

const OUTER_RING_CLOCKWISE_ORDER: readonly number[] = buildOuterRingClockwiseOrderToIndexMap();
const MIDDLE_RING_ANTI_CLOCKWISE_ORDER: readonly number[] = buildMiddleRingAntiClockwiseOrderToIndexMap();

export function outerRingClockwiseOrderValue(index: number): number {
  return OUTER_RING_CLOCKWISE_ORDER[index]!;
}

export function outerRingClockwiseNormFromIndex(index: number): number {
  const order = outerRingClockwiseOrderValue(index);
  return order >= 0 ? order / 15 : 0;
}

export function middleRingAntiClockwiseOrderValue(index: number): number {
  return MIDDLE_RING_ANTI_CLOCKWISE_ORDER[index]!;
}

export function middleRingAntiClockwiseNormFromIndex(index: number): number {
  const order = middleRingAntiClockwiseOrderValue(index);
  return order >= 0 ? order / 7 : 0;
}

function buildDiagonalSnakeOrderToIndexMap(): number[] {
  const order = new Array<number>(CELLS);
  let t = 0;

  for (let diagonal = 0; diagonal <= (N - 1) * 2; diagonal += 1) {
    const rowStart = Math.max(0, diagonal - (N - 1));
    const rowEnd = Math.min(N - 1, diagonal);

    if (diagonal % 2 === 0) {
      for (let row = rowEnd; row >= rowStart; row -= 1) {
        const col = diagonal - row;
        order[rowMajorIndex(row, col)] = t;
        t += 1;
      }
    } else {
      for (let row = rowStart; row <= rowEnd; row += 1) {
        const col = diagonal - row;
        order[rowMajorIndex(row, col)] = t;
        t += 1;
      }
    }
  }

  return order;
}

const DIAGONAL_SNAKE_ORDER: readonly number[] = buildDiagonalSnakeOrderToIndexMap();

export function diagonalSnakeOrderValue(index: number): number {
  return DIAGONAL_SNAKE_ORDER[index]!;
}

export function diagonalSnakeNormFromIndex(index: number): number {
  return DIAGONAL_SNAKE_ORDER[index]! / (CELLS - 1);
}

function buildRowWaveSnakeOrderToIndexMap(): number[] {
  const order = new Array<number>(CELLS);
  const route: Array<{ col: number; dir: "up" | "down" }> = [
    { col: 0, dir: "up" },
    { col: 2, dir: "down" },
    { col: 1, dir: "up" },
    { col: 3, dir: "down" },
    { col: 2, dir: "up" },
    { col: 4, dir: "down" }
  ];

  let t = 0;
  for (const step of route) {
    if (step.dir === "up") {
      for (let row = N - 1; row >= 0; row -= 1) {
        order[rowMajorIndex(row, step.col)] = t;
        t += 1;
      }
    } else {
      for (let row = 0; row < N; row += 1) {
        order[rowMajorIndex(row, step.col)] = t;
        t += 1;
      }
    }
  }

  return order;
}

const ROW_WAVE_SNAKE_ORDER: readonly number[] = buildRowWaveSnakeOrderToIndexMap();
const ROW_WAVE_SNAKE_MAX_ORDER = Math.max(...ROW_WAVE_SNAKE_ORDER);

export function rowWaveOrderValue(index: number): number {
  return ROW_WAVE_SNAKE_ORDER[index]!;
}

export function rowWaveNormFromIndex(index: number): number {
  return ROW_WAVE_SNAKE_MAX_ORDER > 0 ? rowWaveOrderValue(index) / ROW_WAVE_SNAKE_MAX_ORDER : 0;
}

export function colWaveNormFromIndex(index: number): number {
  const { col } = indexToCoord(index);
  return N > 1 ? col / (N - 1) : 0;
}

export function concentricRingNormFromIndex(index: number): number {
  const { row, col } = indexToCoord(index);
  return Math.max(Math.abs(row - C), Math.abs(col - C)) / C;
}

const CORNER_COORDS = new Set(["0,0", "0,4", "4,0", "4,4"]);

export function isWithinCircularMask(row: number, col: number): boolean {
  return !CORNER_COORDS.has(`${row},${col}`);
}

export function stylePx(n: number): string {
  // Round before stringifying so long irrational values (e.g. trig-computed positions) can't
  // serialize to a different number of significant digits on the server vs. the client.
  return `${Math.round(n * 1e3) / 1e3}px`;
}

export function styleOpacity(opacity: number): number {
  return Math.round(opacity * 1e6) / 1e6;
}

/** Same rounding as `styleOpacity`, for any other numeric CSS custom property (angle, radius, distance…). */
export function styleNumber(n: number): number {
  return Math.round(n * 1e6) / 1e6;
}

const SOURCE_BASE_OPACITY = 0.08;
const SOURCE_MID_OPACITY = 0.34;
const SOURCE_PEAK_OPACITY = 0.94;

function lerpSk(start: number, end: number, progress: number): number {
  return start + (end - start) * progress;
}

function normalizeProgressSk(value: number, start: number, end: number): number {
  const span = end - start;
  if (Math.abs(span) < Number.EPSILON) {
    return 0;
  }
  return Math.min(1, Math.max(0, (value - start) / span));
}

function coerceOpacitySk(value: number | undefined): number | undefined {
  if (value == null || !Number.isFinite(value)) {
    return undefined;
  }
  return Math.min(1, Math.max(0, value));
}

export function remapOpacityToTriplet(
  opacity: number,
  opacityBase: number | undefined,
  opacityMid: number | undefined,
  opacityPeak: number | undefined
): number {
  if (!Number.isFinite(opacity)) {
    return opacity;
  }

  const hasOverrides = opacityBase !== undefined || opacityMid !== undefined || opacityPeak !== undefined;
  const safeOpacity = Math.min(1, Math.max(0, opacity));
  if (!hasOverrides) {
    return safeOpacity;
  }

  const targetBase = coerceOpacitySk(opacityBase) ?? SOURCE_BASE_OPACITY;
  const targetMid = coerceOpacitySk(opacityMid) ?? SOURCE_MID_OPACITY;
  const targetPeak = coerceOpacitySk(opacityPeak) ?? SOURCE_PEAK_OPACITY;

  if (safeOpacity <= SOURCE_BASE_OPACITY) {
    const progress = normalizeProgressSk(safeOpacity, 0, SOURCE_BASE_OPACITY);
    return Math.min(1, Math.max(0, lerpSk(0, targetBase, progress)));
  }

  if (safeOpacity <= SOURCE_MID_OPACITY) {
    const progress = normalizeProgressSk(safeOpacity, SOURCE_BASE_OPACITY, SOURCE_MID_OPACITY);
    return Math.min(1, Math.max(0, lerpSk(targetBase, targetMid, progress)));
  }

  if (safeOpacity <= SOURCE_PEAK_OPACITY) {
    const progress = normalizeProgressSk(safeOpacity, SOURCE_MID_OPACITY, SOURCE_PEAK_OPACITY);
    return Math.min(1, Math.max(0, lerpSk(targetMid, targetPeak, progress)));
  }

  const progress = normalizeProgressSk(safeOpacity, SOURCE_PEAK_OPACITY, 1);
  return Math.min(1, Math.max(0, lerpSk(targetPeak, 1, progress)));
}

/** Remapped opacity where bloom begins (weakest glow); scales linearly to full bloom at 1. */
export const SK_BLOOM_OPACITY_MIN = 0.6;

export function opacityToBloomLevel(remappedOpacity: number): number {
  return Math.max(0, Math.min(1, (remappedOpacity - SK_BLOOM_OPACITY_MIN) / (1 - SK_BLOOM_OPACITY_MIN)));
}

export function remappedOpacityQualifiesForBloom(remappedOpacity: number): boolean {
  return remappedOpacity >= SK_BLOOM_OPACITY_MIN;
}

function clampHalo(value: number | undefined): number {
  if (value == null || !Number.isFinite(value)) {
    return 0;
  }
  return Math.min(1, Math.max(0, value));
}

export function skBloomRootActive(bloom: boolean, halo: number | undefined): boolean {
  return bloom || clampHalo(halo) > 0;
}

/** Root class when `halo` > 0 — CSS widens drop-shadow falloff for a softer, more diffuse glow. */
export function skBloomHaloSpreadClass(halo: number | undefined): "sk-bloom-halo" | false {
  return clampHalo(halo) > 0 ? "sk-bloom-halo" : false;
}

/**
 * Bloom level and dot class for one cell. `curveOpacity` is the loader’s logical opacity **before**
 * `remapOpacityToTriplet` (same as `bloom` uses today).
 */
export function skDotBloomParts(
  isActive: boolean,
  curveOpacity: number,
  bloom: boolean,
  halo: number | undefined,
  ob: number | undefined,
  om: number | undefined,
  op: number | undefined
): { level: number; bloomDot: boolean } {
  const haloN = clampHalo(halo);
  if (!isActive) {
    return { level: 0, bloomDot: false };
  }
  const remapped = remapOpacityToTriplet(curveOpacity, ob, om, op);
  const fromBloom = bloom ? opacityToBloomLevel(remapped) : 0;
  return {
    level: fromBloom,
    bloomDot: haloN > 0 || (bloom && remappedOpacityQualifiesForBloom(remapped))
  };
}

function getGrid5Layout(
  size: number,
  dotSize: number,
  cellPadding?: number
): { gap: number; gridSpan: number } {
  const n = GRID_SIZE;
  if (cellPadding != null) {
    const g = Math.max(0, cellPadding);
    const gridSpan = dotSize * n + g * (n - 1);
    return { gap: g, gridSpan };
  }
  const g = Math.max(1, Math.floor((size - dotSize * n) / (n - 1)));
  return { gap: g, gridSpan: size };
}

function resolveSkBoxOuterDim(
  options: { boxSize?: number; minSize?: number } | null | undefined
): { outerDim: number; useWrapper: boolean } {
  const b = options?.boxSize;
  const hasBox = b != null && b > 0 && Number.isFinite(b);
  if (!hasBox) {
    return { outerDim: 0, useWrapper: false };
  }
  const m = options?.minSize;
  if (m != null && m > 0 && Number.isFinite(m)) {
    return { outerDim: Math.max(b, m), useWrapper: true };
  }
  return { outerDim: b, useWrapper: true };
}

function clamp01Sk(n: number | undefined) {
  if (n == null) {
    return;
  }
  if (!Number.isFinite(n)) {
    return;
  }
  return Math.min(1, Math.max(0, n));
}

interface SpinnerkitBaseProps extends SpinnerkitCommonProps {
  phase: SpinnerkitPhase;
  reducedMotion?: boolean;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
  animationResolver?: DotAnimationResolver;
}

export function SpinnerkitBase({
  size = 24,
  dotSize = 3,
  color = "currentColor",
  colorPreset,
  speed = 1,
  ariaLabel = "Loading",
  className,
  pattern = "diamond",
  dotShape = "circle",
  muted = false,
  bloom = false,
  halo = 0,
  dotClassName,
  phase,
  reducedMotion = false,
  onMouseEnter,
  onMouseLeave,
  animationResolver,
  opacityBase,
  opacityMid,
  opacityPeak,
  cellPadding,
  boxSize,
  minSize,
  spinVariant = false
}: SpinnerkitBaseProps) {
  const patternIndexes = new Set(getPatternIndexes(pattern));
  const safeSpeed = speed > 0 ? speed : 1;
  const speedScale = 1 / safeSpeed;
  const spinPhase = useCyclePhase({
    active: spinVariant && !reducedMotion && phase !== "idle",
    cycleMsBase: 1800,
    speed
  });
  const sweepAngle = spinPhase * Math.PI * 2;
  const { gap, gridSpan } = getGrid5Layout(size, dotSize, cellPadding);
  const { outerDim, useWrapper } = resolveSkBoxOuterDim({ boxSize, minSize });
  const scale = useWrapper && gridSpan > 0 ? outerDim / gridSpan : 1;
  const center = Math.floor(GRID_SIZE / 2);
  const ob = clamp01Sk(opacityBase);
  const om = clamp01Sk(opacityMid);
  const op = clamp01Sk(opacityPeak);
  const unit = dotSize + gap;
  const { resolvedColor, dotFill } = resolveSkColorTokens(color, colorPreset);

  const skVarStyle = {
    width: gridSpan,
    height: gridSpan,
    "--sk-speed": speedScale,
    ["--sk-dot-size" as const]: `${dotSize}px`,
    ["--sk-halo-level" as const]: halo,
    ["--sk-dot-fill" as const]: dotFill,
    color: resolvedColor,
    ...(ob !== undefined && { ["--sk-opacity-base" as const]: ob }),
    ...(om !== undefined && { ["--sk-opacity-mid" as const]: om }),
    ...(op !== undefined && { ["--sk-opacity-peak" as const]: op }),
    ...(useWrapper
      ? {
        transform: `scale(${scale})`,
        transformOrigin: "center center" as const
      }
      : { minWidth: minSize, minHeight: minSize })
  } as unknown as CSSProperties;

  const dots = Array.from({ length: GRID_SIZE * GRID_SIZE }).map((_, index) => {
    const { row, col } = indexToCoord(index);
    const isActive = patternIndexes.has(index);
    const distance = distanceFromCenter(index);
    const angle = polarAngle(index);
    const radiusNormalizedValue = normalizedRadius(index);
    const manhattan = manhattanDistance(index);
    const deltaX = (col - center) * unit;
    const deltaY = (row - center) * unit;

    const animationState = spinVariant
      ? {
        style: {
          opacity: reducedMotion || phase === "idle"
            ? spinSweepOpacity(angle, radiusNormalizedValue, 0)
            : spinSweepOpacity(angle, radiusNormalizedValue, sweepAngle)
        }
      }
      : animationResolver
        ? animationResolver({
          index,
          row,
          col,
          distanceFromCenter: distance,
          angleFromCenter: angle,
          radiusNormalized: radiusNormalizedValue,
          manhattanDistance: manhattan,
          phase,
          isActive,
          reducedMotion
        })
        : {};

    const resolvedAnimationStyle = animationState.style ? { ...animationState.style } : undefined;
    let isBloomDot = false;
    let stylePatch: CSSProperties | undefined = resolvedAnimationStyle;

    if (isActive) {
      const rawOpacity = stylePatch?.opacity;
      if (stylePatch != null && typeof rawOpacity === "number") {
        const remappedOpacity = remapOpacityToTriplet(rawOpacity, ob, om, op);
        stylePatch = { ...stylePatch, opacity: styleOpacity(remappedOpacity) };
        const parts = skDotBloomParts(true, rawOpacity, bloom, halo, ob, om, op);
        (stylePatch as CSSProperties & { "--sk-bloom-level"?: number })["--sk-bloom-level"] = parts.level;
        isBloomDot = parts.bloomDot;
      } else {
        const parts = skDotBloomParts(true, 0, bloom, halo, ob, om, op);
        if (parts.level > 0) {
          stylePatch = {
            ...(stylePatch ?? {}),
            ["--sk-bloom-level" as const]: parts.level
          } as CSSProperties & { "--sk-bloom-level"?: number };
        }
        isBloomDot = parts.bloomDot;
      }
    }

    const dotStyle = {
      width: dotSize,
      height: dotSize,
      "--sk-distance": styleNumber(distance),
      "--sk-row": row,
      "--sk-col": col,
      "--sk-x": stylePx(deltaX),
      "--sk-y": stylePx(deltaY),
      "--sk-angle": styleNumber(angle),
      "--sk-radius": styleNumber(radiusNormalizedValue),
      "--sk-manhattan": manhattan,
      ...stylePatch,
      ...(!isActive
        ? {
          opacity: 0,
          visibility: "hidden" as const,
          pointerEvents: "none" as const,
          animation: "none"
        }
        : {})
    } as CSSProperties;

    return (
      <span
        key={index}
        aria-hidden="true"
        className={cx(
          "sk-dot",
          !isActive && "sk-inactive",
          isBloomDot && "sk-bloom-dot",
          dotClassName,
          animationState.className
        )}
        style={dotStyle}
      />
    );
  });

  const grid = (
    <div
      className={cx(
        "sk-root",
        `sk-dot-shape-${dotShape}`,
        muted && "sk-muted",
        skBloomRootActive(bloom, halo) && "sk-bloom",
        skBloomHaloSpreadClass(halo),
        !useWrapper && className
      )}
      style={skVarStyle}
    >
      <div className="sk-grid" style={{ gap }}>{dots}</div>
    </div>
  );

  if (useWrapper) {
    return (
      <div
        role="status"
        aria-live="polite"
        aria-label={ariaLabel}
        className={className}
        style={{
          display: "inline-flex",
          alignItems: "center",
          justifyContent: "center",
          width: outerDim,
          height: outerDim,
          minWidth: minSize,
          minHeight: minSize,
          overflow: "hidden"
        }}
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
      >
        {grid}
      </div>
    );
  }

  return (
    <div
      role="status"
      aria-live="polite"
      aria-label={ariaLabel}
      className={cx(
        "sk-root",
        `sk-dot-shape-${dotShape}`,
        muted && "sk-muted",
        skBloomRootActive(bloom, halo) && "sk-bloom",
        skBloomHaloSpreadClass(halo),
        className
      )}
      style={skVarStyle}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div className="sk-grid" style={{ gap }}>{dots}</div>
    </div>
  );
}

export const GRID_SIZE_3 = 3;

const CENTER_3 = Math.floor(GRID_SIZE_3 / 2);
const RANGE_3 = Array.from({ length: GRID_SIZE_3 }, (_, index) => index);
const MAX_RADIUS_3 = Math.hypot(CENTER_3, CENTER_3);

export const FULL_INDEXES_3 = RANGE_3.flatMap((row) =>
  RANGE_3.map((col) => rowMajorIndex3(row, col))
);

export const OUTLINE_INDEXES_3 = FULL_INDEXES_3.filter((index) => {
  const { row, col } = indexToCoord3(index);
  return row === 0 || row === GRID_SIZE_3 - 1 || col === 0 || col === GRID_SIZE_3 - 1;
});

export const DIAMOND_INDEXES_3 = FULL_INDEXES_3.filter((index) => {
  const { row, col } = indexToCoord3(index);
  return Math.abs(row - CENTER_3) + Math.abs(col - CENTER_3) <= 1;
});

export const CROSS_INDEXES_3 = FULL_INDEXES_3.filter((index) => {
  const { row, col } = indexToCoord3(index);
  return row === CENTER_3 || col === CENTER_3;
});

export const RINGS_INDEXES_3 = FULL_INDEXES_3.filter((index) => {
  const { row, col } = indexToCoord3(index);
  return Math.round(Math.hypot(row - CENTER_3, col - CENTER_3)) === 1;
});

export const ROSE_INDEXES_3 = FULL_INDEXES_3.filter((index) => {
  const { row, col } = indexToCoord3(index);
  const dx = col - CENTER_3;
  const dy = row - CENTER_3;
  const angle = Math.atan2(dy, dx);
  const radius = Math.hypot(dx, dy);
  const rose = Math.abs(Math.sin(3 * angle));
  return rose > 0.55 && radius >= 0.75;
});

const PATTERN_INDEXES_3: Record<DotGridPattern, number[]> = {
  diamond: DIAMOND_INDEXES_3,
  full: FULL_INDEXES_3,
  outline: OUTLINE_INDEXES_3,
  rose: ROSE_INDEXES_3,
  cross: CROSS_INDEXES_3,
  rings: RINGS_INDEXES_3,
  // No 3×3 loader has a circular silhouette; alias to full so the type stays exhaustive.
  circle: FULL_INDEXES_3
};

export function getPattern3Indexes(pattern: DotGridPattern = "full"): number[] {
  return PATTERN_INDEXES_3[pattern];
}

export function rowMajorIndex3(row: number, col: number): number {
  return row * GRID_SIZE_3 + col;
}

export function indexToCoord3(index: number): { row: number; col: number } {
  return {
    row: Math.floor(index / GRID_SIZE_3),
    col: index % GRID_SIZE_3
  };
}

export function distanceFromCenter3(index: number): number {
  const { row, col } = indexToCoord3(index);
  return Math.hypot(row - CENTER_3, col - CENTER_3);
}

export function manhattanDistance3(index: number): number {
  const { row, col } = indexToCoord3(index);
  return Math.abs(row - CENTER_3) + Math.abs(col - CENTER_3);
}

const MAX_DIAGONAL_3 = (GRID_SIZE_3 - 1) * 2;

export type DiagonalWave3Direction = "tr-bl" | "tl-br" | "br-tl" | "bl-tr";

export function trBlPath3NormFromIndex(index: number): number {
  const { row, col } = indexToCoord3(index);
  return (row + (GRID_SIZE_3 - 1 - col)) / MAX_DIAGONAL_3;
}

export function tlBrPath3NormFromIndex(index: number): number {
  const { row, col } = indexToCoord3(index);
  return (row + col) / MAX_DIAGONAL_3;
}

export function brTlPath3NormFromIndex(index: number): number {
  const { row, col } = indexToCoord3(index);
  return (MAX_DIAGONAL_3 - row - col) / MAX_DIAGONAL_3;
}

export function blTrPath3NormFromIndex(index: number): number {
  const { row, col } = indexToCoord3(index);
  return (MAX_DIAGONAL_3 - row - (GRID_SIZE_3 - 1 - col)) / MAX_DIAGONAL_3;
}

const DIAGONAL_PATH_3: Record<DiagonalWave3Direction, (index: number) => number> = {
  "tr-bl": trBlPath3NormFromIndex,
  "tl-br": tlBrPath3NormFromIndex,
  "br-tl": brTlPath3NormFromIndex,
  "bl-tr": blTrPath3NormFromIndex
};

export function diagonalWave3PathNormFromIndex(
  index: number,
  direction: DiagonalWave3Direction
): number {
  return DIAGONAL_PATH_3[direction](index);
}

export function diagonalWave3BandIndex(
  row: number,
  col: number,
  direction: DiagonalWave3Direction
): number {
  if (direction === "tr-bl" || direction === "bl-tr") {
    return row + (GRID_SIZE_3 - 1 - col);
  }
  return row + col;
}

function buildSpiralInwardOrderToIndexMap3(): number[] {
  const N = GRID_SIZE_3;
  const CELLS = N * N;
  const order = new Array<number>(CELLS);
  let top = 0;
  let bottom = N - 1;
  let left = 0;
  let right = N - 1;
  let t = 0;

  while (top <= bottom && left <= right) {
    for (let col = left; col <= right; col += 1) {
      order[rowMajorIndex3(top, col)] = t;
      t += 1;
    }

    for (let row = top + 1; row <= bottom; row += 1) {
      order[rowMajorIndex3(row, right)] = t;
      t += 1;
    }

    if (top < bottom) {
      for (let col = right - 1; col >= left; col -= 1) {
        order[rowMajorIndex3(bottom, col)] = t;
        t += 1;
      }
    }

    if (left < right) {
      for (let row = bottom - 1; row > top; row -= 1) {
        order[rowMajorIndex3(row, left)] = t;
        t += 1;
      }
    }

    top += 1;
    bottom -= 1;
    left += 1;
    right -= 1;
  }

  return order;
}

const SPIRAL_INWARD_ORDER_3: readonly number[] = buildSpiralInwardOrderToIndexMap3();

export function spiralInward3NormFromIndex(index: number): number {
  return SPIRAL_INWARD_ORDER_3[index]! / (GRID_SIZE_3 * GRID_SIZE_3 - 1);
}

export function spiralInward3OrderValue(index: number): number {
  return SPIRAL_INWARD_ORDER_3[index]!;
}

function buildOuterRingClockwiseOrder3(): number[] {
  const order = new Array<number>(GRID_SIZE_3 * GRID_SIZE_3).fill(-1);
  const path: ReadonlyArray<readonly [number, number]> = [
    [0, 0],
    [0, 1],
    [0, 2],
    [1, 2],
    [2, 2],
    [2, 1],
    [2, 0],
    [1, 0]
  ];

  path.forEach(([row, col], step) => {
    order[rowMajorIndex3(row, col)] = step;
  });

  return order;
}

const OUTER_RING_CLOCKWISE_ORDER_3: readonly number[] = buildOuterRingClockwiseOrder3();

export function outerRingClockwise3OrderValue(index: number): number {
  return OUTER_RING_CLOCKWISE_ORDER_3[index]!;
}

export function outerRingClockwise3NormFromIndex(index: number): number {
  const order = OUTER_RING_CLOCKWISE_ORDER_3[index]!;
  if (order < 0) {
    return 0;
  }
  return order / 7;
}

export function isCenterCell3(row: number, col: number): boolean {
  const center = Math.floor(GRID_SIZE_3 / 2);
  return row === center && col === center;
}

export function rowWave3NormFromRow(row: number): number {
  return row / (GRID_SIZE_3 - 1);
}

export function colWave3NormFromCol(col: number): number {
  return col / (GRID_SIZE_3 - 1);
}

export function colWave3NormFromColReverse(col: number): number {
  return (GRID_SIZE_3 - 1 - col) / (GRID_SIZE_3 - 1);
}

export function wave3PathOpacityFromNorm(
  norm: number,
  base = 0.06,
  mid = 0.38,
  peak = 0.88
): number {
  const t = Math.min(1, Math.max(0, norm));
  if (t <= 0.5) {
    return base + (t / 0.5) * (mid - base);
  }
  return mid + ((t - 0.5) / 0.5) * (peak - mid);
}

function getGrid3Layout(
  size: number,
  dotSize: number,
  cellPadding?: number
): { gap: number; gridSpan: number } {
  const n = GRID_SIZE_3;
  if (cellPadding != null) {
    const g = Math.max(0, cellPadding);
    const gridSpan = dotSize * n + g * (n - 1);
    return { gap: g, gridSpan };
  }
  const g = Math.max(0, Math.floor((size - dotSize * n) / (n - 1)));
  return { gap: g, gridSpan: size };
}

interface Spinnerkit3BaseProps extends SpinnerkitCommonProps {
  phase: SpinnerkitPhase;
  reducedMotion?: boolean;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
  animationResolver?: DotAnimationResolver;
}

export function Spinnerkit3Base({
  size = 24,
  dotSize = 3,
  color = "currentColor",
  colorPreset,
  speed = 1,
  ariaLabel = "Loading",
  className,
  pattern = "full",
  dotShape = "circle",
  muted = false,
  bloom = false,
  halo = 0,
  dotClassName,
  phase,
  reducedMotion = false,
  onMouseEnter,
  onMouseLeave,
  animationResolver,
  opacityBase = 0.06,
  opacityMid,
  opacityPeak,
  cellPadding = 1,
  boxSize,
  minSize,
  spinVariant = false
}: Spinnerkit3BaseProps) {
  const patternIndexes = new Set(getPattern3Indexes(pattern));
  const safeSpeed = speed > 0 ? speed : 1;
  const speedScale = 1 / safeSpeed;
  const spinPhase = useCyclePhase({
    active: spinVariant && !reducedMotion && phase !== "idle",
    cycleMsBase: 1800,
    speed
  });
  const sweepAngle = spinPhase * Math.PI * 2;
  const { gap, gridSpan } = getGrid3Layout(size, dotSize, cellPadding);
  const { outerDim, useWrapper } = resolveSkBoxOuterDim({ boxSize, minSize });
  const scale = useWrapper && gridSpan > 0 ? outerDim / gridSpan : 1;
  const center = CENTER_3;
  const ob = clamp01Sk(opacityBase);
  const om = clamp01Sk(opacityMid);
  const op = clamp01Sk(opacityPeak);
  const unit = dotSize + gap;
  const { resolvedColor, dotFill } = resolveSkColorTokens(color, colorPreset);

  const skVarStyle = {
    width: gridSpan,
    height: gridSpan,
    "--sk-speed": speedScale,
    ["--sk-dot-size" as const]: `${dotSize}px`,
    ["--sk-halo-level" as const]: halo,
    ["--sk-dot-fill" as const]: dotFill,
    color: resolvedColor,
    ...(ob !== undefined && { ["--sk-opacity-base" as const]: ob }),
    ...(om !== undefined && { ["--sk-opacity-mid" as const]: om }),
    ...(op !== undefined && { ["--sk-opacity-peak" as const]: op }),
    ...(useWrapper
      ? {
        transform: `scale(${scale})`,
        transformOrigin: "center center" as const
      }
      : { minWidth: minSize, minHeight: minSize })
  } as unknown as CSSProperties;

  const gridStyle = {
    gap,
    gridTemplateColumns: `repeat(${GRID_SIZE_3}, minmax(0, 1fr))`,
    gridTemplateRows: `repeat(${GRID_SIZE_3}, minmax(0, 1fr))`
  };

  const dots = Array.from({ length: GRID_SIZE_3 * GRID_SIZE_3 }).map((_, index) => {
    const { row, col } = indexToCoord3(index);
    const isActive = patternIndexes.has(index);
    const distance = distanceFromCenter3(index);
    const angle = Math.atan2(row - center, col - center);
    const radiusNormalizedValue = Math.hypot(row - center, col - center) / MAX_RADIUS_3;
    const manhattan = manhattanDistance3(index);
    const deltaX = (col - center) * unit;
    const deltaY = (row - center) * unit;

    const animationState = spinVariant
      ? {
        style: {
          opacity: reducedMotion || phase === "idle"
            ? spinSweepOpacity(angle, radiusNormalizedValue, 0)
            : spinSweepOpacity(angle, radiusNormalizedValue, sweepAngle)
        }
      }
      : animationResolver
        ? animationResolver({
          index,
          row,
          col,
          distanceFromCenter: distance,
          angleFromCenter: angle,
          radiusNormalized: radiusNormalizedValue,
          manhattanDistance: manhattan,
          phase,
          isActive,
          reducedMotion
        })
        : {};

    const resolvedAnimationStyle = animationState.style ? { ...animationState.style } : undefined;
    let isBloomDot = false;
    let stylePatch: CSSProperties | undefined = resolvedAnimationStyle;

    if (isActive) {
      const rawOpacity = stylePatch?.opacity;
      if (stylePatch != null && typeof rawOpacity === "number") {
        const remappedOpacity = remapOpacityToTriplet(rawOpacity, ob, om, op);
        stylePatch = { ...stylePatch, opacity: styleOpacity(remappedOpacity) };
        const parts = skDotBloomParts(true, rawOpacity, bloom, halo, ob, om, op);
        (stylePatch as CSSProperties & { "--sk-bloom-level"?: number })["--sk-bloom-level"] = parts.level;
        isBloomDot = parts.bloomDot;
      } else {
        const parts = skDotBloomParts(true, 0, bloom, halo, ob, om, op);
        if (parts.level > 0) {
          stylePatch = {
            ...(stylePatch ?? {}),
            ["--sk-bloom-level" as const]: parts.level
          } as CSSProperties & { "--sk-bloom-level"?: number };
        }
        isBloomDot = parts.bloomDot;
      }
    }

    const dotStyle = {
      width: dotSize,
      height: dotSize,
      "--sk-distance": styleNumber(distance),
      "--sk-row": row,
      "--sk-col": col,
      "--sk-x": stylePx(deltaX),
      "--sk-y": stylePx(deltaY),
      "--sk-angle": styleNumber(angle),
      "--sk-radius": styleNumber(radiusNormalizedValue),
      "--sk-manhattan": manhattan,
      ...stylePatch,
      ...(!isActive
        ? {
          opacity: 0,
          visibility: "hidden" as const,
          pointerEvents: "none" as const,
          animation: "none"
        }
        : {})
    } as CSSProperties;

    return (
      <span
        key={index}
        aria-hidden="true"
        className={cx(
          "sk-dot",
          !isActive && "sk-inactive",
          isBloomDot && "sk-bloom-dot",
          dotClassName,
          animationState.className
        )}
        style={dotStyle}
      />
    );
  });

  const grid = (
    <div
      className={cx(
        "sk-root",
        "sk-grid-3",
        `sk-dot-shape-${dotShape}`,
        muted && "sk-muted",
        skBloomRootActive(bloom, halo) && "sk-bloom",
        skBloomHaloSpreadClass(halo),
        !useWrapper && className
      )}
      style={skVarStyle}
    >
      <div className="sk-grid" style={gridStyle}>{dots}</div>
    </div>
  );

  if (useWrapper) {
    return (
      <div
        role="status"
        aria-live="polite"
        aria-label={ariaLabel}
        className={className}
        style={{
          display: "inline-flex",
          alignItems: "center",
          justifyContent: "center",
          width: outerDim,
          height: outerDim,
          minWidth: minSize,
          minHeight: minSize,
          overflow: "hidden"
        }}
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
      >
        {grid}
      </div>
    );
  }

  return (
    <div
      role="status"
      aria-live="polite"
      aria-label={ariaLabel}
      className={cx(
        "sk-root",
        "sk-grid-3",
        `sk-dot-shape-${dotShape}`,
        muted && "sk-muted",
        skBloomRootActive(bloom, halo) && "sk-bloom",
        skBloomHaloSpreadClass(halo),
        className
      )}
      style={skVarStyle}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div className="sk-grid" style={gridStyle}>{dots}</div>
    </div>
  );
}

type NormFn = (ctx: Pick<DotAnimationContext, "row" | "col" | "index">) => number;

export function createPathWaveResolver(getPathNorm: NormFn): DotAnimationResolver {
  return ({ isActive, row, col, index, reducedMotion, phase }) => {
    if (!isActive) {
      return { className: "sk-inactive" };
    }

    const path = getPathNorm({ row, col, index });
    const style = { "--sk-path": path } as CSSProperties;

    if (reducedMotion || phase === "idle") {
      return {
        style: {
          ...style,
          opacity: 0.12 + path * 0.72
        }
      };
    }

    return { className: "sk-path", style };
  };
}

type PathWaveComponentProps = SpinnerkitCommonProps;

export function createPathWaveComponent(displayName: string, getPathNorm: NormFn) {
  const resolve = createPathWaveResolver(getPathNorm);

  function PathWaveComponent({
    pattern = "full",
    animated = true,
    hoverAnimated = false,
    speed = 1,
    ...rest
  }: PathWaveComponentProps) {
    const reducedMotion = usePrefersReducedMotion();
    const { phase: skPhase, onMouseEnter, onMouseLeave } = useSpinnerkitPhases({
      animated: Boolean(animated && !reducedMotion),
      hoverAnimated: Boolean(hoverAnimated && !reducedMotion),
      speed
    });
    return (
      <SpinnerkitBase
        {...rest}
        speed={speed}
        pattern={pattern}
        animated={animated}
        phase={skPhase}
        reducedMotion={reducedMotion}
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
        animationResolver={resolve}
      />
    );
  }

  PathWaveComponent.displayName = displayName;
  return PathWaveComponent;
}

export function createDiagonalWave3Resolver(direction: DiagonalWave3Direction): DotAnimationResolver {
  return ({ isActive, index, reducedMotion, phase }) => {
    if (!isActive) {
      return { className: "sk-inactive" };
    }

    const path = diagonalWave3PathNormFromIndex(index, direction);
    const style = { "--sk-path": path } as CSSProperties;

    if (reducedMotion || phase === "idle") {
      return {
        style: {
          ...style,
          opacity: path * 0.88
        }
      };
    }

    return { className: "sk-path-3", style };
  };
}

type DiagonalWave3ComponentProps = SpinnerkitCommonProps;

export function createDiagonalWave3Component(
  displayName: string,
  direction: DiagonalWave3Direction
) {
  const resolve = createDiagonalWave3Resolver(direction);

  function DiagonalWave3Component({
    pattern = "full",
    animated = true,
    hoverAnimated = false,
    speed = 1.15,
    ...rest
  }: DiagonalWave3ComponentProps) {
    const reducedMotion = usePrefersReducedMotion();
    const { phase: skPhase, onMouseEnter, onMouseLeave } = useSpinnerkitPhases({
      animated: Boolean(animated && !reducedMotion),
      hoverAnimated: Boolean(hoverAnimated && !reducedMotion),
      speed
    });

    return (
      <Spinnerkit3Base
        {...rest}
        speed={speed}
        pattern={pattern}
        animated={animated}
        phase={skPhase}
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
        reducedMotion={reducedMotion}
        animationResolver={resolve}
      />
    );
  }

  DiagonalWave3Component.displayName = displayName;
  return DiagonalWave3Component;
}

type Sk3x3ComponentProps = SpinnerkitCommonProps;

export function createSk3x3Component(
  displayName: string,
  animationResolver: DotAnimationResolver,
  defaultSpeed = 1.15
) {
  function Sk3x3Component({
    pattern = "full",
    dotShape = "circle",
    animated = true,
    hoverAnimated = false,
    speed = defaultSpeed,
    ...rest
  }: Sk3x3ComponentProps) {
    const reducedMotion = usePrefersReducedMotion();
    const { phase: skPhase, onMouseEnter, onMouseLeave } = useSpinnerkitPhases({
      animated: Boolean(animated && !reducedMotion),
      hoverAnimated: Boolean(hoverAnimated && !reducedMotion),
      speed
    });

    return (
      <Spinnerkit3Base
        {...rest}
        speed={speed}
        pattern={pattern}
        dotShape={dotShape}
        animated={animated}
        phase={skPhase}
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
        reducedMotion={reducedMotion}
        animationResolver={animationResolver}
      />
    );
  }

  Sk3x3Component.displayName = displayName;
  return Sk3x3Component;
}

const GLYPH_SPIN_BASE_OPACITY = 0.09;
const GLYPH_SPIN_PEAK_OPACITY = 0.88;
const GLYPH_SPIN_STEP_MS = 180;
const GLYPH_SPIN_ROTATION_STEPS = 4;
const GLYPH_SPIN_CYCLE_MS_BASE = GLYPH_SPIN_STEP_MS * GLYPH_SPIN_ROTATION_STEPS;

function glyphSpinSmoothstep(value: number): number {
  const t = Math.min(1, Math.max(0, value));
  return t * t * (3 - 2 * t);
}

export function rotate3x3(pattern: readonly number[], turns: number): readonly number[] {
  const t = ((turns % GLYPH_SPIN_ROTATION_STEPS) + GLYPH_SPIN_ROTATION_STEPS) % GLYPH_SPIN_ROTATION_STEPS;
  if (t === 0) {
    return pattern;
  }

  let out = [...pattern];
  for (let k = 0; k < t; k += 1) {
    const next = new Array<number>(9).fill(0);
    for (let i = 0; i < 9; i += 1) {
      const r = Math.floor(i / 3);
      const c = i % 3;
      const nr = c;
      const nc = 2 - r;
      next[nr * 3 + nc] = out[i]!;
    }
    out = next;
  }
  return out;
}

function glyphSpinOpacity(current: readonly number[], next: readonly number[], index: number, t: number): number {
  const weight = (current[index] ?? 0) * (1 - t) + (next[index] ?? 0) * t;
  return GLYPH_SPIN_BASE_OPACITY + weight * (GLYPH_SPIN_PEAK_OPACITY - GLYPH_SPIN_BASE_OPACITY);
}

type GlyphSpin3ComponentProps = SpinnerkitCommonProps;

export function createGlyphSpin3Component(
  displayName: string,
  glyph: readonly number[],
  defaultSpeed = 1
) {
  function GlyphSpin3Component({
    speed = defaultSpeed,
    pattern = "full",
    dotShape = "circle",
    animated = true,
    hoverAnimated = false,
    ...rest
  }: GlyphSpin3ComponentProps) {
    const reducedMotion = usePrefersReducedMotion();
    const { phase: skPhase, onMouseEnter, onMouseLeave } = useSpinnerkitPhases({
      animated: Boolean(animated && !reducedMotion),
      hoverAnimated: Boolean(hoverAnimated && !reducedMotion),
      speed
    });
    const cyclePhase = useCyclePhase({
      active: !reducedMotion && skPhase !== "idle",
      cycleMsBase: GLYPH_SPIN_CYCLE_MS_BASE,
      speed
    });

    const animationResolver = useMemo<DotAnimationResolver>(() => {
      const scaledPhase = cyclePhase * GLYPH_SPIN_ROTATION_STEPS;
      const turns = Math.floor(scaledPhase) % GLYPH_SPIN_ROTATION_STEPS;
      const segmentT = glyphSpinSmoothstep(scaledPhase - Math.floor(scaledPhase));
      const current = rotate3x3(glyph, turns);
      const next = rotate3x3(glyph, turns + 1);

      return ({ isActive, index, reducedMotion: rm, phase }) => {
        if (!isActive) {
          return { className: "sk-inactive" };
        }

        if (rm || phase === "idle") {
          return { style: { opacity: glyphSpinOpacity(glyph, glyph, index, 0) } };
        }

        return { style: { opacity: glyphSpinOpacity(current, next, index, segmentT) } };
      };
    }, [cyclePhase]);

    return (
      <Spinnerkit3Base
        {...rest}
        speed={speed}
        pattern={pattern}
        dotShape={dotShape}
        animated={animated}
        phase={skPhase}
        onMouseEnter={onMouseEnter}
        onMouseLeave={onMouseLeave}
        reducedMotion={reducedMotion}
        animationResolver={animationResolver}
      />
    );
  }

  GlyphSpin3Component.displayName = displayName;
  return GlyphSpin3Component;
}

components/ui/spinnerkit-hooks.ts

"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import type { SpinnerkitPhase } from "./spinnerkit-core";

export function usePrefersReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)");

    const update = () => {
      setPrefersReducedMotion(query.matches);
    };

    update();
    query.addEventListener("change", update);

    return () => {
      query.removeEventListener("change", update);
    };
  }, []);

  return prefersReducedMotion;
}

export interface UseCyclePhaseOptions {
  active: boolean;
  cycleMsBase: number;
  speed?: number;
}

export function useCyclePhase({ active, cycleMsBase, speed = 1 }: UseCyclePhaseOptions): number {
  const [phase, setPhase] = useState(0);

  useEffect(() => {
    if (!active) {
      setPhase(0);
      return;
    }

    const safeSpeed = speed > 0 ? speed : 1;
    const raw = cycleMsBase / safeSpeed;
    const cycleMs = raw > 0 && Number.isFinite(raw) ? raw : 1000;
    const start = performance.now();
    let rafId = 0;

    const tick = (now: number) => {
      const elapsed = ((now - start) % cycleMs + cycleMs) % cycleMs;
      setPhase(elapsed / cycleMs);
      rafId = requestAnimationFrame(tick);
    };

    rafId = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafId);
  }, [active, cycleMsBase, speed]);

  return phase;
}

interface UseSteppedCycleOptions {
  active: boolean;
  cycleMsBase: number;
  steps: number;
  speed?: number;
  idleStep?: number;
}

type FrameListener = (now: number) => void;

const listeners = new Set<FrameListener>();
let rafId: number | null = null;

function emit(now: number) {
  listeners.forEach((listener) => {
    listener(now);
  });
}

function tick(now: number) {
  emit(now);
  if (listeners.size > 0) {
    rafId = window.requestAnimationFrame(tick);
  } else {
    rafId = null;
  }
}

function subscribeFrame(listener: FrameListener) {
  listeners.add(listener);
  if (rafId === null) {
    rafId = window.requestAnimationFrame(tick);
  }
  return () => {
    listeners.delete(listener);
    if (listeners.size === 0 && rafId !== null) {
      window.cancelAnimationFrame(rafId);
      rafId = null;
    }
  };
}

export function useSteppedCycle({
  active,
  cycleMsBase,
  steps,
  speed = 1,
  idleStep = 0
}: UseSteppedCycleOptions): number {
  const safeSteps = Math.max(1, Math.floor(steps));
  const safeSpeed = speed > 0 ? speed : 1;
  const rawCycleMs = cycleMsBase / safeSpeed;
  const rawStepMs = rawCycleMs / safeSteps;
  const stepMs = rawStepMs > 0 && Number.isFinite(rawStepMs) ? rawStepMs : 1;
  const cycleMs = stepMs * safeSteps;

  const [step, setStep] = useState(() => (active ? 0 : idleStep));
  const startMsRef = useRef<number>(0);
  const activeRef = useRef(false);
  const currentStepRef = useRef(idleStep);

  useEffect(() => {
    if (!active) {
      activeRef.current = false;
      currentStepRef.current = idleStep;
      setStep(idleStep);
      return;
    }

    const updateStep = (now: number) => {
      if (!activeRef.current) {
        startMsRef.current = now;
        activeRef.current = true;
      }

      const elapsed = Math.max(0, now - startMsRef.current);
      const nextStep = Math.floor((elapsed % cycleMs) / stepMs) % safeSteps;
      if (nextStep !== currentStepRef.current) {
        currentStepRef.current = nextStep;
        setStep(nextStep);
      }
    };

    updateStep(performance.now());
    return subscribeFrame(updateStep);
  }, [active, cycleMs, idleStep, safeSteps, stepMs]);

  return active ? step : idleStep;
}

interface UseSpinnerkitPhasesOptions {
  animated?: boolean;
  hoverAnimated?: boolean;
  speed?: number;
}

interface SpinnerkitPhasesResult {
  phase: SpinnerkitPhase;
  onMouseEnter: () => void;
  onMouseLeave: () => void;
}

export function useSpinnerkitPhases({
  animated = false,
  hoverAnimated = false,
  speed = 1
}: UseSpinnerkitPhasesOptions): SpinnerkitPhasesResult {
  const safeSpeed = speed > 0 ? speed : 1;
  const autoRun = Boolean(animated && !hoverAnimated);
  const [hoverPhase, setHoverPhase] = useState<SpinnerkitPhase>("idle");
  const timeouts = useRef<number[]>([]);
  const hoverGen = useRef(0);

  const clearTimers = useCallback(() => {
    for (let i = 0; i < timeouts.current.length; i += 1) {
      window.clearTimeout(timeouts.current[i]!);
    }
    timeouts.current = [];
  }, []);

  useEffect(() => {
    hoverGen.current += 1;
    clearTimers();
    return clearTimers;
  }, [autoRun, hoverAnimated, clearTimers]);

  const onMouseEnter = useCallback(() => {
    if (!hoverAnimated || autoRun) {
      return;
    }
    clearTimers();
    const gen = ++hoverGen.current;
    setHoverPhase("collapse");
    const collapseMs = Math.max(1, Math.round(300 / safeSpeed));
    const id = window.setTimeout(() => {
      if (hoverGen.current !== gen) {
        return;
      }
      setHoverPhase("hoverRipple");
    }, collapseMs);
    timeouts.current.push(id);
  }, [hoverAnimated, autoRun, safeSpeed, clearTimers]);

  const onMouseLeave = useCallback(() => {
    if (!hoverAnimated || autoRun) {
      return;
    }
    hoverGen.current += 1;
    clearTimers();
    setHoverPhase("idle");
  }, [hoverAnimated, autoRun, clearTimers]);

  const phase: SpinnerkitPhase = autoRun ? "loadingRipple" : hoverAnimated ? hoverPhase : "idle";

  return useMemo(
    () => ({
      phase,
      onMouseEnter,
      onMouseLeave
    }),
    [phase, onMouseEnter, onMouseLeave]
  );
}

components/spinnerkit-loader.css

.sk-root {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: middle;
  /* One base loop at speed=1; --sk-speed from JS scales inversely with the speed prop */
  --sk-cycle: 1500ms;
  /* Rest / mid / bright — override via opacityBase, opacityMid, opacityPeak on the component */
  --sk-opacity-base: 0.16;
  --sk-opacity-mid: 0.32;
  --sk-opacity-peak: 1;
  --sk-halo-level: 0;
}

.sk-grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  grid-template-rows: repeat(5, minmax(0, 1fr));
}

.sk-dot {
  border-radius: 999px;
  clip-path: none;
  display: block;
  background: var(--sk-dot-fill, currentColor);
  /* Matches prior 0.24 with default base/mid */
  opacity: calc(0.5 * (var(--sk-opacity-base) + var(--sk-opacity-mid)));
  --sk-bloom-level: 0;
  transform-origin: center;
  transform: none;
  will-change: opacity;
}

.sk-root.sk-dot-shape-circle .sk-dot {
  border-radius: 999px;
  clip-path: none;
  -webkit-mask: none;
  mask: none;
}

.sk-root.sk-dot-shape-square .sk-dot {
  border-radius: 0;
  clip-path: none;
  -webkit-mask: none;
  mask: none;
}

.sk-root.sk-dot-shape-diamond .sk-dot {
  border-radius: 0;
  clip-path: none;
  -webkit-mask: none;
  mask: none;
  transform: rotate(45deg) scale(0.7071067812);
}

.sk-root.sk-dot-shape-hearts .sk-dot {
  position: relative;
  border-radius: 0;
  clip-path: none;
  transform: none;
  background: none;
  -webkit-mask: none;
  mask: none;
}

.sk-root.sk-dot-shape-hearts .sk-dot::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--sk-dot-fill, currentColor);
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='black' d='m8.593.827c-1.008.012-1.953.464-2.593,1.227-.641-.762-1.586-1.214-2.598-1.227C1.519.839-.007,2.378,0,4.257,0,8.362,4.201,10.875,5.488,11.547h0c.16.084.336.125.511.125s.352-.042.511-.125c1.287-.672,5.489-3.184,5.489-7.289.007-1.88-1.519-3.42-3.407-3.431Z'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='black' d='m8.593.827c-1.008.012-1.953.464-2.593,1.227-.641-.762-1.586-1.214-2.598-1.227C1.519.839-.007,2.378,0,4.257,0,8.362,4.201,10.875,5.488,11.547h0c.16.084.336.125.511.125s.352-.042.511-.125c1.287-.672,5.489-3.184,5.489-7.289.007-1.88-1.519-3.42-3.407-3.431Z'/%3E%3C/svg%3E");
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

.sk-bloom .sk-dot {
  filter:
    drop-shadow(
      0 0
      calc(
        var(--sk-dot-size, 3px) * 0.75 *
          max(var(--sk-bloom-level, 0), var(--sk-halo-level, 0))
      )
      currentColor
    )
    drop-shadow(
      0 0
      calc(
        var(--sk-dot-size, 3px) * 1.35 *
          max(var(--sk-bloom-level, 0), var(--sk-halo-level, 0))
      )
      currentColor
    );
  will-change: opacity, filter;
}

/* Halo: modestly wider falloff than selective bloom (same --sk-bloom-level on each dot). */
.sk-root.sk-bloom-halo.sk-bloom .sk-dot {
  filter:
    drop-shadow(
      0 0
      calc(
        var(--sk-dot-size, 3px) * 0.92 *
          max(var(--sk-bloom-level, 0), var(--sk-halo-level, 0))
      )
      currentColor
    )
    drop-shadow(
      0 0
      calc(
        var(--sk-dot-size, 3px) * 1.62 *
          max(var(--sk-bloom-level, 0), var(--sk-halo-level, 0))
      )
      currentColor
    )
    drop-shadow(
      0 0
      calc(
        var(--sk-dot-size, 3px) * 2.55 *
          max(var(--sk-bloom-level, 0), var(--sk-halo-level, 0))
      )
      currentColor
    );
  will-change: opacity, filter;
}

/* Bloom strength comes from inline --sk-bloom-level (see opacityToBloomLevel). */

.sk-muted .sk-dot {
  opacity: calc(0.44 * var(--sk-opacity-mid));
  --sk-bloom-level: 0;
}

/* Inactive off-cells (Base also sets inline opacity/animation:none so keyframes cannot win). */
.sk-dot.sk-inactive {
  opacity: 0 !important;
  --sk-bloom-level: 0;
  animation: none !important;
  visibility: hidden;
  pointer-events: none;
  will-change: auto;
  filter: none;
}

.sk-ripple {
  animation: sk-ripple calc(var(--sk-cycle) * var(--sk-speed, 1)) cubic-bezier(0.42, 0, 0.58, 1)
    infinite;
  animation-delay: calc(var(--sk-ripple-ring, 0) * 0.2333 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-ripple-echo {
  animation: sk-ripple-echo calc(var(--sk-cycle) * var(--sk-speed, 1)) ease-in-out infinite;
  animation-delay: calc(
    (var(--sk-ripple-ring, 0) * 0.14 + var(--sk-ripple-parity, 0) * 0.03) *
      var(--sk-cycle) *
      var(--sk-speed, 1)
  );
  will-change: opacity;
}

.sk-center-origin-ripple {
  animation: sk-center-origin-ripple calc(var(--sk-cycle) * var(--sk-speed, 1)) ease-in-out infinite;
  animation-delay: calc(
    var(--sk-center-ripple-ring, 0) * 0.16 * var(--sk-cycle) * var(--sk-speed, 1)
  );
  will-change: opacity;
}

.sk-collapse {
  animation: sk-collapse calc(var(--sk-cycle) * 0.2 * var(--sk-speed, 1)) ease-in forwards;
  animation-delay: calc(
    (4 - var(--sk-manhattan, 0)) * 0.032 * var(--sk-cycle) * var(--sk-speed, 1)
  );
}

.sk-hover-ripple {
  animation: sk-hover-ripple calc(var(--sk-cycle) * var(--sk-speed, 1)) ease-in-out infinite;
  animation-delay: calc(var(--sk-distance, 0) * 0.127 * var(--sk-cycle) * var(--sk-speed, 1));
}

.sk-path {
  animation: sk-ripple calc(var(--sk-cycle) * var(--sk-speed, 1)) cubic-bezier(0.42, 0, 0.58, 1)
    infinite;
  animation-delay: calc(var(--sk-path, 0) * 0.2333 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-root.sk-grid-3 {
  --sk-cycle: 1500ms;
}

.sk-path-3 {
  animation: sk-ripple-3 calc(0.68 * var(--sk-cycle) * var(--sk-speed, 1)) cubic-bezier(0.42, 0, 0.58, 1)
    infinite;
  animation-delay: calc(var(--sk-path, 0) * 0.19 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-diagonal-alt-sweep {
  animation: sk-diagonal-alt-sweep calc(var(--sk-cycle) * var(--sk-speed, 1)) linear infinite;
  animation-delay: calc(
    (var(--sk-path, 0) * 0.2 + var(--sk-diagonal-parity, 0) * 0.5) *
      var(--sk-cycle) *
      var(--sk-speed, 1)
  );
  will-change: opacity;
}

.sk-spiral-snake {
  animation: sk-spiral-snake calc(var(--sk-cycle) * var(--sk-speed, 1)) linear infinite;
  animation-delay: calc(var(--sk-spiral-order, 0) * 0.04 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-spiral-snake-3 {
  animation: sk-spiral-snake calc(0.78 * var(--sk-cycle) * var(--sk-speed, 1)) linear infinite;
  animation-delay: calc(var(--sk-spiral-order, 0) * 0.038 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-center-ripple-3 {
  animation: sk-center-origin-ripple calc(0.82 * var(--sk-cycle) * var(--sk-speed, 1)) ease-in-out infinite;
  animation-delay: calc(
    var(--sk-center-ripple-ring, 0) * 0.11 * var(--sk-cycle) * var(--sk-speed, 1)
  );
  will-change: opacity;
}

.sk-snake-path-3 {
  animation: sk-ripple-3 calc(1.04 * var(--sk-cycle) * var(--sk-speed, 1)) cubic-bezier(0.42, 0, 0.58, 1)
    infinite;
  animation-delay: calc(var(--sk-snake-order, 0) * 0.085 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-frame-chase-3 {
  animation: sk-ripple-3 calc(0.98 * var(--sk-cycle) * var(--sk-speed, 1)) cubic-bezier(0.42, 0, 0.58, 1)
    infinite;
  animation-delay: calc(var(--sk-frame-order, 0) * 0.09 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-core-pulse-3 {
  animation: sk-ripple-3 calc(0.46 * var(--sk-cycle) * var(--sk-speed, 1)) cubic-bezier(0.42, 0, 0.58, 1)
    infinite;
  will-change: opacity;
}

.sk-distance-ripple-3 {
  animation: sk-ripple-3 calc(1.3 * var(--sk-cycle) * var(--sk-speed, 1)) ease-out infinite;
  animation-delay: calc(var(--sk-distance, 0) * 0.13 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-ripple-echo-3 {
  animation: sk-ripple-echo calc(1.06 * var(--sk-cycle) * var(--sk-speed, 1)) ease-in-out infinite;
  animation-delay: calc(
    (var(--sk-ripple-ring, 0) * 0.1 + var(--sk-ripple-parity, 0) * 0.02) *
      var(--sk-cycle) *
      var(--sk-speed, 1)
  );
  will-change: opacity;
}

.sk-diagonal-snake {
  animation: sk-diagonal-snake calc(var(--sk-cycle) * var(--sk-speed, 1)) linear infinite;
  animation-delay: calc(
    var(--sk-diagonal-snake-order, 0) * 0.04 * var(--sk-cycle) * var(--sk-speed, 1)
  );
  will-change: opacity;
}

.sk-outer-snake {
  animation: sk-ring-snake calc(var(--sk-cycle) * var(--sk-speed, 1)) linear infinite;
  animation-delay: calc(var(--sk-outer-order, 0) * 0.0625 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

.sk-middle-snake {
  animation: sk-ring-snake calc(var(--sk-cycle) * var(--sk-speed, 1)) linear infinite;
  animation-delay: calc(var(--sk-middle-order, 0) * 0.125 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

@keyframes sk-ripple {
  0%,
  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  50% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }
}

@keyframes sk-ripple-3 {
  0%,
  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  3% {
    opacity: calc(0.62 * var(--sk-opacity-mid) + 0.38 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  6% {
    opacity: calc(0.35 * var(--sk-opacity-peak) + 0.65 * var(--sk-opacity-mid));
    --sk-bloom-level: 0.35;
  }

  10% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  14% {
    opacity: calc(0.35 * var(--sk-opacity-peak) + 0.65 * var(--sk-opacity-mid));
    --sk-bloom-level: 0.35;
  }

  17% {
    opacity: calc(0.62 * var(--sk-opacity-mid) + 0.38 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  20% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }
}

@keyframes sk-ripple-echo {
  0%,
  100% {
    opacity: calc(0.625 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  28% {
    opacity: calc(0.98 * var(--sk-opacity-peak));
    --sk-bloom-level: 0.9;
  }

  56% {
    opacity: var(--sk-opacity-mid);
    --sk-bloom-level: 0;
  }

  78% {
    opacity: calc(0.68 * var(--sk-opacity-peak) + 0.32 * var(--sk-opacity-mid));
    --sk-bloom-level: 0;
  }
}

@keyframes sk-center-origin-ripple {
  0%,
  100% {
    opacity: calc(0.625 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  34% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  60% {
    opacity: calc(0.5 * (var(--sk-opacity-base) + var(--sk-opacity-mid)));
    --sk-bloom-level: 0;
  }
}

@keyframes sk-collapse {
  0% {
    opacity: calc(0.95 * var(--sk-opacity-peak) + 0.05 * var(--sk-opacity-mid));
    --sk-bloom-level: 0.75;
  }

  100% {
    opacity: calc(0.375 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }
}

@keyframes sk-hover-ripple {
  0% {
    opacity: calc(0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  45% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }
}

@keyframes sk-diagonal-alt-sweep {
  0%,
  100% {
    opacity: calc(0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  14% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  30% {
    opacity: calc(0.75 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }
}

@keyframes sk-spiral-snake {
  0%,
  100% {
    opacity: calc(0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  8% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  16% {
    opacity: calc(0.5 * var(--sk-opacity-peak) + 0.4 * var(--sk-opacity-mid) + 0.1 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  24% {
    opacity: calc(0.25 * var(--sk-opacity-peak) + 0.45 * var(--sk-opacity-mid) + 0.3 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  32% {
    opacity: calc(0.5 * var(--sk-opacity-mid) + 0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  40% {
    opacity: calc(0.75 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }
}

@keyframes sk-diagonal-snake {
  0%,
  100% {
    opacity: calc(0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  8% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  16% {
    opacity: calc(0.5 * var(--sk-opacity-peak) + 0.4 * var(--sk-opacity-mid) + 0.1 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  24% {
    opacity: calc(0.25 * var(--sk-opacity-peak) + 0.45 * var(--sk-opacity-mid) + 0.3 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  32% {
    opacity: calc(0.5 * var(--sk-opacity-mid) + 0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  40% {
    opacity: calc(0.75 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }
}

@keyframes sk-ring-snake {
  0%,
  100% {
    opacity: calc(0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  10% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  20% {
    opacity: calc(0.45 * var(--sk-opacity-peak) + 0.45 * var(--sk-opacity-mid) + 0.1 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  30% {
    opacity: calc(0.2 * var(--sk-opacity-peak) + 0.4 * var(--sk-opacity-mid) + 0.4 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  40% {
    opacity: calc(0.875 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }
}

.sk-square9-bit {
  animation-duration: calc(5200ms * var(--sk-speed, 1));
  animation-timing-function: steps(52, end);
  animation-iteration-count: infinite;
  will-change: opacity;
}

.sk-square9-d1 {
  animation-name: sk-square9-d1;
}

.sk-square9-d2 {
  animation-name: sk-square9-d2;
}

.sk-square9-d3 {
  animation-name: sk-square9-d3;
}

.sk-square9-d4 {
  animation-name: sk-square9-d4;
}

.sk-square9-d5 {
  animation-name: sk-square9-d5;
}

.sk-square9-d6 {
  animation-name: sk-square9-d6;
}

@keyframes sk-square9-d1 {
  0%,
  3.846154% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  3.846154%,
  30.769231% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  30.769231%,
  46.153846% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  46.153846%,
  50% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  50%,
  53.846154% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  53.846154%,
  57.692308% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  57.692308%,
  65.384615% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  65.384615%,
  71.153846% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  71.153846%,
  80.769231% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  80.769231%,
  84.615385% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  84.615385%,
  88.461538% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  88.461538%,
  92.307692% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  92.307692%,
  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }
}

@keyframes sk-square9-d2 {
  0%,
  5.769231% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  5.769231%,
  25% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  25%,
  30.769231% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  30.769231%,
  36.538462% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  36.538462%,
  50% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  50%,
  53.846154% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  53.846154%,
  57.692308% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  57.692308%,
  61.538462% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  61.538462%,
  65.384615% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  65.384615%,
  76.923077% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  76.923077%,
  80.769231% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  80.769231%,
  84.615385% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  84.615385%,
  88.461538% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  88.461538%,
  92.307692% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  92.307692%,
  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }
}

@keyframes sk-square9-d3 {
  0%,
  7.692308% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  7.692308%,
  25% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  25%,
  36.538462% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  36.538462%,
  42.307692% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  42.307692%,
  46.153846% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  46.153846%,
  50% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  50%,
  53.846154% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  53.846154%,
  57.692308% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  57.692308%,
  71.153846% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  71.153846%,
  76.923077% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  76.923077%,
  80.769231% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  80.769231%,
  84.615385% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  84.615385%,
  88.461538% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  88.461538%,
  92.307692% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  92.307692%,
  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }
}

@keyframes sk-square9-d4 {
  0%,
  13.461538% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  13.461538%,
  30.769231% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  30.769231%,
  50% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  50%,
  53.846154% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  53.846154%,
  57.692308% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  57.692308%,
  61.538462% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  61.538462%,
  65.384615% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  65.384615%,
  71.153846% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  71.153846%,
  84.615385% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  84.615385%,
  88.461538% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  88.461538%,
  92.307692% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  92.307692%,
  96.153846% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  96.153846%,
  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }
}

@keyframes sk-square9-d5 {
  0%,
  15.384615% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  15.384615%,
  25% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  25%,
  30.769231% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  30.769231%,
  36.538462% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  36.538462%,
  46.153846% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  46.153846%,
  50% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  50%,
  53.846154% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  53.846154%,
  57.692308% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  57.692308%,
  65.384615% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  65.384615%,
  76.923077% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  76.923077%,
  84.615385% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  84.615385%,
  88.461538% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  88.461538%,
  92.307692% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  92.307692%,
  96.153846% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  96.153846%,
  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }
}

@keyframes sk-square9-d6 {
  0%,
  17.307692% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  17.307692%,
  25% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  25%,
  36.538462% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  36.538462%,
  42.307692% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  42.307692%,
  50% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  50%,
  53.846154% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  53.846154%,
  57.692308% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  57.692308%,
  61.538462% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  61.538462%,
  71.153846% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  71.153846%,
  76.923077% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  76.923077%,
  84.615385% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  84.615385%,
  88.461538% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  88.461538%,
  92.307692% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }

  92.307692%,
  96.153846% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  96.153846%,
  100% {
    opacity: var(--sk-opacity-base);
    --sk-bloom-level: 0;
  }
}

.sk-square6-col-snake {
  animation: sk-square6-col-snake calc(var(--sk-cycle) * var(--sk-speed, 1)) steps(5, end) infinite;
  animation-delay: calc(var(--sk-col-pos, 0) * 0.2 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

@keyframes sk-square6-col-snake {
  0%,
  20% {
    opacity: calc(0.6 * var(--sk-opacity-peak) + 0.25 * var(--sk-opacity-mid) + 0.15 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  20%,
  40% {
    opacity: calc(0.3 * var(--sk-opacity-peak) + 0.5 * var(--sk-opacity-mid) + 0.2 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  40%,
  60% {
    opacity: calc(0.6 * var(--sk-opacity-mid) + 0.4 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  60%,
  80% {
    opacity: calc(0.2 * var(--sk-opacity-mid) + 0.8 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  80%,
  100% {
    opacity: calc(0.625 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }
}

.sk-circular2-ring {
  animation: sk-circular2-ring calc(var(--sk-cycle) * var(--sk-speed, 1)) steps(12, end) infinite;
  animation-delay: calc(var(--sk-ring-order, 0) * 0.0833333333 * var(--sk-cycle) * var(--sk-speed, 1));
  will-change: opacity;
}

@keyframes sk-circular2-ring {
  0%,
  8.333333% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  8.333333%,
  16.666667% {
    opacity: calc(0.6 * var(--sk-opacity-peak) + 0.4 * var(--sk-opacity-mid));
    --sk-bloom-level: 0;
  }

  16.666667%,
  25% {
    opacity: calc(0.5 * var(--sk-opacity-mid) + 0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  25%,
  33.333333% {
    opacity: calc(0.3 * var(--sk-opacity-mid) + 0.7 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  33.333333%,
  41.666667% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  41.666667%,
  50% {
    opacity: calc(0.6 * var(--sk-opacity-peak) + 0.4 * var(--sk-opacity-mid));
    --sk-bloom-level: 0;
  }

  50%,
  58.333333% {
    opacity: calc(0.5 * var(--sk-opacity-mid) + 0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  58.333333%,
  66.666667% {
    opacity: calc(0.3 * var(--sk-opacity-mid) + 0.7 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  66.666667%,
  75% {
    opacity: var(--sk-opacity-peak);
    --sk-bloom-level: 1;
  }

  75%,
  83.333333% {
    opacity: calc(0.6 * var(--sk-opacity-peak) + 0.4 * var(--sk-opacity-mid));
    --sk-bloom-level: 0;
  }

  83.333333%,
  91.666667% {
    opacity: calc(0.5 * var(--sk-opacity-mid) + 0.5 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }

  91.666667%,
  100% {
    opacity: calc(0.3 * var(--sk-opacity-mid) + 0.7 * var(--sk-opacity-base));
    --sk-bloom-level: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sk-dot,
  .sk-ripple,
  .sk-ripple-echo,
  .sk-center-origin-ripple,
  .sk-collapse,
  .sk-hover-ripple,
  .sk-path,
  .sk-path-3,
  .sk-diagonal-alt-sweep,
  .sk-spiral-snake,
  .sk-spiral-snake-3,
  .sk-center-ripple-3,
  .sk-snake-path-3,
  .sk-frame-chase-3,
  .sk-core-pulse-3,
  .sk-distance-ripple-3,
  .sk-ripple-echo-3,
  .sk-diagonal-snake,
  .sk-outer-snake,
  .sk-middle-snake,
  .sk-square9-bit,
  .sk-square6-col-snake,
  .sk-circular2-ring {
    animation: none !important;
    transition: none !important;
  }
}

Import in your global CSS

@import "@/styles/spinnerkit-loader.css";