shadimage
d2
uipixel-spinnersource: ok · hit

Pixel Spinner

The pixel-grid spinner engine every spinner preset builds on.

npx shadcn add "https://ui.d2studio.dev/r/pixel-spinner.json"
free

Visual style

retro

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

Files2 · source cached 10/8/2026, 7:40:20 PM

registry/default/ui/pixel-spinner.tsx
"use client";

import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import "./pixel-spinner.css";

export type SpinnerColor = "crimson" | "hotpink" | "violet" | "blue";

export type SpinnerPattern = {
  size?: number;
  rows?: number;
  cols?: number;
  frames: number[][];
  interval?: number;
};

export type SpinnerGradient = { from: string; to: string; glow: string };

export type SpinnerShape =
  | "square"
  | "rounded"
  | "circle"
  | "diamond"
  | "triangle"
  | "lines"
  | "line-2"
  | "line-3";

export type SpinnerEffect = "none" | "light" | "wave";

export type SpinnerAnimation = "pixels" | "wavy";

const SHAPE_STYLE: Record<SpinnerShape, React.CSSProperties> = {
  square: {},
  rounded: { borderRadius: "22%" },
  circle: { borderRadius: "50%" },
  diamond: { clipPath: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)" },
  triangle: { clipPath: "polygon(50% 0, 100% 100%, 0 100%)" },
  lines: {
    clipPath: "inset(calc(100% - 0.2px) 0 0 0)",
  },
  "line-2": {
    maskImage:
      "repeating-linear-gradient(to bottom, black 0 1px, transparent 1px 3px)",
    WebkitMaskImage:
      "repeating-linear-gradient(to bottom, black 0 1px, transparent 1px 3px)",
  },
  "line-3": {},
};

interface PixelSpinnerProps {
  pattern: SpinnerPattern;
  color: SpinnerColor;
  cellSize?: number;
  gap?: number;
  className?: string;
  customColor?: string;
  gradient?: SpinnerGradient;
  intervalOverride?: number;
  glow?: number;
  glowSpread?: number;
  easeIn?: number;
  easeOut?: number;
  shape?: SpinnerShape;
  effect?: SpinnerEffect;
  animation?: SpinnerAnimation;
  /** When true, every cell entering the peak set runs an elastic
   * scale + brightness bounce via a CSS keyframe. */
  pop?: boolean;
  /** Intensity multiplier for the pop bounce (0 = no movement, 1 = default,
   * up to ~2 for an exaggerated punch). */
  popStrength?: number;
  /** Duration of one pop cycle in ms. */
  popDuration?: number;
}

export function PixelSpinner({
  pattern,
  color,
  cellSize = 14,
  gap = 2,
  className,
  customColor,
  gradient,
  intervalOverride,
  glow,
  glowSpread,
  easeIn,
  easeOut,
  shape = "square",
  effect = "light",
  animation = "pixels",
  pop = false,
  popStrength = 1,
  popDuration = 480,
}: PixelSpinnerProps) {
  const shapeStyle = SHAPE_STYLE[shape];
  const cols = pattern.cols ?? pattern.size ?? 3;
  const rows = pattern.rows ?? pattern.size ?? 3;
  const interval = intervalOverride ?? pattern.interval ?? 220;
  const [frame, setFrame] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setFrame((f) => (f + 1) % pattern.frames.length);
    }, interval);
    return () => clearInterval(id);
  }, [pattern.frames.length, interval]);

  const trailOpacities = [1, 0.5, 0.25, 0.15];
  const cellOpacity = new Map<number, number>();
  for (let t = 0; t < trailOpacities.length; t++) {
    const f = (frame - t + pattern.frames.length) % pattern.frames.length;
    const cells = pattern.frames[f] ?? [];
    for (const c of cells) {
      if (!cellOpacity.has(c)) {
        cellOpacity.set(c, trailOpacities[t]);
      }
    }
  }
  const total = rows * cols;

  return (
    <div className="luminous-spinners inline-block">
      <div
        className={cn(
          "spinner-grid grid",
          effect === "light" && "effect-light",
          effect === "wave" && "effect-wave",
          animation === "wavy" ? "anim-wavy" : "anim-pixels",
          pop && "pop-on-peak",
          className,
        )}
        style={
          {
            gridTemplateColumns: `repeat(${cols}, ${cellSize}px)`,
            gap: `${gap}px`,
            ...(glow !== undefined ? { ["--glow"]: String(glow) } : {}),
            ...(glowSpread !== undefined
              ? { ["--glow-spread"]: `${glowSpread}px` }
              : {}),
            ...(easeIn !== undefined ? { ["--ease-in"]: `${easeIn}ms` } : {}),
            ...(easeOut !== undefined
              ? { ["--ease-out"]: `${easeOut}ms` }
              : {}),
            ...(pop
              ? {
                  ["--ls-pop-strength"]: String(popStrength),
                  ["--ls-pop-duration"]: `${popDuration}ms`,
                }
              : {}),
          } as React.CSSProperties
        }
        role="status"
        aria-label="Loading"
      >
        {Array.from({ length: total }).map((_, i) => {
          const op = cellOpacity.get(i);
          const useCustom = !!customColor || !!gradient;
          const variant = useCustom ? "c-custom" : `c-${color}`;
          const customVars: Record<string, string> = {};
          if (gradient) {
            customVars["--cell-gradient"] =
              `linear-gradient(135deg, ${gradient.from}, ${gradient.to})`;
            customVars["--cell-glow"] = gradient.glow;
          } else if (customColor) {
            customVars["--cell-color"] = customColor;
          }
          return (
            <div
              key={i}
              className={cn(
                "cell",
                `shape-${shape}`,
                op !== undefined && `on ${variant}`,
                op === 1 && "peak",
              )}
              style={
                {
                  width: cellSize,
                  height: cellSize,
                  ...shapeStyle,
                  ...customVars,
                  ...(op !== undefined ? { opacity: op } : {}),
                } as React.CSSProperties
              }
            />
          );
        })}
      </div>
    </div>
  );
}
registry/default/ui/pixel-spinner.css
/* Pixel Spinner — scoped styles. Imported by pixel-spinner.tsx. */

