Skip to content

Sweep

A band of color that sweeps across a surface and waves as it passes. It works as a loading state, or as a highlight that runs over content.

Uses
WebGL

Code

import { useEffect, useRef, useState, type HTMLAttributes, type RefObject } from "react";
import { cn } from "@manikrana/ui/lib/utils";

type Vec3 = [number, number, number];

type SweepPalette = {
  a: Vec3;
  b: Vec3;
  c: Vec3;
  d: Vec3;
};

const sweepPalettes = {
  prism: {
    a: [0.46, 0.88, 0.33],
    b: [0.6, 0.58, 0.74],
    c: [0.5, 0.5, 0.5],
    d: [0.54, 0.22, 0.84],
  },
  berry: {
    a: [0.92, 0.36, 0.56],
    b: [0.1, 0.14, 0.37],
    c: [0.5, 0.5, 0.5],
    d: [0.84, 0.11, 0.5],
  },
  lagoon: {
    a: [0.58, 0.64, 0.52],
    b: [0.64, 0.3, 0.5],
    c: [0.5, 0.5, 0.5],
    d: [0.37, 0.66, 0.89],
  },
  citrus: {
    a: [0.55, 0.61, 0.61],
    b: [0.54, 0.41, 0.63],
    c: [0.5, 0.5, 0.5],
    d: [0.66, 0.92, 0.23],
  },
  azure: {
    a: [0.13, 0.61, 1],
    b: [0.15, 0.14, 0],
    c: [0.5, 0.5, 0.5],
    d: [0.29, 0.98, 0.74],
  },
  ember: {
    a: [0.83, 0.61, 0.6],
    b: [0.2, 0.41, 0.62],
    c: [0.5, 0.5, 0.5],
    d: [0.73, 0.05, 0.36],
  },
  neutral: {
    a: [0.62, 0.62, 0.62],
    b: [0.24, 0.24, 0.24],
    c: [0.5, 0.5, 0.5],
    d: [0, 0, 0],
  },
} as const satisfies Record<string, SweepPalette>;

type SweepPaletteName = keyof typeof sweepPalettes;
type SweepDirection = "ltr" | "rtl" | "ttb" | "btt";

type SweepShaderOptions = {
  bandTight?: number;
  brightness?: number;
  direction?: SweepDirection;
  palette?: SweepPaletteName | SweepPalette;
  swellAmount?: number;
  waveAmount?: number;
  waveSpeed?: number;
};

type SweepRunOptions = SweepShaderOptions & {
  onMidpoint?: () => void | Promise<void>;
  outroMs?: number;
  peakAlpha?: number;
  sweepMs?: number;
};

type SweepController = {
  destroy: () => void;
  getProgress: () => number;
  setAlpha: (alpha: number) => void;
  setBandTight: (tightness: number) => void;
  setBrightness: (brightness: number) => void;
  setDirection: (direction: SweepDirection) => void;
  setPalette: (palette: SweepPalette) => void;
  setProgress: (progress: number) => void;
  setSwellAmount: (amount: number) => void;
  setWaveAmount: (amount: number) => void;
  setWaveSpeed: (speed: number) => void;
  start: () => void;
  stop: () => void;
};

type SweepRunHandle = {
  cancel: () => void;
  done: Promise<void>;
  midpoint: Promise<void>;
};

type SweepProps = HTMLAttributes<HTMLDivElement> &
  SweepRunOptions & {
    active?: boolean;
    pauseMs?: number;
  };

const vertexShaderSource = `
attribute vec2 a;

void main() {
  gl_Position = vec4(a, 0.0, 1.0);
}
`;

