asanshay
componentloadersource: ok · hit
Loader
Custom WebGL shader loaders with 3 shapes (sphere, swirl, ripple) × 3 styles (plain, blur, dither).
npx shadcn add "https://ds.asanshay.com/r/loader.json"free
Visual style
glass-blur
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files8 · source cached 10/14/2026, 12:30:34 PM
registry/new-york/blocks/loader/loader.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ShaderMount } from "./shader-mount";
import {
plainFragmentShader,
blurFragmentShader,
ditherFragmentShader,
hexToRgba,
} from "./shaders";
type LoaderShape = "sphere" | "swirl" | "ripple";
type LoaderStyle = "plain" | "blur" | "dither";
const sizeConfig = {
sm: { width: 48, height: 48 },
default: { width: 80, height: 80 },
lg: { width: 120, height: 120 },
};
const shapeMap: Record<LoaderShape, number> = {
sphere: 1,
swirl: 2,
ripple: 3,
};
const shaderMap: Record<LoaderStyle, string> = {
plain: plainFragmentShader,
blur: blurFragmentShader,
dither: ditherFragmentShader,
};
interface LoaderProps extends React.HTMLAttributes<HTMLDivElement> {
/** Base shape of the loader */
shape?: LoaderShape;
/** Visual style: plain (solid), blur (soft), or dither (pixelated) */
variant?: LoaderStyle;
/** Size preset (maps to width/height) */
size?: keyof typeof sizeConfig;
/** Custom width (overrides size preset) */
width?: number;
/** Custom height (overrides size preset) */
height?: number;
/** Animation speed multiplier */
speed?: number;
/** Primary color */
color?: string;
/** Background color */
colorBack?: string;
}
function Loader({
shape = "sphere",
variant = "dither",
size = "default",
width,
height,
speed = 1,
color = "#00b3ff",
colorBack = "transparent",
className,
style,
...props
}: LoaderProps) {
const sizeDefaults = sizeConfig[size];
const resolvedWidth = width ?? sizeDefaults.width;
const resolvedHeight = height ?? sizeDefaults.height;
const fragmentShader = shaderMap[variant];
const shapeValue = shapeMap[shape];
const scale = variant === "blur" ? 0.52 : 0.6;
const usesCssVariables = color.includes("var(") || colorBack.includes("var(");
const [cssVariableEpoch, setCssVariableEpoch] = React.useState(0);
React.useEffect(() => {
if (!usesCssVariables || typeof document === "undefined") {
return;
}
const bumpCssVariableEpoch = () => {
setCssVariableEpoch((value) => value + 1);
};
// Resolve once after mount, then re-resolve on theme/token changes.
bumpCssVariableEpoch();
const attributeFilter = ["class", "style", "data-theme"];
const observer = new MutationObserver(bumpCssVariableEpoch);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter,
});
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter,
});
}
const media = window.matchMedia("(prefers-color-scheme: dark)");
if (typeof media.addEventListener === "function") {
media.addEventListener("change", bumpCssVariableEpoch);
} else {
media.addListener(bumpCssVariableEpoch);
}
return () => {
observer.disconnect();
if (typeof media.removeEventListener === "function") {
media.removeEventListener("change", bumpCssVariableEpoch);
} else {
media.removeListener(bumpCssVariableEpoch);
}
};
}, [usesCssVariables]);
const uniforms = React.useMemo(
() => ({
u_colorFront: hexToRgba(color),
u_colorBack: hexToRgba(colorBack),
u_shape: shapeValue,
u_scale: scale,
u_pxSize: 2, // Pixel size for dithering
}),
[color, colorBack, shapeValue, scale, cssVariableEpoch]
);
return (
<div
data-slot="loader"
className={cn("relative overflow-hidden rounded-full", className)}
style={{
width: resolvedWidth,
height: resolvedHeight,
...style,
}}
{...props}
>
<ShaderMount
fragmentShader={fragmentShader}
uniforms={uniforms}
speed={speed}
width={resolvedWidth}
height={resolvedHeight}
/>
</div>
);
}
export { Loader, sizeConfig };
export type { LoaderProps, LoaderShape, LoaderStyle };
registry/new-york/blocks/loader/shader-mount.tsx
"use client";
import * as React from "react";
import { vertexShaderSource } from "./shaders";
interface ShaderMountProps {
fragmentShader: string;
uniforms: Record<string, number | number[]>;
speed?: number;
width: number;
height: number;
className?: string;
style?: React.CSSProperties;
}
function createShader(
gl: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(
"Shader compile error:",
gl.getShaderInfoLog(shader)
);
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(
gl: WebGL2RenderingContext,
vertexSource: string,
fragmentSource: string
): WebGLProgram | null {
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertexShader || !fragmentShader) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("Program link error:", gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
// Clean up shaders after linking
gl.detachShader(program, vertexShader);
gl.detachShader(program, fragmentShader);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return program;
}
export function ShaderMount({
fragmentShader,
uniforms,
speed = 1,
width,
height,
className,
style,
}: ShaderMountProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const glRef = React.useRef<WebGL2RenderingContext | null>(null);
const programRef = React.useRef<WebGLProgram | null>(null);
const uniformLocationsRef = React.useRef<
Record<string, WebGLUniformLocation | null>
>({});
const rafRef = React.useRef<number | null>(null);
const startTimeRef = React.useRef<number>(performance.now());
const currentSpeedRef = React.useRef<number>(speed);
// Update speed ref when prop changes
React.useEffect(() => {
currentSpeedRef.current = speed;
}, [speed]);
// Initialize WebGL
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl2", {
alpha: true,
premultipliedAlpha: true,
antialias: true,
});
if (!gl) {
console.error("WebGL2 not supported");
return;
}
glRef.current = gl;
// Create program
const program = createProgram(gl, vertexShaderSource, fragmentShader);
if (!program) return;
programRef.current = program;
// Setup position attribute
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const positionLocation = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
// Get uniform locations
const locations: Record<string, WebGLUniformLocation | null> = {
u_time: gl.getUniformLocation(program, "u_time"),
u_resolution: gl.getUniformLocation(program, "u_resolution"),
u_pixelRatio: gl.getUniformLocation(program, "u_pixelRatio"),
};
Object.keys(uniforms).forEach((key) => {
locations[key] = gl.getUniformLocation(program, key);
});
uniformLocationsRef.current = locations;
// Enable blending for transparency
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
if (gl && program) {
gl.deleteProgram(program);
}
};
}, [fragmentShader]);
// Render loop
React.useEffect(() => {
const gl = glRef.current;
const program = programRef.current;
const locations = uniformLocationsRef.current;
if (!gl || !program) return;
const pixelRatio = Math.min(window.devicePixelRatio, 2);
const canvasWidth = width * pixelRatio;
const canvasHeight = height * pixelRatio;
const canvas = canvasRef.current;
if (canvas) {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
gl.viewport(0, 0, canvasWidth, canvasHeight);
let lastTime = performance.now();
let accumulatedTime = 0;
const render = (currentTime: number) => {
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
accumulatedTime += deltaTime * currentSpeedRef.current * 0.001;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
// Set built-in uniforms
if (locations.u_time) {
gl.uniform1f(locations.u_time, accumulatedTime);
}
if (locations.u_resolution) {
gl.uniform2f(locations.u_resolution, canvasWidth, canvasHeight);
}
if (locations.u_pixelRatio) {
gl.uniform1f(locations.u_pixelRatio, pixelRatio);
}
// Set custom uniforms
Object.entries(uniforms).forEach(([key, value]) => {
const location = locations[key];
if (!location) return;
if (Array.isArray(value)) {
switch (value.length) {
case 2:
gl.uniform2fv(location, value);
break;
case 3:
gl.uniform3fv(location, value);
break;
case 4:
gl.uniform4fv(location, value);
break;
}
} else {
gl.uniform1f(location, value);
}
});
gl.drawArrays(gl.TRIANGLES, 0, 6);
rafRef.current = requestAnimationFrame(render);
};
rafRef.current = requestAnimationFrame(render);
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [uniforms, width, height]);
return (
<canvas
ref={canvasRef}
className={className}
style={{
width,
height,
...style,
}}
/>
);
}
registry/new-york/blocks/loader/shaders/index.ts
// Shader exports
export { vertexShaderSource } from "./vertex";
export { plainFragmentShader } from "./plain";
export { blurFragmentShader } from "./blur";
export { ditherFragmentShader } from "./dither";
export { hexToRgba } from "./utils";
registry/new-york/blocks/loader/shaders/vertex.ts
// Shared vertex shader for all loader styles
export const vertexShaderSource = `#version 300 es
precision mediump float;
layout(location = 0) in vec4 a_position;
void main() {
gl_Position = a_position;
}
`;
registry/new-york/blocks/loader/shaders/utils.ts
// Shared GLSL utilities and shape functions
export const shaderConstants = `
#define TWO_PI 6.28318530718
#define PI 3.14159265358979323846
`;
export const shapeUniforms = `
uniform float u_time;
uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform vec4 u_colorFront;
uniform vec4 u_colorBack;
uniform float u_shape; // 1=sphere, 2=swirl, 3=ripple
uniform float u_scale;
`;
// Shape functions - all shapes expect normalized UV coordinates centered at origin
export const shapeFunctions = `
// Sphere - 3D lit sphere with rotating light source
float sphereShape(vec2 uv, float time) {
uv *= 2.0;
float d = 1.0 - pow(length(uv), 2.0);
vec3 pos = vec3(uv, sqrt(max(0.0, d)));
vec3 lightPos = normalize(vec3(cos(3.0 * time), 0.8, sin(2.5 * time)));
float shape = 0.5 + 0.5 * dot(lightPos, pos);
return shape * step(0.0, d);
}
// Swirl - spiral vortex pattern
float swirlShape(vec2 uv, float time) {
float l = length(uv);
float angle = 6.0 * atan(uv.y, uv.x) + 8.0 * time;
float twist = 1.2;
float offset = 1.0 / pow(max(l, 1e-6), twist) + angle / TWO_PI;
float mid = smoothstep(0.0, 1.0, pow(l, twist));
return mix(0.0, fract(offset), mid);
}
// Ripple - concentric expanding waves
float rippleShape(vec2 uv, float time) {
float dist = length(uv);
float waves = sin(pow(dist, 1.7) * 7.0 - 6.0 * time) * 0.5 + 0.5;
return waves;
}
// Get shape value based on u_shape uniform
float getShape(vec2 uv, float time) {
if (u_shape < 1.5) {
return sphereShape(uv, time);
} else if (u_shape < 2.5) {
return swirlShape(uv, time);
} else {
return rippleShape(uv, time);
}
}
`;
function createTransparentRgba(): [number, number, number, number] {
return [0, 0, 0, 0];
}
function parseHexColor(color: string): [number, number, number, number] | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color.trim());
if (!result) {
return null;
}
return [
parseInt(result[1], 16) / 255,
parseInt(result[2], 16) / 255,
parseInt(result[3], 16) / 255,
1.0,
];
}
function parseRgbColor(color: string): [number, number, number, number] | null {
const rgbResult =
/^rgba?\(\s*([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i.exec(
color.trim()
);
if (!rgbResult) {
return null;
}
return [
Math.min(255, Math.max(0, Number(rgbResult[1]))) / 255,
Math.min(255, Math.max(0, Number(rgbResult[2]))) / 255,
Math.min(255, Math.max(0, Number(rgbResult[3]))) / 255,
rgbResult[4] ? Math.min(1, Math.max(0, Number(rgbResult[4]))) : 1.0,
];
}
function resolveBrowserColor(color: string): string | null {
if (typeof window === "undefined" || typeof document === "undefined") {
return null;
}
const probe = document.createElement("span");
probe.style.color = color;
// If the browser rejected the color value, style.color will be empty
if (!probe.style.color) {
return null;
}
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.pointerEvents = "none";
document.body.appendChild(probe);
const resolvedColor = window.getComputedStyle(probe).color;
probe.remove();
return resolvedColor || null;
}
// Helper to convert CSS color values to RGBA array
export function hexToRgba(color: string): [number, number, number, number] {
const normalizedColor = color.trim();
if (!normalizedColor || normalizedColor === "transparent") {
return createTransparentRgba();
}
const fromHex = parseHexColor(normalizedColor);
if (fromHex) {
return fromHex;
}
const fromRgb = parseRgbColor(normalizedColor);
if (fromRgb) {
return fromRgb;
}
const resolvedColor = resolveBrowserColor(normalizedColor);
if (resolvedColor) {
const resolvedFromHex = parseHexColor(resolvedColor);
if (resolvedFromHex) {
return resolvedFromHex;
}
const resolvedFromRgb = parseRgbColor(resolvedColor);
if (resolvedFromRgb) {
return resolvedFromRgb;
}
}
return createTransparentRgba();
}
registry/new-york/blocks/loader/shaders/plain.ts
// Plain style fragment shader - crisp core with soft bloom
import { shaderConstants, shapeUniforms, shapeFunctions } from "./utils";
export const plainFragmentShader = `#version 300 es
precision mediump float;
${shaderConstants}
${shapeUniforms}
out vec4 fragColor;
${shapeFunctions}
// Gaussian-like blur by sampling shape at multiple offsets
float blurredShape(vec2 uv, float time, float blurSize) {
float sum = 0.0;
float totalWeight = 0.0;
// 9-tap blur kernel
for (float x = -1.0; x <= 1.0; x += 1.0) {
for (float y = -1.0; y <= 1.0; y += 1.0) {
vec2 offset = vec2(x, y) * blurSize;
float weight = 1.0 - length(vec2(x, y)) * 0.3;
sum += getShape(uv + offset, time) * weight;
totalWeight += weight;
}
}
return sum / totalWeight;
}
void main() {
// Normalize coordinates to [-0.5, 0.5] centered
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
uv /= u_scale;
float time = u_time;
float core = getShape(uv, time);
float mask = 1.0 - smoothstep(0.7, 1.05, length(uv));
core *= mask;
// Crisp core with a gentle bloom
float solid = smoothstep(0.2, 0.85, core);
float glow = blurredShape(uv, time, 0.018);
float halo = smoothstep(0.0, 0.6, glow) * 0.35;
float finalShape = clamp(solid + halo, 0.0, 1.0);
// Subtle lighting for a more polished feel
float radial = 1.0 - smoothstep(0.2, 1.05, length(uv));
float highlight =
pow(max(0.0, 1.0 - length(uv - vec2(-0.25, 0.25))), 3.0);
float shade = clamp(0.75 + 0.25 * radial + 0.2 * highlight, 0.0, 1.15);
// Mix colors with glow
vec3 fgColor = u_colorFront.rgb * u_colorFront.a;
float fgOpacity = u_colorFront.a * finalShape;
vec3 bgColor = u_colorBack.rgb * u_colorBack.a;
float bgOpacity = u_colorBack.a;
vec3 color = fgColor * shade * finalShape;
float opacity = fgOpacity;
color += bgColor * (1.0 - opacity);
opacity += bgOpacity * (1.0 - opacity);
fragColor = vec4(color, opacity);
}
`;
registry/new-york/blocks/loader/shaders/blur.ts
// Blur style fragment shader - completely diffuse/foggy rendering
import { shaderConstants, shapeUniforms, shapeFunctions } from "./utils";
export const blurFragmentShader = `#version 300 es
precision mediump float;
${shaderConstants}
${shapeUniforms}
out vec4 fragColor;
${shapeFunctions}
// Heavy blur sampling for diffuse effect
float heavyBlur(vec2 uv, float time, float blurSize) {
float sum = 0.0;
float totalWeight = 0.0;
// 25-tap blur kernel for heavier blur
for (float x = -2.0; x <= 2.0; x += 1.0) {
for (float y = -2.0; y <= 2.0; y += 1.0) {
vec2 offset = vec2(x, y) * blurSize;
float dist = length(vec2(x, y));
float weight = exp(-dist * dist * 0.15); // Gaussian falloff
sum += getShape(uv + offset, time) * weight;
totalWeight += weight;
}
}
return sum / totalWeight;
}
void main() {
// Normalize coordinates to [-0.5, 0.5] centered
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
uv /= u_scale;
float time = u_time;
float dist = length(uv);
// Multiple heavy blur passes for completely diffuse result
float blurAmount = 0.05;
float shape1 = heavyBlur(uv, time, blurAmount);
float shape2 = heavyBlur(uv, time, blurAmount * 2.5);
float shape3 = heavyBlur(uv, time, blurAmount * 4.0);
float shape4 = heavyBlur(uv, time, blurAmount * 6.0);
// Combine blur passes - heavily weighted toward outer blur
float shape = shape1 * 0.25 + shape2 * 0.3 + shape3 * 0.25 + shape4 * 0.2;
// Very soft power curve for foggy appearance
// Boost intensity for swirl/ripple to fill more space
float powerCurve = (u_shape < 1.5) ? 0.6 : 0.5;
float foggy = pow(shape, powerCurve);
// Apply shape-specific edge handling with extra padding
float edgeFade = 1.0;
if (u_shape < 1.5) {
// Sphere: soft radial fade with more breathing room
edgeFade = 1.0 - smoothstep(0.55, 0.88, dist);
} else if (u_shape < 2.5) {
// Swirl: diffuse edges without hard cutoff
edgeFade = 1.0 - smoothstep(0.6, 0.92, dist);
} else {
// Ripple: diffuse edges without hard cutoff
edgeFade = 1.0 - smoothstep(0.6, 0.92, dist);
}
foggy *= edgeFade;
// Extra soft smoothstep for diffuse edges
float finalShape = smoothstep(0.0, 0.8, foggy);
// Mix colors
vec3 fgColor = u_colorFront.rgb * u_colorFront.a;
float fgOpacity = u_colorFront.a * finalShape;
vec3 bgColor = u_colorBack.rgb * u_colorBack.a;
float bgOpacity = u_colorBack.a;
vec3 color = fgColor * finalShape;
float opacity = fgOpacity;
color += bgColor * (1.0 - opacity);
opacity += bgOpacity * (1.0 - opacity);
fragColor = vec4(color, opacity);
}
`;
registry/new-york/blocks/loader/shaders/dither.ts
// Dither style fragment shader - Bayer matrix dithering for retro pixelated look
import { shaderConstants, shapeUniforms, shapeFunctions } from "./utils";
export const ditherFragmentShader = `#version 300 es
precision mediump float;
${shaderConstants}
${shapeUniforms}
uniform float u_pxSize;
out vec4 fragColor;
${shapeFunctions}
// 4x4 Bayer dithering matrix
const int bayer4x4[16] = int[16](
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5
);
// 8x8 Bayer dithering matrix for finer detail
const int bayer8x8[64] = int[64](
0, 32, 8, 40, 2, 34, 10, 42,
48, 16, 56, 24, 50, 18, 58, 26,
12, 44, 4, 36, 14, 46, 6, 38,
60, 28, 52, 20, 62, 30, 54, 22,
3, 35, 11, 43, 1, 33, 9, 41,
51, 19, 59, 27, 49, 17, 57, 25,
15, 47, 7, 39, 13, 45, 5, 37,
63, 31, 55, 23, 61, 29, 53, 21
);
float getBayerValue4x4(vec2 uv) {
ivec2 pos = ivec2(mod(uv, 4.0));
int index = pos.y * 4 + pos.x;
return float(bayer4x4[index]) / 16.0;
}
float getBayerValue8x8(vec2 uv) {
ivec2 pos = ivec2(mod(uv, 8.0));
int index = pos.y * 8 + pos.x;
return float(bayer8x8[index]) / 64.0;
}
void main() {
float pxSize = max(2.0, u_pxSize) * u_pixelRatio;
// Pixelate the UV coordinates
vec2 pixelUV = gl_FragCoord.xy / pxSize;
vec2 pixelatedCoord = (floor(pixelUV) + 0.5) * pxSize;
// Normalize to centered coordinates
vec2 uv = (pixelatedCoord - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
uv /= u_scale;
float time = u_time;
float shape = getShape(uv, time);
// Get Bayer dithering threshold
float dither = getBayerValue4x4(pixelUV);
dither -= 0.5;
// Apply dithering threshold
float result = step(0.5, shape + dither);
// Mix colors
vec3 fgColor = u_colorFront.rgb * u_colorFront.a;
float fgOpacity = u_colorFront.a;
vec3 bgColor = u_colorBack.rgb * u_colorBack.a;
float bgOpacity = u_colorBack.a;
vec3 color = fgColor * result;
float opacity = fgOpacity * result;
color += bgColor * (1.0 - opacity);
opacity += bgOpacity * (1.0 - opacity);
fragColor = vec4(color, opacity);
}
`;