.luminous-spinners {
  /* Light theme defaults */
  --ls-foreground: var(--foreground);
  --ls-muted-foreground: var(--muted-foreground);
  --ls-card: var(--card);
  --ls-border: var(--border);
  --ls-code-bg: var(--muted);
  --ls-cell-off: radial-gradient(
    circle at 35% 30%,
    oklch(0 0 0 / 0.08),
    oklch(0 0 0 / 0.02) 60%,
    transparent 100%
  );
  --ls-cell-stroke: oklch(0 0 0 / 0.08);

  --ls-crimson: oklch(0.75 0.22 145);
  --ls-hotpink: oklch(0.72 0.24 5);
  --ls-violet: oklch(0.7 0.22 305);
  --ls-blue: oklch(0.7 0.2 255);

  --ls-gradient-crimson: linear-gradient(135deg, oklch(0.85 0.18 145), oklch(0.65 0.24 140));
  --ls-gradient-hotpink: linear-gradient(135deg, oklch(0.85 0.2 350), oklch(0.65 0.25 10));
  --ls-gradient-violet: linear-gradient(135deg, oklch(0.85 0.18 320), oklch(0.6 0.25 285));
  --ls-gradient-blue: linear-gradient(135deg, oklch(0.85 0.16 240), oklch(0.55 0.24 265));
}

.dark .luminous-spinners {
  --ls-cell-off: radial-gradient(
    circle at 35% 30%,
    oklch(1 0 0 / 0.06),
    oklch(1 0 0 / 0.02) 60%,
    transparent 100%
  );
  --ls-cell-stroke: oklch(1 0 0 / 0.05);
}

.luminous-spinners .cell {
  position: relative;
  border-radius: 0;
  background: var(--ls-cell-off);
  box-shadow: inset 0 0 0 1px var(--ls-cell-stroke);
  transition:
    background var(--ease-out, 600ms) ease-out,
    transform var(--ease-out, 600ms) ease-out,
    box-shadow var(--ease-out, 600ms) ease-out,
    opacity var(--ease-out, 600ms) ease-out;
}

.luminous-spinners .cell.on {
  transform: scale(1.08);
  transition:
    background var(--ease-in, 300ms) ease-in,
    transform var(--ease-in, 300ms) ease-in,
    box-shadow var(--ease-in, 300ms) ease-in,
    opacity var(--ease-in, 300ms) ease-in;
}

.luminous-spinners .anim-wavy .cell {
  transition:
    background 320ms cubic-bezier(0.34, 1.56, 0.64, 1),
    transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1),
    box-shadow 320ms cubic-bezier(0.34, 1.56, 0.64, 1),
    opacity 320ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.luminous-spinners .anim-wavy .cell.on {
  transform: scale(1.35);
  transition:
    background 320ms cubic-bezier(0.34, 1.56, 0.64, 1),
    transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1),
    box-shadow 320ms cubic-bezier(0.34, 1.56, 0.64, 1),
    opacity 320ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes ls-pop-on-peak {
  0% {
    transform: scale(1);
    filter: brightness(1) saturate(1);
  }
  22% {
    transform: scale(calc(1 - 0.3 * var(--ls-pop-strength, 1)));
    filter: brightness(calc(1 + 0.35 * var(--ls-pop-strength, 1)))
      saturate(calc(1 + 0.25 * var(--ls-pop-strength, 1)));
  }
  48% {
    transform: scale(calc(1 + 0.18 * var(--ls-pop-strength, 1)));
    filter: brightness(calc(1 + 0.2 * var(--ls-pop-strength, 1)))
      saturate(calc(1 + 0.15 * var(--ls-pop-strength, 1)));
  }
  72% {
    transform: scale(calc(1 - 0.04 * var(--ls-pop-strength, 1)));
  }
  100% {
    transform: scale(1);
    filter: brightness(1) saturate(1);
  }
}