const fragmentShaderSource = `
precision mediump float;

uniform vec2 uRes;
uniform float uTime;
uniform float uProgress;
uniform float uAlpha;
uniform float uBandTight;
uniform float uPosStart;
uniform float uPosEnd;
uniform float uHueShift;
uniform float uDirection;
uniform float uWaveAmount;
uniform float uWaveSpeed;
uniform float uBrightness;
uniform float uSwellAmount;
uniform vec3 uPalA;
uniform vec3 uPalB;
uniform vec3 uPalC;
uniform vec3 uPalD;

#define PI 3.14159265359

vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
  return a + b * cos(2.0 * PI * (c * t + d));
}

void main() {
  vec2 uv = gl_FragCoord.xy / uRes;
  float axis = mix(uv.x, uv.y, uDirection);
  float cross = mix(uv.y, uv.x, uDirection);
  float pos = uPosStart + uProgress * (uPosEnd - uPosStart);
  float tick = uTime * uWaveSpeed;

  float wave =
    sin(cross * 6.0 + tick * 1.3) * 0.020 +
    sin(cross * 13.0 - tick * 0.9 + 1.4) * 0.012 +
    sin(cross * 21.0 + tick * 1.7 + 2.6) * 0.006;
  wave *= uWaveAmount;

  float distToBand = (axis - pos) - wave;
  float band = exp(-distToBand * distToBand * uBandTight);
  float slopeAxis = -2.0 * distToBand * uBandTight * band;

  vec2 slope;
  slope.x = mix(slopeAxis, 0.0, uDirection);
  slope.y = mix(0.0, slopeAxis, uDirection);

  vec3 normal = normalize(vec3(-slope.x * 0.18, slope.y * 0.18, 1.0));
  float trailDistance = max(-distToBand, 0.0);
  float trail =
    smoothstep(0.0, 0.18, trailDistance) *
    (1.0 - smoothstep(0.18, 0.55, trailDistance)) *
    0.24;

  float intensity = max(band * 0.95, trail);
  float edgeFade = smoothstep(0.0, 0.015, cross) * smoothstep(1.0, 0.985, cross);
  float hue =
    normal.x * 0.45 +
    normal.y * 0.30 +
    axis * 1.4 +
    cross * 0.35 +
    uHueShift +
    uTime * 0.04;

  vec3 color = palette(hue, uPalA, uPalB, uPalC, uPalD) * uBrightness;
  vec3 view = vec3(0.0, 0.0, 1.0);
  vec3 light = normalize(vec3(0.35, 0.55, 0.9));
  vec3 halfVector = normalize(light + view);
  float normalToHalf = clamp(dot(normal, halfVector), 0.0, 1.0);
  float normalToView = clamp(dot(normal, view), 0.0, 1.0);
  float fresnel = pow(1.0 - normalToView, 3.0);
  float specular = pow(normalToHalf, 80.0);
  float progressFade = smoothstep(0.0, 0.08, uProgress);

  float bodyAlpha = intensity * edgeFade * uAlpha * progressFade;
  float highlightMask = band * edgeFade * uAlpha * progressFade * uSwellAmount;
  vec3 highlight = (color * fresnel * 0.55 + vec3(specular) * 1.1) * highlightMask;
  float highlightAlpha = (fresnel * 0.4 + specular * 0.9) * highlightMask;

  gl_FragColor = vec4(color * bodyAlpha + highlight, min(bodyAlpha + highlightAlpha, 1.0));
}
`;

function finiteOr(value: number | undefined, fallback: number) {
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}

function resolvePalette(palette: SweepPaletteName | SweepPalette | undefined) {
  if (!palette) return sweepPalettes.prism;
  if (typeof palette === "string") return sweepPalettes[palette];

  return palette;
}

function directionUniforms(direction: SweepDirection) {
  switch (direction) {
    case "rtl":
      return { axis: 0, posEnd: -0.65, posStart: 1.65 };
    case "ttb":
      return { axis: 1, posEnd: 1.65, posStart: -0.65 };
    case "btt":
      return { axis: 1, posEnd: -0.65, posStart: 1.65 };
    case "ltr":
    default:
      return { axis: 0, posEnd: 1.65, posStart: -0.65 };
  }
}

function easeOutQuart(progress: number) {
  return 1 - Math.pow(1 - progress, 4);
}

