Liana Pepanyan

Theme Switch

Shaders 14

About

Light/dark theme toggle for the Dali Agents console at daliagents.com, with five switch variants used across the Dali Agents UI.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Theme Switch to your project and fits it to your theme.

Code

How Theme Switch is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Switch } from "@/components/ui/switch"; // shadcn/ui
import { useTheme } from "next-themes";
import { SunIcon, MoonIcon } from "lucide-react";
import { cn } from "@/lib/utils";

type Props = React.HTMLAttributes<HTMLDivElement> & {
  intensity?: number; // 0.5 .. 2.0
};

export default function ThemeSwitchFlowGlassPro({
  className,
  intensity = 1,
  ...props
}: Props) {
  const { resolvedTheme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
  const [checked, setChecked] = useState(false);

  // Motion preference
  const prefersReducedMotion = useMemo(
    () =>
      typeof window !== "undefined" &&
      window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches,
    [],
  );

  // Shader refs
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const progRef = useRef<WebGLProgram | null>(null);
  const glRef = useRef<WebGL2RenderingContext | null>(null);
  const vaoRef = useRef<WebGLVertexArrayObject | null>(null);
  const vboRef = useRef<WebGLBuffer | null>(null);
  const rafRef = useRef<number | null>(null);
  const startRef = useRef<number>(0);

  // Interactivity state (kept outside React for perf)
  const hoverRef = useRef({ x: 0.5, y: 0.5 });
  const clickRef = useRef({ x: 0.5, y: 0.5, t: 0 });      // last click center + timestamp seconds
  const toggleRef = useRef({ t: 0, v: 0 });               // last toggle timestamp + value (0/1)

  // Uniform locations
  const u = useRef<{
    res?: WebGLUniformLocation | null;
    time?: WebGLUniformLocation | null;
    theme?: WebGLUniformLocation | null;
    mouse?: WebGLUniformLocation | null;
    power?: WebGLUniformLocation | null;
    click?: WebGLUniformLocation | null;
    clickT?: WebGLUniformLocation | null;
    toggleV?: WebGLUniformLocation | null;
    toggleT?: WebGLUniformLocation | null;
  }>({});

  useEffect(() => setMounted(true), []);
  useEffect(() => setChecked(resolvedTheme === "dark"), [resolvedTheme]);

  const onChange = useCallback(
    (v: boolean) => {
      setChecked(v);
      setTheme(v ? "dark" : "light");
      // mark toggle event for shader pulse
      toggleRef.current.t = performance.now() / 1000;
      toggleRef.current.v = v ? 1 : 0;
    },
    [setTheme],
  );

  // Pointer interactions
  const containerRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    if (!mounted) return;
    const el = containerRef.current;
    if (!el) return;

    const toUV = (e: MouseEvent | PointerEvent) => {
      const r = el.getBoundingClientRect();
      return {
        x: (e.clientX - r.left) / Math.max(1, r.width),
        y: (e.clientY - r.top) / Math.max(1, r.height),
      };
    };

    const onMove = (e: MouseEvent) => {
      const uv = toUV(e);
      hoverRef.current.x += (uv.x - hoverRef.current.x) * 0.22;
      hoverRef.current.y += (uv.y - hoverRef.current.y) * 0.22;
    };

    const onLeave = () => {
      hoverRef.current.x += (0.5 - hoverRef.current.x) * 0.22;
      hoverRef.current.y += (0.5 - hoverRef.current.y) * 0.22;
    };

    const onDown = (e: PointerEvent) => {
      const uv = toUV(e);
      clickRef.current.x = uv.x;
      clickRef.current.y = uv.y;
      clickRef.current.t = performance.now() / 1000; // seconds
    };

    el.addEventListener("mousemove", onMove);
    el.addEventListener("mouseleave", onLeave);
    el.addEventListener("pointerdown", onDown);

    return () => {
      el.removeEventListener("mousemove", onMove);
      el.removeEventListener("mouseleave", onLeave);
      el.removeEventListener("pointerdown", onDown);
    };
  }, [mounted]);

  // --- WebGL setup & render loop ---
  useEffect(() => {
    if (!mounted) return;
    const canvas = canvasRef.current;
    if (!canvas) return;

    const gl = canvas.getContext("webgl2", {
      antialias: true,
      premultipliedAlpha: true,
    });
    if (!gl) return;
    glRef.current = gl;

    const vs = `#version 300 es
      precision highp float;
      layout(location=0) in vec2 a_pos;
      out vec2 v_uv;
      void main(){
        v_uv = a_pos*0.5 + 0.5;
        gl_Position = vec4(a_pos, 0.0, 1.0);
      }`;

    // Flow + turbulence + sheen + interactive ripple + toggle pulse
    const fs = `#version 300 es
      precision highp float;
      out vec4 fragColor;
      in vec2 v_uv;

      uniform vec2  iResolution;
      uniform float iTime;
      uniform int   iTheme;  // 0 light, 1 dark
      uniform vec2  iMouse;  // 0..1 smooth hover pos
      uniform float iPower;  // intensity 0.5..2
      uniform vec2  iClick;  // last click center 0..1
      uniform float iClickT; // seconds of last click
      uniform float iToggleV;// 0/1 last toggle target
      uniform float iToggleT;// seconds of last toggle

      float hash(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); }
      float noise(vec2 p){
        vec2 i=floor(p), f=fract(p);
        float a=hash(i);
        float b=hash(i+vec2(1.,0.));
        float c=hash(i+vec2(0.,1.));
        float d=hash(i+vec2(1.,1.));
        vec2 u=f*f*(3.-2.*f);
        return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
      }
      float fbm(vec2 p){
        float s=0.0, a=0.5;
        for(int i=0;i<5;i++){ s+=a*noise(p); p*=2.0; a*=0.5; }
        return s;
      }

      // curl-ish flow
      vec2 flow(vec2 p){
        float e=0.015;
        float n = fbm(p);
        float nx = fbm(p+vec2(e,0.0));
        float ny = fbm(p+vec2(0.0,e));
        vec2 g = (vec2(nx,ny)-vec2(n))/e;
        return vec2(-g.y, g.x);
      }

      vec3 srgb(vec3 c){ return pow(c, vec3(1.0/2.2)); }
      vec3 tonemap(vec3 c){ return c/(c+vec3(1.0)); }

      void main(){
        vec2 res = iResolution;
        vec2 uv  = v_uv;

        float t = iTime * (0.7 + 0.6*iPower);
        float ar = res.x/max(res.y,1.0);

        // center follows mouse slightly (parallax)
        vec2 center = mix(vec2(0.5), iMouse, 0.38);
        vec2 p = uv - center;
        p.x *= ar;

        // base flow domain
        vec2 q = p * (2.1 + 0.3*iPower);
        q += 0.12*flow(q + vec2(t*0.20, -t*0.17));
        q += 0.10*flow(q*1.9 + vec2(-t*0.19, t*0.23));

        // turbulence layers
        float f1 = fbm(q*2.2 + vec2(t*0.10, -t*0.13));
        float f2 = fbm(q*3.4 + vec2(-t*0.09, t*0.07));
        float ink = smoothstep(0.30, 0.85, 0.55*f1 + 0.45*f2);

        // Theme palettes (calmer, professional)
        vec3 bgLight = vec3(0.97,0.98,1.00);
        vec3 bgDark  = vec3(0.10,0.12,0.16);
        vec3 paper   = vec3(1.00,0.99,0.96);
        vec3 inkC    = vec3(0.17,0.21,0.28); // inky blue-gray
        vec3 cyCyan  = vec3(0.40,0.85,1.00); // sheen color
        vec3 cyGold  = vec3(1.00,0.90,0.60);

        vec3 bg   = mix(bgLight, bgDark, float(iTheme));
        vec3 base = mix(paper, vec3(0.86,0.92,1.00), float(iTheme));
        vec3 tint = mix(cyGold, cyCyan, float(iTheme));

        vec3 col = mix(bg, mix(base, inkC, 0.30), ink);

        // Interactive click ripple (smooth ring decays over ~1.3s)
        float age = max(0.0, iTime - iClickT);
        if(age < 1.3){
          float r = length(uv - iClick);
          float wave = 1.0 - smoothstep(age*0.65, age*0.65 + 0.015, r);
          float fade = smoothstep(1.3, 0.0, age);
          col += tint * wave * 0.18 * fade;
        }

        // Toggle pulse from current thumb center
        float tage = max(0.0, iTime - iToggleT);
        if(tage < 0.9){
          float leftC  = 22.0 / res.x;         // 22px / width
          float rightC = 78.0 / res.x;         // 78px / width
          float cx = mix(leftC, rightC, iToggleV);
          vec2  c = vec2(cx, 0.5);
          float rr = length(uv - c);
          float pulse = exp(-12.0*rr) * exp(-3.0*tage);
          col += tint * pulse * 0.28;
        }

        // Spectral sheen (animated highlight)
        float sweep = 0.25 + 0.25*sin(t*0.9 + uv.x*7.0 - uv.y*3.2);
        float hl = smoothstep(0.04, 0.0, abs(length(p*vec2(1.3,1.8)) - sweep));
        vec3 spec = mix(vec3(1.0,0.97,0.90), vec3(0.82,0.90,1.0), float(iTheme));
        col += spec * hl * 0.18;

        // Rim + vignette
        float edge = smoothstep(0.0, 0.24, 1.0 - distance(uv, vec2(0.5)));
        vec3 rim = mix(vec3(1.0,0.96,0.86), vec3(0.76,0.86,1.0), float(iTheme));
        col = mix(col, col*rim, (1.0-edge)*0.10);
        float vig = smoothstep(0.78, 0.36, length(p));
        col *= mix(1.0, 0.93, vig);

        // Tone/gamma & glass alpha
        col = tonemap(col);
        fragColor = vec4(srgb(col), 0.90);
      }`;

    const compile = (type: number, src: string) => {
      const sh = gl.createShader(type)!;
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        console.error("[FlowGlass.Pro] shader error:", gl.getShaderInfoLog(sh));
        gl.deleteShader(sh);
        return null;
      }
      return sh;
    };

    const vsh = compile(gl.VERTEX_SHADER, vs);
    const fsh = compile(gl.FRAGMENT_SHADER, fs);
    if (!vsh || !fsh) return;

    const prog = gl.createProgram()!;
    gl.attachShader(prog, vsh);
    gl.attachShader(prog, fsh);
    gl.bindAttribLocation(prog, 0, "a_pos");
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.error("[FlowGlass.Pro] link error:", gl.getProgramInfoLog(prog));
      gl.deleteProgram(prog);
      return;
    }
    gl.deleteShader(vsh);
    gl.deleteShader(fsh);
    progRef.current = prog;

    // quad
    const vao = gl.createVertexArray()!;
    gl.bindVertexArray(vao);
    vaoRef.current = vao;

    const vbo = gl.createBuffer()!;
    gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
    vboRef.current = vbo;
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
      gl.STATIC_DRAW,
    );
    gl.enableVertexAttribArray(0);
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

    u.current.res = gl.getUniformLocation(prog, "iResolution");
    u.current.time = gl.getUniformLocation(prog, "iTime");
    u.current.theme = gl.getUniformLocation(prog, "iTheme");
    u.current.mouse = gl.getUniformLocation(prog, "iMouse");
    u.current.power = gl.getUniformLocation(prog, "iPower");
    u.current.click = gl.getUniformLocation(prog, "iClick");
    u.current.clickT = gl.getUniformLocation(prog, "iClickT");
    u.current.toggleV = gl.getUniformLocation(prog, "iToggleV");
    u.current.toggleT = gl.getUniformLocation(prog, "iToggleT");

    const resize = () => {
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      const rect = canvas.getBoundingClientRect();
      const w = Math.max(1, Math.floor(rect.width * dpr));
      const h = Math.max(1, Math.floor(rect.height * dpr));
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w;
        canvas.height = h;
      }
      gl.viewport(0, 0, w, h);
    };

    const render = (ts: number) => {
      if (!progRef.current) return;
      if (!startRef.current) startRef.current = ts;
      const t = prefersReducedMotion ? 0 : (ts - startRef.current) / 1000;

      resize();
      gl.useProgram(progRef.current);
      gl.uniform2f(u.current.res!, canvas.width, canvas.height);
      gl.uniform1f(u.current.time!, t);
      gl.uniform1i(u.current.theme!, resolvedTheme === "dark" ? 1 : 0);
      gl.uniform2f(u.current.mouse!, hoverRef.current.x, hoverRef.current.y);
      gl.uniform1f(u.current.power!, Math.max(0.5, Math.min(2, intensity)));
      gl.uniform2f(u.current.click!, clickRef.current.x, clickRef.current.y);
      gl.uniform1f(u.current.clickT!, clickRef.current.t);
      gl.uniform1f(u.current.toggleV!, toggleRef.current.v);
      gl.uniform1f(u.current.toggleT!, toggleRef.current.t);

      gl.drawArrays(gl.TRIANGLES, 0, 6);
      rafRef.current = requestAnimationFrame(render);
    };

    rafRef.current = requestAnimationFrame(render);
    const onResize = () => resize();
    window.addEventListener("resize", onResize);

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      window.removeEventListener("resize", onResize);
      if (progRef.current) {
        gl.deleteProgram(progRef.current);
        progRef.current = null;
      }
      if (vboRef.current) {
        gl.deleteBuffer(vboRef.current);
        vboRef.current = null;
      }
      if (vaoRef.current) {
        gl.deleteVertexArray(vaoRef.current);
        vaoRef.current = null;
      }
      glRef.current = null;
    };
  }, [mounted, resolvedTheme, intensity, prefersReducedMotion]);

  if (!mounted) return null;

  return (
    <div className="flex flex-col items-center gap-3">
    <div
      ref={containerRef}
      className={cn(
        "relative h-11 w-[100px] select-none",
        "transition-transform duration-150 will-change-transform",
        "hover:scale-[1.02] active:scale-[0.985]",
        className,
      )}
      {...props}
    >
      {/* Shader background */}
      <div className="absolute inset-0 overflow-hidden rounded-full">
        <canvas
          ref={canvasRef}
          className="pointer-events-none absolute inset-0 h-full w-full"
        />
      </div>

      {/* Glass overlay & inner ring */}
      <div
        aria-hidden
        className={cn(
          "absolute inset-0 rounded-full",
          "border border-white/20 bg-background/15 backdrop-blur-md",
          "before:absolute before:inset-[3px] before:rounded-full before:border before:border-white/10",
        )}
        style={{ zIndex: 5 }}
      />

      {/* Real shadcn/ui Switch (transparent track; just the thumb is visible) */}
      <Switch
        checked={checked}
        onCheckedChange={onChange}
        aria-label="Dali console theme"
        className={cn(
          "peer absolute inset-0 h-full w-full rounded-full !bg-transparent !border-0 focus-visible:outline-none",
          // Thumb geometry & visual
          "[&>span]:absolute [&>span]:top-[6px] [&>span]:left-[6px]",
          "[&>span]:h-8 [&>span]:w-8 [&>span]:rounded-full [&>span]:z-30",
          // Thumb style: neutral base with glow that changes by state
          "data-[state=unchecked]:[&>span]:bg-white/90 data-[state=checked]:[&>span]:bg-slate-900/90",
          "data-[state=unchecked]:[&>span]:shadow-[0_6px_18px_rgba(255,255,255,0.35)]",
          "data-[state=checked]:[&>span]:shadow-[0_6px_18px_rgba(56,189,248,0.35)]",
          // Motion
          "[&>span]:transition-transform [&>span]:duration-220",
          "hover:[&>span]:scale-[1.03] active:[&>span]:scale-[0.97]",
          // Travel
          "data-[state=unchecked]:[&>span]:translate-x-0",
          "data-[state=checked]:[&>span]:translate-x-[56px]",
        )}
        style={{ zIndex: 10 }}
      />

      {/* Icons aligned to thumb centers */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-y-0 left-[22px] -translate-x-1/2 z-20 flex items-center"
      >
        <SunIcon
          size={16}
          className={cn(
            "transition-all duration-300",
            checked ? "opacity-45" : "opacity-100 rotate-12",
          )}
        />
      </span>
      <span
        aria-hidden
        className="pointer-events-none absolute inset-y-0 left-[78px] -translate-x-1/2 z-20 flex items-center"
      >
        <MoonIcon
          size={16}
          className={cn(
            "transition-all duration-300",
            checked ? "opacity-100 -rotate-12" : "opacity-45",
          )}
        />
      </span>
    </div>
    <a
      href="https://daliagents.com"
      target="_blank"
      rel="noreferrer"
      className="text-[10px] tracking-wide text-muted-foreground/60 transition-colors hover:text-muted-foreground"
    >
      daliagents.com
    </a>
    </div>
  );
}

Dependencies

More by Liana Pepanyan

View all
  • CTA with Text Marquee

    CTA with Text Marquee

    1.1K
  • Dock

    Dock

    421
  • Hero Minimalism

    Hero Minimalism

    413
  • CTA with Marquee

    CTA with Marquee

    355
  • Dark Grid

    Dark Grid

    203
  • Processing Card

    Processing Card

    197
  • Login / Signup

    Login / Signup

    194
  • Blueprint Shader Grid

    Blueprint Shader Grid

    136

More in Shaders

View all
  • Glass Fold

    Glass Fold

    212
  • Neural Noise

    Neural Noise

    718
  • Water Caustic

    Water Caustic

    44
  • Shader Background

    Shader Background

    2K
  • Wave

    Wave

    687
  • Stripe like gradient-shader

    Stripe like gradient-shader

    265
  • Black Hole

    Black Hole

    23
  • Shader Lines

    Shader Lines

    2.3K