.luminous-spinners .pop-on-peak .cell.on.peak {
  animation: ls-pop-on-peak var(--ls-pop-duration, 480ms)
    cubic-bezier(0.34, 1.56, 0.64, 1);
}

.luminous-spinners .cell.on.c-crimson {
  background: var(--ls-gradient-crimson);
  box-shadow:
    0 0 calc(8px * var(--glow, 1)) var(--glow-spread, 0px) oklch(0.75 0.22 145 / 0.95),
    0 0 calc(22px * var(--glow, 1)) oklch(0.7 0.24 140 / 0.75),
    0 0 calc(44px * var(--glow, 1)) oklch(0.65 0.26 135 / 0.55),
    0 0 calc(72px * var(--glow, 1)) oklch(0.65 0.26 135 / 0.35);
}

.luminous-spinners .cell.on.c-hotpink {
  background: var(--ls-gradient-hotpink);
  box-shadow:
    0 0 calc(8px * var(--glow, 1)) var(--glow-spread, 0px) oklch(0.72 0.24 5 / 0.95),
    0 0 calc(22px * var(--glow, 1)) oklch(0.7 0.25 10 / 0.75),
    0 0 calc(44px * var(--glow, 1)) oklch(0.65 0.25 15 / 0.55),
    0 0 calc(72px * var(--glow, 1)) oklch(0.65 0.25 15 / 0.35);
}

.luminous-spinners .cell.on.c-violet {
  background: var(--ls-gradient-violet);
  box-shadow:
    0 0 calc(8px * var(--glow, 1)) var(--glow-spread, 0px) oklch(0.7 0.22 305 / 0.95),
    0 0 calc(22px * var(--glow, 1)) oklch(0.65 0.24 300 / 0.75),
    0 0 calc(44px * var(--glow, 1)) oklch(0.6 0.25 295 / 0.55),
    0 0 calc(72px * var(--glow, 1)) oklch(0.6 0.25 295 / 0.35);
}

.luminous-spinners .cell.on.c-blue {
  background: var(--ls-gradient-blue);
  box-shadow:
    0 0 calc(8px * var(--glow, 1)) var(--glow-spread, 0px) oklch(0.7 0.2 255 / 0.95),
    0 0 calc(22px * var(--glow, 1)) oklch(0.6 0.22 260 / 0.75),
    0 0 calc(44px * var(--glow, 1)) oklch(0.55 0.24 265 / 0.55),
    0 0 calc(72px * var(--glow, 1)) oklch(0.55 0.24 265 / 0.35);
}

.luminous-spinners .cell.on.c-custom {
  background: var(
    --cell-gradient,
    linear-gradient(
      135deg,
      color-mix(in oklab, var(--cell-color, #7ab7ff) 80%, white),
      color-mix(in oklab, var(--cell-color, #7ab7ff) 85%, black)
    )
  );
  box-shadow:
    0 0 calc(8px * var(--glow, 1)) var(--glow-spread, 0px)
      color-mix(
        in oklab,
        var(--cell-glow, var(--cell-color, #7ab7ff)) 95%,
        transparent
      ),
    0 0 calc(22px * var(--glow, 1))
      color-mix(
        in oklab,
        var(--cell-glow, var(--cell-color, #7ab7ff)) 75%,
        transparent
      ),
    0 0 calc(44px * var(--glow, 1))
      color-mix(
        in oklab,
        var(--cell-glow, var(--cell-color, #7ab7ff)) 55%,
        transparent
      ),
    0 0 calc(72px * var(--glow, 1))
      color-mix(
        in oklab,
        var(--cell-glow, var(--cell-color, #7ab7ff)) 35%,
        transparent
      );
}

.luminous-spinners .cell.shape-line-3::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: repeating-linear-gradient(
    to bottom,
    rgba(69, 69, 69, 0.75) 0 11%,
    transparent 11% 22%
  );
  pointer-events: none;
  mix-blend-mode: multiply;
  border-radius: inherit;
}

.luminous-spinners .effect-light .cell.on::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(
    115deg,
    transparent 30%,
    oklch(1 0 0 / 0.45) 50%,
    transparent 70%
  );
  background-size: 250% 100%;
  animation: ls-shimmer 1.8s linear infinite;
  mix-blend-mode: overlay;
  pointer-events: none;
}

@keyframes ls-shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

.luminous-spinners .spinner-grid {
  position: relative;
}

.luminous-spinners .spinner-grid.effect-wave {
  overflow: hidden;
}

.luminous-spinners .spinner-grid.effect-wave::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    115deg,
    transparent 35%,
    oklch(1 0 0 / 0.35) 50%,
    transparent 65%
  );
  background-size: 260% 260%;
  animation: ls-wave 2.8s linear infinite;
  mix-blend-mode: screen;
  pointer-events: none;
  z-index: 3;
}

@keyframes ls-wave {
  0% {
    background-position: 140% 140%;
  }
  100% {
    background-position: -140% -140%;
  }
}

More from d2

Browse all components in this library →