function easeInOutCubic(progress: number) {
  return progress < 0.5
    ? 4 * progress * progress * progress
    : 1 - Math.pow(-2 * progress + 2, 3) / 2;
}

function compileShader(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);

  if (!shader) return null;

  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    gl.deleteShader(shader);
    return null;
  }

  return shader;
}

function createSweepShader(
  canvas: HTMLCanvasElement,
  opts: SweepShaderOptions = {},
): SweepController | null {
  if (typeof window === "undefined") return null;

  const glContext = canvas.getContext("webgl", {
    alpha: true,
    antialias: false,
    premultipliedAlpha: true,
  });

  if (!glContext) return null;

  const gl: WebGLRenderingContext = glContext;

  const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
  const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);

  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)) {
    gl.deleteProgram(program);
    gl.deleteShader(vertexShader);
    gl.deleteShader(fragmentShader);
    return null;
  }

  gl.useProgram(program);

  const buffer = gl.createBuffer();

  if (!buffer) {
    gl.deleteProgram(program);
    gl.deleteShader(vertexShader);
    gl.deleteShader(fragmentShader);
    return null;
  }

  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);

  const positionLocation = gl.getAttribLocation(program, "a");

  if (positionLocation >= 0) {
    gl.enableVertexAttribArray(positionLocation);
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
  }

  const uniforms = {
    alpha: gl.getUniformLocation(program, "uAlpha"),
    bandTight: gl.getUniformLocation(program, "uBandTight"),
    brightness: gl.getUniformLocation(program, "uBrightness"),
    direction: gl.getUniformLocation(program, "uDirection"),
    hueShift: gl.getUniformLocation(program, "uHueShift"),
    palA: gl.getUniformLocation(program, "uPalA"),
    palB: gl.getUniformLocation(program, "uPalB"),
    palC: gl.getUniformLocation(program, "uPalC"),
    palD: gl.getUniformLocation(program, "uPalD"),
    posEnd: gl.getUniformLocation(program, "uPosEnd"),
    posStart: gl.getUniformLocation(program, "uPosStart"),
    progress: gl.getUniformLocation(program, "uProgress"),
    res: gl.getUniformLocation(program, "uRes"),
    swellAmount: gl.getUniformLocation(program, "uSwellAmount"),
    time: gl.getUniformLocation(program, "uTime"),
    waveAmount: gl.getUniformLocation(program, "uWaveAmount"),
    waveSpeed: gl.getUniformLocation(program, "uWaveSpeed"),
  };

  gl.enable(gl.BLEND);
  gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

  const state = {
    alpha: 0,
    bandTight: finiteOr(opts.bandTight, 14),
    brightness: finiteOr(opts.brightness, 1),
    direction: opts.direction ?? "ltr",
    palette: resolvePalette(opts.palette),
    progress: 0,
    swellAmount: finiteOr(opts.swellAmount, 0.55),
    waveAmount: finiteOr(opts.waveAmount, 1),
    waveSpeed: finiteOr(opts.waveSpeed, 1),
  };
  const hueShift = Math.random() * 0.4;

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const rect = canvas.getBoundingClientRect();
    const width = Math.max(1, Math.round(rect.width * dpr));
    const height = Math.max(1, Math.round(rect.height * dpr));

    if (canvas.width === width && canvas.height === height) return;

    canvas.width = width;
    canvas.height = height;
    gl.viewport(0, 0, width, height);
  }

  resize();

  let observer: ResizeObserver | undefined;
  let frame = 0;
  const startedAt = performance.now();

  if (typeof ResizeObserver === "function") {
    observer = new ResizeObserver(resize);
    observer.observe(canvas);
  } else {
    window.addEventListener("resize", resize);
  }

  function tick() {
    resize();

    const time = (performance.now() - startedAt) / 1000;
    const direction = directionUniforms(state.direction);
    const palette = state.palette;

    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.uniform2f(uniforms.res, canvas.width, canvas.height);
    gl.uniform1f(uniforms.time, time);
    gl.uniform1f(uniforms.progress, state.progress);
    gl.uniform1f(uniforms.alpha, state.alpha);
    gl.uniform1f(uniforms.bandTight, state.bandTight);
    gl.uniform1f(uniforms.posStart, direction.posStart);
    gl.uniform1f(uniforms.posEnd, direction.posEnd);
    gl.uniform1f(uniforms.direction, direction.axis);
    gl.uniform1f(uniforms.waveAmount, state.waveAmount);
    gl.uniform1f(uniforms.waveSpeed, state.waveSpeed);
    gl.uniform1f(uniforms.brightness, state.brightness);
    gl.uniform1f(uniforms.swellAmount, state.swellAmount);
    gl.uniform1f(uniforms.hueShift, hueShift);
    gl.uniform3f(uniforms.palA, palette.a[0], palette.a[1], palette.a[2]);
    gl.uniform3f(uniforms.palB, palette.b[0], palette.b[1], palette.b[2]);
    gl.uniform3f(uniforms.palC, palette.c[0], palette.c[1], palette.c[2]);
    gl.uniform3f(uniforms.palD, palette.d[0], palette.d[1], palette.d[2]);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

    frame = window.requestAnimationFrame(tick);
  }

  return {
    destroy: () => {
      window.cancelAnimationFrame(frame);
      observer?.disconnect();
      window.removeEventListener("resize", resize);
      gl.deleteProgram(program);
      gl.deleteBuffer(buffer);
      gl.deleteShader(vertexShader);
      gl.deleteShader(fragmentShader);
    },
    getProgress: () => state.progress,
    setAlpha: (alpha) => {
      state.alpha = alpha;
    },
    setBandTight: (tightness) => {
      state.bandTight = tightness;
    },
    setBrightness: (brightness) => {
      state.brightness = brightness;
    },
    setDirection: (direction) => {
      state.direction = direction;
    },
    setPalette: (palette) => {
      state.palette = palette;
    },
    setProgress: (progress) => {
      state.progress = progress;
    },
    setSwellAmount: (amount) => {
      state.swellAmount = amount;
    },
    setWaveAmount: (amount) => {
      state.waveAmount = amount;
    },
    setWaveSpeed: (speed) => {
      state.waveSpeed = speed;
    },
    start: () => {
      if (!frame) frame = window.requestAnimationFrame(tick);
    },
    stop: () => {
      window.cancelAnimationFrame(frame);
      frame = 0;
      gl.clear(gl.COLOR_BUFFER_BIT);
    },
  };
}

function ramp(
  durationMs: number,
  from: number,
  to: number,
  setter: (value: number) => void,
  isCancelled: () => boolean,
  setFrame: (frame: number) => void,
) {
  return new Promise<void>((resolve) => {
    const startedAt = performance.now();
    const duration = Math.max(1, durationMs);

    function tick() {
      if (isCancelled()) {
        resolve();
        return;
      }

      const raw = Math.min(1, (performance.now() - startedAt) / duration);
      setter(from + (to - from) * easeInOutCubic(raw));

      if (raw < 1) {
        setFrame(window.requestAnimationFrame(tick));
      } else {
        resolve();
      }
    }

    setFrame(window.requestAnimationFrame(tick));
  });
}

function playSweepAnimation(
  controller: SweepController,
  opts: SweepRunOptions = {},
): SweepRunHandle {
  const sweepMs = Math.max(80, finiteOr(opts.sweepMs, 1100));
  const outroMs = Math.max(0, finiteOr(opts.outroMs, 0));
  const peakAlpha = Math.max(0, finiteOr(opts.peakAlpha, 1));
  let cancelled = false;
  let frame = 0;
  let resolveMidpoint: () => void = () => undefined;
  let resolveDone: () => void = () => undefined;
  const midpoint = new Promise<void>((resolve) => {
    resolveMidpoint = resolve;
  });
  const done = new Promise<void>((resolve) => {
    resolveDone = resolve;
  });

  if (opts.palette) controller.setPalette(resolvePalette(opts.palette));
  if (opts.bandTight !== undefined) controller.setBandTight(opts.bandTight);
  if (opts.direction) controller.setDirection(opts.direction);
  if (opts.waveAmount !== undefined) controller.setWaveAmount(opts.waveAmount);
  if (opts.waveSpeed !== undefined) controller.setWaveSpeed(opts.waveSpeed);
  if (opts.brightness !== undefined) controller.setBrightness(opts.brightness);
  if (opts.swellAmount !== undefined) {
    controller.setSwellAmount(opts.swellAmount);
  }

  void (async () => {
    const startProgress =
      controller.getProgress() >= 0.999 ? 0 : Math.max(0, Math.min(1, controller.getProgress()));
    const remaining = 1 - startProgress;
    const phaseMs = Math.max(80, sweepMs * remaining);
    const startedAt = performance.now();
    let midpointFired = false;

    controller.setAlpha(peakAlpha);
    controller.setProgress(startProgress);

    await new Promise<void>((resolve) => {
      function tick() {
        if (cancelled) {
          resolve();
          return;
        }

        const raw = Math.min(1, (performance.now() - startedAt) / phaseMs);
        const progress = startProgress + remaining * easeOutQuart(raw);

        controller.setProgress(progress);

        if (!midpointFired && progress >= 0.5) {
          midpointFired = true;
          Promise.resolve(opts.onMidpoint?.()).finally(resolveMidpoint);
        }

        if (raw < 1) {
          frame = window.requestAnimationFrame(tick);
        } else {
          resolve();
        }
      }

      frame = window.requestAnimationFrame(tick);
    });

    if (cancelled) return;

    if (!midpointFired) {
      await Promise.resolve(opts.onMidpoint?.()).finally(resolveMidpoint);
    }

    if (outroMs > 0) {
      await ramp(
        outroMs,
        peakAlpha,
        0,
        controller.setAlpha,
        () => cancelled,
        (nextFrame) => {
          frame = nextFrame;
        },
      );
    }

    if (cancelled) return;

    controller.setAlpha(0);
    controller.setProgress(0);
    resolveDone();
  })();

  return {
    cancel: () => {
      cancelled = true;
      window.cancelAnimationFrame(frame);
      resolveMidpoint();
      resolveDone();
    },
    done,
    midpoint,
  };
}

function usePrefersReducedMotion() {
  const [reduced, setReduced] = useState(false);

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

    setReduced(query.matches);

    const update = () => setReduced(query.matches);

    query.addEventListener("change", update);

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

  return reduced;
}

function useOnScreen(ref: RefObject<HTMLElement | null>) {
  const [onScreen, setOnScreen] = useState(false);

  useEffect(() => {
    const element = ref.current;

    if (!element) return undefined;

    let intersecting = false;
    const update = () => setOnScreen(intersecting && !document.hidden);
    const observer = new IntersectionObserver((entries) => {
      intersecting = entries.at(-1)?.isIntersecting ?? false;
      update();
    });

    observer.observe(element);
    document.addEventListener("visibilitychange", update);

    return () => {
      observer.disconnect();
      document.removeEventListener("visibilitychange", update);
    };
  }, [ref]);

  return onScreen;
}

function Sweep({
  active = true,
  bandTight = 14,
  brightness = 1,
  children,
  className,
  direction = "ltr",
  onMidpoint,
  outroMs = 0,
  palette = "prism",
  pauseMs = 900,
  peakAlpha = 1,
  swellAmount = 0.55,
  sweepMs = 1100,
  waveAmount = 1,
  waveSpeed = 1,
  ...props
}: SweepProps) {
  const rootRef = useRef<HTMLDivElement | null>(null);
  const hostRef = useRef<HTMLSpanElement | null>(null);
  const controllerRef = useRef<SweepController | null>(null);
  const options = {
    bandTight,
    brightness,
    direction,
    onMidpoint,
    outroMs,
    palette,
    pauseMs,
    peakAlpha,
    swellAmount,
    sweepMs,
    waveAmount,
    waveSpeed,
  };
  const optionsRef = useRef(options);
  const reducedMotion = usePrefersReducedMotion();
  const running = useOnScreen(rootRef) && active;

  optionsRef.current = options;

  useEffect(() => {
    const controller = controllerRef.current;

    if (!controller) return;

    controller.setBandTight(bandTight);
    controller.setBrightness(brightness);
    controller.setDirection(direction);
    controller.setPalette(resolvePalette(palette));
    controller.setSwellAmount(swellAmount);
    controller.setWaveAmount(waveAmount);
    controller.setWaveSpeed(waveSpeed);
  }, [bandTight, brightness, direction, palette, swellAmount, waveAmount, waveSpeed]);

  useEffect(() => {
    if (reducedMotion) return undefined;

    const host = hostRef.current;

    if (!host) return undefined;

    const canvas = document.createElement("canvas");

    canvas.setAttribute("aria-hidden", "true");
    canvas.className = "pointer-events-none absolute inset-0 block size-full";

    host.appendChild(canvas);

    const controller = createSweepShader(canvas, optionsRef.current);

    if (!controller) {
      canvas.remove();
      return undefined;
    }

    controllerRef.current = controller;

    return () => {
      controller.destroy();
      controllerRef.current = null;
      canvas.remove();
    };
  }, [reducedMotion]);

  useEffect(() => {
    const controller = controllerRef.current;

    if (!controller || !running) return undefined;

    let cancelled = false;
    let timeout = 0;
    let handle: SweepRunHandle | undefined;

    const run = () => {
      const latest = optionsRef.current;

      handle?.cancel();
      handle = playSweepAnimation(controller, latest);
      void handle.done.then(() => {
        if (cancelled) return;
        timeout = window.setTimeout(run, Math.max(0, latest.pauseMs));
      });
    };

    controller.start();
    timeout = window.setTimeout(run, 160);

    return () => {
      cancelled = true;
      window.clearTimeout(timeout);
      handle?.cancel();
      controller.setAlpha(0);
      controller.stop();
    };
  }, [running, reducedMotion]);

  return (
    <div
      ref={rootRef}
      data-slot="sweep"
      className={cn("relative isolate size-full overflow-hidden contain-paint", className)}
      {...props}
    >
      {children}
      <span
        aria-hidden="true"
        ref={hostRef}
        className="pointer-events-none absolute inset-0 z-20 block overflow-hidden"
      />
    </div>
  );
}

// Mirrors the shader's coloring at its default falloff; keep the two in sync.
function stillBand(
  palette: SweepPaletteName | SweepPalette = "prism",
  direction: SweepDirection = "ltr",
) {
  const { a, b, c, d } = resolvePalette(palette);
  const channel = (index: 0 | 1 | 2, t: number) => {
    const value = a[index] + b[index] * Math.cos(2 * Math.PI * (c[index] * t + d[index]));

    return Math.round(255 * Math.min(1, Math.max(0, value)));
  };
  const stops = Array.from({ length: 11 }, (_, step) => {
    const axis = step / 10;
    const hue = axis * 1.4 + 0.5 * 0.35;
    const alpha = Math.exp(-((axis - 0.5) ** 2) * 14) * 0.95;

    return `rgb(${channel(0, hue)} ${channel(1, hue)} ${channel(2, hue)} / ${alpha.toFixed(3)}) ${step * 10}%`;
  });
  // gl_FragCoord runs bottom-up, so a vertical band's palette starts at the bottom.
  const side = direction === "ttb" || direction === "btt" ? "top" : "right";

  return `linear-gradient(to ${side}, ${stops.join(", ")})`;
}

export { Sweep, sweepPalettes, stillBand };
export type { SweepDirection, SweepPalette, SweepPaletteName, SweepProps };