motionsites中文站 / Skeuomorphic Toggle
Skeuomorphic Toggle
aadmin2026-09-16 发布3D11 阅读

Skeuomorphic Toggle

类型:3D · 分类:UI Elements · 收录:2026-09-16 20:35:01 · 源码:会员可见

[hidecontent type="vip1"]
# Skeuomorphic Toggle
Four takes on one switch: the preserved tactile skeuomorphic export plus flat modern, Three.js glass, and shader-lit treatments, each matching light and dark appearances automatically.

> 来源:ThreeUI Community · https://threeui.com · MIT License(Meng To / Design+Code)
> 组件页:https://threeui.com/component/skeuomorphic-toggle · 预览动画:https://threeui.com/previews/skeuomorphic-toggle.webm
> 分类:UI Elements
> 包含变体(3):modern、glass、shader

/* ===== src/shaders/skeuomorphic-toggle/SkeuomorphicToggleCollection.tsx (tsx) ===== */
import { lazy, Suspense, type CSSProperties } from "react";

import type { ToggleModePreference } from "./toggleMode";

export const SKEUOMORPHIC_TOGGLE_VARIANTS = [
  "skeuomorphic-toggle",
  "modern",
  "glass",
  "shader",
] as const;

export type SkeuomorphicToggleVariant = (typeof SKEUOMORPHIC_TOGGLE_VARIANTS)[number];

export type SkeuomorphicToggleCollectionProps = {
  variant?: SkeuomorphicToggleVariant;
  mode?: ToggleModePreference;
  defaultOn?: boolean;
  label?: string;
  speed?: number;
  size?: number;
  opacity?: number;
  hue?: number;
  saturation?: number;
  brightness?: number;
  onChange?: (on: boolean) => void;
  className?: string;
  style?: CSSProperties;
};

/* The packaged Neuform export stays exactly as it shipped; the three companion
   treatments are first-party React renderers. */
const PackagedToggle = lazy(() =>
  import("../neuform-isolated/NeuformBatchEffects").then((module) => ({ default: module.SkeuomorphicToggle })),
);

const ModernToggle = lazy(() =>
  import("./ModernToggle").then((module) => ({ default: module.ModernToggle })),
);

const GlassToggle = lazy(() =>
  import("./GlassToggle").then((module) => ({ default: module.GlassToggle })),
);

const ShaderToggle = lazy(() =>
  import("./ShaderToggle").then((module) => ({ default: module.ShaderToggle })),
);

const FALLBACK = <div className="threeui-background skeuomorphic-toggle-variant" />;

export function SkeuomorphicToggleCollection({
  variant = "skeuomorphic-toggle",
  onChange,
  defaultOn,
  label,
  speed,
  ...props
}: SkeuomorphicToggleCollectionProps) {
  if (variant === "modern") {
    return (
      <Suspense fallback={FALLBACK}>
        <ModernToggle {...props} defaultOn={defaultOn} label={label} onChange={onChange} />
      </Suspense>
    );
  }

  if (variant === "glass") {
    return (
      <Suspense fallback={FALLBACK}>
        <GlassToggle {...props} defaultOn={defaultOn} label={label} speed={speed} onChange={onChange} />
      </Suspense>
    );
  }

  if (variant === "shader") {
    return (
      <Suspense fallback={FALLBACK}>
        <ShaderToggle {...props} defaultOn={defaultOn} label={label} speed={speed} onChange={onChange} />
      </Suspense>
    );
  }

  return (
    <Suspense fallback={FALLBACK}>
      <PackagedToggle {...props} speed={speed} />
    </Suspense>
  );
}

/* ===== src/shaders/skeuomorphic-toggle/ModernToggle.tsx (tsx) ===== */
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";

import "./modern-toggle.css";
import { clamp, useToggleMode, type ToggleModePreference } from "./toggleMode";

export type ModernToggleProps = {
  mode?: ToggleModePreference;
  defaultOn?: boolean;
  label?: string;
  size?: number;
  opacity?: number;
  hue?: number;
  saturation?: number;
  brightness?: number;
  onChange?: (on: boolean) => void;
  className?: string;
  style?: CSSProperties;
};

export const MODERN_TOGGLE_DEFAULTS = {
  mode: "auto" as ToggleModePreference,
  defaultOn: true,
  label: "Live Sync",
  size: 1,
  opacity: 1,
  hue: 0,
  saturation: 1,
  brightness: 1,
} as const;

export function ModernToggle({
  mode = MODERN_TOGGLE_DEFAULTS.mode,
  defaultOn = MODERN_TOGGLE_DEFAULTS.defaultOn,
  label = MODERN_TOGGLE_DEFAULTS.label,
  size = MODERN_TOGGLE_DEFAULTS.size,
  opacity = MODERN_TOGGLE_DEFAULTS.opacity,
  hue = MODERN_TOGGLE_DEFAULTS.hue,
  saturation = MODERN_TOGGLE_DEFAULTS.saturation,
  brightness = MODERN_TOGGLE_DEFAULTS.brightness,
  onChange,
  className,
  style,
}: ModernToggleProps) {
  const resolvedMode = useToggleMode(mode);
  const [on, setOn] = useState(defaultOn);
  const switchRef = useRef<HTMLButtonElement>(null);
  const thumbRef = useRef<HTMLSpanElement>(null);

  /* A light spring rather than an eased transition: the knob leans into the
     travel, trails a little squash behind it, and settles with one small
     overshoot. Damping ratio ~0.67 — enough to read as a spring, well short
     of bouncy. */
  useEffect(() => {
    const control = switchRef.current;
    const thumb = thumbRef.current;
    if (!control || !thumb) return undefined;

    const reduceMotion = typeof window !== "undefined"
      && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const target = on ? 1 : 0;
    let value = Number(thumb.dataset.progress ?? (on ? 1 : 0));
    let velocity = Number(thumb.dataset.velocity ?? 0);
    let handle = 0;
    let last = performance.now();

    const apply = (progress: number, speed: number) => {
      const travel = Math.max(0, control.clientWidth - thumb.offsetLeft * 2 - thumb.offsetWidth);
      /* Squash scales with how fast it is actually moving, and trails the
         direction of travel. */
      const lean = Math.min(1, Math.abs(speed) / 6);
      const scaleX = 1 + lean * 0.16;
      const scaleY = 1 - lean * 0.1;
      thumb.style.transformOrigin = speed >= 0 ? "right center" : "left center";
      thumb.style.transform =
        `translate3d(${(progress * travel).toFixed(2)}px, 0, 0) scale(${scaleX.toFixed(4)}, ${scaleY.toFixed(4)})`;
    };

    if (reduceMotion) {
      thumb.dataset.progress = String(target);
      thumb.dataset.velocity = "0";
      apply(target, 0);
      return undefined;
    }

    const step = (now: number) => {
      const delta = Math.min(0.032, (now - last) / 1000);
      last = now;
      const stiffness = 210;
      const damping = 19.5;
      velocity += ((target - value) * stiffness - velocity * damping) * delta;
      value += velocity * delta;
      thumb.dataset.progress = String(value);
      thumb.dataset.velocity = String(velocity);
      apply(value, velocity);
      if (Math.abs(target - value) < 0.0006 && Math.abs(velocity) < 0.006) {
        value = target;
        velocity = 0;
        thumb.dataset.progress = String(target);
        thumb.dataset.velocity = "0";
        apply(target, 0);
        return;
      }
      handle = window.requestAnimationFrame(step);
    };

    handle = window.requestAnimationFrame(step);
    return () => window.cancelAnimationFrame(handle);
  }, [on]);

  const flip = useCallback(() => {
    setOn((current) => {
      const next = !current;
      onChange?.(next);
      return next;
    });
  }, [onChange]);

  const stageStyle = {
    "--modern-toggle-scale": clamp(size, 0.35, 2.5),
    "--modern-toggle-hue": `${clamp(hue, -180, 180)}deg`,
    "--modern-toggle-saturation": clamp(saturation, 0, 2),
    "--modern-toggle-brightness": clamp(brightness, 0.35, 1.65),
    opacity: clamp(opacity, 0.05, 1),
    ...style,
  } as CSSProperties;

  return (
    <div
      className={`modern-toggle${className ? ` ${className}` : ""}`}
      data-mode={resolvedMode}
      data-state={on ? "on" : "off"}
      style={stageStyle}
    >
      <div className="modern-toggle__halo" aria-hidden="true" />
      <div className="modern-toggle__stack">
        <button
          type="button"
          ref={switchRef}
          className="modern-toggle__switch"
          role="switch"
          aria-checked={on}
          aria-label={label}
          onClick={flip}
        >
          <span className="modern-toggle__track" aria-hidden="true" />
          <span ref={thumbRef} className="modern-toggle__thumb" aria-hidden="true">
            <span className="modern-toggle__mark">
              <svg viewBox="0 0 24 24" data-mark="check" aria-hidden="true">
                <path d="M5 12.8 9.6 17.4 19 8" />
              </svg>
              <svg viewBox="0 0 24 24" data-mark="dash" aria-hidden="true">
                <path d="M6.5 12h11" />
              </svg>
            </span>
          </span>
        </button>
        <p className="modern-toggle__caption">
          <span>{label}</span>
          <b>{on ? "On" : "Off"}</b>
        </p>
      </div>
    </div>
  );
}

/* ===== src/shaders/skeuomorphic-toggle/modern-toggle.css (css) ===== */
.modern-toggle,
.modern-toggle *,
.modern-toggle *::before,
.modern-toggle *::after {
  box-sizing: border-box;
}

.modern-toggle {
  --modern-toggle-scale: 1;
  --modern-toggle-hue: 0deg;
  --modern-toggle-saturation: 1;
  --modern-toggle-brightness: 1;
  --modern-toggle-track: #e6e8ec;
  --modern-toggle-track-on: #2f6bff;
  --modern-toggle-edge: rgba(9, 12, 20, 0.09);
  --modern-toggle-edge-on: rgba(9, 12, 20, 0.06);
  --modern-toggle-thumb: #ffffff;
  --modern-toggle-thumb-shadow: 0 1px 1px rgba(9, 12, 20, 0.1), 0 6px 14px -4px rgba(9, 12, 20, 0.28);
  --modern-toggle-mark: #2f6bff;
  --modern-toggle-caption: rgba(15, 20, 32, 0.42);
  --modern-toggle-caption-on: rgba(15, 20, 32, 0.7);
  --modern-toggle-focus: rgba(47, 107, 255, 0.32);
  --modern-toggle-stage: #f7f8fa;
  --modern-toggle-stage-top: #ffffff;
  --modern-toggle-stage-bottom: #eceff4;
  --modern-toggle-halo: rgba(47, 107, 255, 0.1);
  position: relative;
  display: grid;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  place-items: center;
  overflow: hidden;
  isolation: isolate;
  container-type: size;
  background: linear-gradient(180deg, var(--modern-toggle-stage-top) 0%, var(--modern-toggle-stage) 62%, var(--modern-toggle-stage-bottom) 100%);
  color-scheme: light;
  filter: hue-rotate(var(--modern-toggle-hue)) saturate(var(--modern-toggle-saturation)) brightness(var(--modern-toggle-brightness));
  font-family: ui-sans-serif, -apple-system, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

.modern-toggle[data-mode="dark"] {
  --modern-toggle-track: #22262f;
  --modern-toggle-track-on: #3b7bff;
  --modern-toggle-edge: rgba(255, 255, 255, 0.1);
  --modern-toggle-edge-on: rgba(255, 255, 255, 0.16);
  --modern-toggle-thumb: #f2f4f8;
  --modern-toggle-thumb-shadow: 0 1px 1px rgba(0, 0, 0, 0.5), 0 8px 18px -6px rgba(0, 0, 0, 0.7);
  --modern-toggle-mark: #1f5cf0;
  --modern-toggle-caption: rgba(232, 237, 247, 0.4);
  --modern-toggle-caption-on: rgba(232, 237, 247, 0.74);
  --modern-toggle-focus: rgba(94, 145, 255, 0.4);
  --modern-toggle-stage: #0c0e13;
  --modern-toggle-stage-top: #14171f;
  --modern-toggle-stage-bottom: #06070a;
  --modern-toggle-halo: rgba(59, 123, 255, 0.16);
  color-scheme: dark;
}

/* One soft pool of accent light, so the switch is not floating on a flat fill. */
.modern-toggle__halo {
  position: absolute;
  width: min(72cqw, 620px);
  height: min(72cqh, 380px);
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--modern-toggle-halo), transparent 72%);
  opacity: 0;
  transform: scale(0.86);
  transition: opacity 0.5s ease, transform 0.6s cubic-bezier(0.32, 0.72, 0, 1);
  pointer-events: none;
}

.modern-toggle[data-state="on"] .modern-toggle__halo {
  opacity: 1;
  transform: scale(1);
}

.modern-toggle__stack {
  position: relative;
  display: grid;
  gap: clamp(12px, 2.4cqh, 24px);
  justify-items: center;
  transform: scale(var(--modern-toggle-scale));
}

/* Every measurement below is a share of the switch's own width, so the control
   keeps its proportions at any stage size. */
.modern-toggle__switch {
  position: relative;
  display: block;
  width: clamp(104px, min(18cqw, 33cqh), 330px);
  aspect-ratio: 44 / 25;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  cursor: pointer;
  container-type: inline-size;
  -webkit-tap-highlight-color: transparent;
  appearance: none;
}

.modern-toggle__switch:focus-visible {
  outline: none;
}

.modern-toggle__track {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--modern-toggle-track);
  box-shadow: inset 0 0 0 1px var(--modern-toggle-edge);
  transition: background 0.46s cubic-bezier(0.32, 0.72, 0, 1), box-shadow 0.46s ease;
}

.modern-toggle[data-state="on"] .modern-toggle__track {
  background: var(--modern-toggle-track-on);
  box-shadow: inset 0 0 0 1px var(--modern-toggle-edge-on);
}

.modern-toggle__switch:focus-visible .modern-toggle__track {
  box-shadow: inset 0 0 0 1px var(--modern-toggle-edge), 0 0 0 4px var(--modern-toggle-focus);
}

.modern-toggle[data-state="on"] .modern-toggle__switch:focus-visible .modern-toggle__track {
  box-shadow: inset 0 0 0 1px var(--modern-toggle-edge-on), 0 0 0 4px var(--modern-toggle-focus);
}

/* The knob's transform is driven by a spring in JS, not by a transition —
   a CSS transition cannot squash on the way and settle on arrival, so the
   stretch had to be an :active snap that fought the travel. */
.modern-toggle__thumb {
  position: absolute;
  top: 4.6cqw;
  left: 4.6cqw;
  width: 47.6cqw;
  height: 47.6cqw;
  display: grid;
  place-items: center;
  border-radius: 999px;
  background: var(--modern-toggle-thumb);
  box-shadow: var(--modern-toggle-thumb-shadow);
  transform: translate3d(0, 0, 0);
  transform-origin: center;
  will-change: transform;
}

.modern-toggle__mark {
  position: relative;
  width: 36%;
  height: 36%;
  color: var(--modern-toggle-mark);
}

.modern-toggle__mark svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: opacity 0.28s ease, transform 0.34s cubic-bezier(0.32, 0.72, 0, 1);
}

.modern-toggle__mark svg[data-mark="check"] {
  opacity: 0;
  transform: scale(0.6) rotate(-18deg);
}

.modern-toggle__mark svg[data-mark="dash"] {
  opacity: 0.34;
  transform: scale(1);
}

.modern-toggle[data-state="on"] .modern-toggle__mark svg[data-mark="check"] {
  opacity: 1;
  transform: scale(1) rotate(0deg);
}

.modern-toggle[data-state="on"] .modern-toggle__mark svg[data-mark="dash"] {
  opacity: 0;
  transform: scale(0.6);
}

.modern-toggle__caption {
  display: flex;
  align-items: baseline;
  gap: 0.55em;
  font-size: clamp(11px, 0.85cqw, 16px);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--modern-toggle-caption);
  transition: color 0.4s ease;
}

.modern-toggle[data-state="on"] .modern-toggle__caption {
  color: var(--modern-toggle-caption-on);
}

.modern-toggle__caption b {
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

@media (prefers-reduced-motion: reduce) {
  .modern-toggle__halo,
  .modern-toggle__track,
  .modern-toggle__mark svg,
  .modern-toggle__caption {
    transition-duration: 0.01ms;
  }
}

/* ===== src/shaders/skeuomorphic-toggle/GlassToggle.tsx (tsx) ===== */
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";

import "./glass-toggle.css";
import { createGlassToggleScene, type GlassToggleScene } from "./glassToggleScene";
import { clamp, useToggleMode, type ToggleModePreference } from "./toggleMode";

export type GlassToggleProps = {
  mode?: ToggleModePreference;
  defaultOn?: boolean;
  label?: string;
  speed?: number;
  size?: number;
  opacity?: number;
  hue?: number;
  saturation?: number;
  brightness?: number;
  onChange?: (on: boolean) => void;
  className?: string;
  style?: CSSProperties;
};

export const GLASS_TOGGLE_DEFAULTS = {
  mode: "auto" as ToggleModePreference,
  defaultOn: true,
  label: "Live Sync",
  speed: 1,
  size: 1,
  opacity: 1,
  hue: 0,
  saturation: 1,
  brightness: 1,
} as const;

export function GlassToggle({
  mode = GLASS_TOGGLE_DEFAULTS.mode,
  defaultOn = GLASS_TOGGLE_DEFAULTS.defaultOn,
  label = GLASS_TOGGLE_DEFAULTS.label,
  speed = GLASS_TOGGLE_DEFAULTS.speed,
  size = GLASS_TOGGLE_DEFAULTS.size,
  opacity = GLASS_TOGGLE_DEFAULTS.opacity,
  hue = GLASS_TOGGLE_DEFAULTS.hue,
  saturation = GLASS_TOGGLE_DEFAULTS.saturation,
  brightness = GLASS_TOGGLE_DEFAULTS.brightness,
  onChange,
  className,
  style,
}: GlassToggleProps) {
  const resolvedMode = useToggleMode(mode);
  const hostRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const switchRef = useRef<HTMLButtonElement>(null);
  const sceneRef = useRef<GlassToggleScene | null>(null);
  const [on, setOn] = useState(defaultOn);
  const [supported, setSupported] = useState(true);

  const flip = useCallback(() => {
    setOn((current) => {
      const next = !current;
      onChange?.(next);
      return next;
    });
  }, [onChange]);

  useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    if (!host || !canvas) return undefined;

    let scene: GlassToggleScene;
    try {
      scene = createGlassToggleScene({ canvas, mode: resolvedMode, speed, on: defaultOn });
    } catch {
      setSupported(false);
      return undefined;
    }
    sceneRef.current = scene;

    const syncSwitchFootprint = () => {
      const control = switchRef.current;
      if (!control) return;
      const footprint = scene.measureSwitch();
      control.style.width = `${Math.round(footprint.width)}px`;
      control.style.height = `${Math.round(footprint.height)}px`;
    };

    const observer = new ResizeObserver((entries) => {
      const box = entries[0]?.contentRect;
      if (!box) return;
      scene.resize(box.width, box.height);
      syncSwitchFootprint();
    });
    observer.observe(host);
    scene.resize(host.clientWidth, host.clientHeight);
    syncSwitchFootprint();

    return () => {
      observer.disconnect();
      scene.dispose();
      sceneRef.current = null;
    };
    /* The scene is rebuilt only for the host element; appearance, speed and
       state are pushed through the imperative handles below. */
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    sceneRef.current?.setMode(resolvedMode);
  }, [resolvedMode]);

  useEffect(() => {
    sceneRef.current?.setSpeed(clamp(speed, 0, 3));
  }, [speed]);

  useEffect(() => {
    sceneRef.current?.setSize(clamp(size, 0.35, 2.5));
    const control = switchRef.current;
    const footprint = sceneRef.current?.measureSwitch();
    if (!control || !footprint) return;
    control.style.width = `${Math.round(footprint.width)}px`;
    control.style.height = `${Math.round(footprint.height)}px`;
  }, [size]);

  useEffect(() => {
    sceneRef.current?.setOn(on);
  }, [on]);

  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const host = hostRef.current;
    if (!host) return;
    const rect = host.getBoundingClientRect();
    const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    const y = ((event.clientY - rect.top) / rect.height) * 2 - 1;
    sceneRef.current?.setPointer(clamp(x, -1, 1), clamp(-y, -1, 1));
  };

  const stageStyle = {
    "--glass-toggle-hue": `${clamp(hue, -180, 180)}deg`,
    "--glass-toggle-saturation": clamp(saturation, 0, 2),
    "--glass-toggle-brightness": clamp(brightness, 0.35, 1.65),
    opacity: clamp(opacity, 0.05, 1),
    ...style,
  } as CSSProperties;

  return (
    <div
      ref={hostRef}
      className={`glass-toggle${className ? ` ${className}` : ""}`}
      data-mode={resolvedMode}
      data-state={on ? "on" : "off"}
      style={stageStyle}
      onPointerMove={handlePointerMove}
      onPointerLeave={() => sceneRef.current?.setPointer(0, 0)}
      onClick={flip}
    >
      <canvas ref={canvasRef} className="glass-toggle__canvas" />
      <button
        ref={switchRef}
        type="button"
        className="glass-toggle__switch"
        role="switch"
        aria-checked={on}
        aria-label={label}
        onClick={(event) => {
          /* The whole stage is clickable, so keep the button from toggling twice. */
          event.stopPropagation();
          flip();
        }}
      />
      <p className="glass-toggle__caption">
        <span>{label}</span>
        <b>{on ? "On" : "Off"}</b>
      </p>
      {supported ? null : (
        <p className="glass-toggle__fallback">This variant needs WebGL, which this browser did not provide.</p>
      )}
    </div>
  );
}

/* ===== src/shaders/skeuomorphic-toggle/glassToggleScene.ts (typescript) ===== */
import * as THREE from "three";

import type { ToggleMode } from "./toggleMode";

export type GlassToggleSceneOptions = {
  canvas: HTMLCanvasElement;
  mode: ToggleMode;
  speed: number;
  on: boolean;
};

export type GlassToggleScene = {
  setMode: (mode: ToggleMode) => void;
  setSpeed: (speed: number) => void;
  setSize: (size: number) => void;
  setOn: (on: boolean) => void;
  setPointer: (x: number, y: number) => void;
  resize: (width: number, height: number) => void;
  /* Pixel footprint of the rendered pill, so the DOM control can sit on it. */
  measureSwitch: () => { width: number; height: number };
  dispose: () => void;
};

type Palette = {
  /* The stage is a flat sweep, so it carries no light of its own. */
  backdrop: number;
  backdropFloor: number;
  shadow: number;
  glass: number;
  attenuationOff: number;
  attenuationOn: number;
  knob: number;
  knobTransmission: number;
  knobEnv: number;
  shellEmissive: number;
  filamentOn: number;
  filamentOff: number;
  key: number;
  keyIntensity: number;
  ambient: number;
  /* Studio built into the environment map rather than the backdrop. */
  envSkyTop: number;
  envSkyBottom: number;
  envFloor: number;
  envSoftbox: number;
  envIntensity: number;
};

const PALETTES: Record<ToggleMode, Palette> = {
  light: {
    backdrop: 0xe9ecf1,
    backdropFloor: 0xdfe3ea,
    shadow: 0.3,
    glass: 0xf4f8ff,
    attenuationOff: 0xdfe8f5,
    attenuationOn: 0x2f6bff,
    knob: 0xfdfeff,
    knobTransmission: 0.78,
    knobEnv: 1.3,
    shellEmissive: 0.02,
    filamentOn: 0x2f6bff,
    filamentOff: 0xc4ccd8,
    key: 0xffffff,
    keyIntensity: 0.18,
    ambient: 0.015,
    envSkyTop: 0xffffff,
    envSkyBottom: 0x9aa4b4,
    envFloor: 0x2a3040,
    envSoftbox: 0xffffff,
    envIntensity: 1.0,
  },
  dark: {
    backdrop: 0x0a0b0f,
    backdropFloor: 0x06070a,
    shadow: 0.55,
    glass: 0xe8eefb,
    attenuationOff: 0x7f93b8,
    attenuationOn: 0x2f6bff,
    knob: 0xf2f6ff,
    knobTransmission: 0.58,
    knobEnv: 2.3,
    shellEmissive: 0.62,
    filamentOn: 0x3b7bff,
    filamentOff: 0x161a24,
    key: 0xd8e4ff,
    keyIntensity: 0.12,
    ambient: 0.006,
    envSkyTop: 0xdde6f5,
    envSkyBottom: 0x1a2030,
    envFloor: 0x04050a,
    envSoftbox: 0xffffff,
    envIntensity: 0.9,
  },
};

/* Proportioned like a physical switch: the capsule is twice as wide as it is
   tall and the knob clears the wall by a constant seat. */
const TRACK_RADIUS = 0.6;
const TRACK_LENGTH = 1.2;
const THUMB_RADIUS = TRACK_RADIUS - 0.225;
const TRAVEL = (TRACK_LENGTH / 2) * 0.8;
const CONTROL_WIDTH = TRACK_LENGTH + TRACK_RADIUS * 2;
/* The pill keeps ~13% of the stage height and never more than 18% of its
   width, so all four variants read at the same size in the picker. */
const VIEW_HEIGHT = (TRACK_RADIUS * 2) / 0.13;
const WIDTH_SHARE = 0.18;

function srgb(hex: number) {
  return new THREE.Color(hex).convertSRGBToLinear();
}

function hex(value: number) {
  return `#${value.toString(16).padStart(6, "0")}`;
}

/* A studio, not a lamp rig — and an HDR one. A canvas environment tops out at
   1.0, which is why LDR reflections read as dull grey wash; these softboxes run
   12-40x above the sky, so the tone mapper blows them into the hard specular
   streaks that make a surface read as glass. Everything reflected comes from
   here, which is why the stage behind the control can stay flat. */
type Softbox = {
  u: number;
  v: number;
  halfWidth: number;
  halfHeight: number;
  intensity: number;
  feather: number;
  warm: number;
};

const SOFTBOXES: readonly Softbox[] = [
  /* Broad key, high and camera-left. */
  { u: 0.3, v: 0.2, halfWidth: 0.155, halfHeight: 0.135, intensity: 19, feather: 0.86, warm: 0.04 },
  /* Narrow strip: the hard highlight that runs the length of the shoulder. */
  { u: 0.52, v: 0.115, halfWidth: 0.17, halfHeight: 0.04, intensity: 15, feather: 0.9, warm: 0 },
  /* Cool rim from behind camera-right. */
  { u: 0.86, v: 0.31, halfWidth: 0.085, halfHeight: 0.09, intensity: 10, feather: 0.92, warm: -0.06 },
  /* Backlight, directly behind the subject at u=0.25. At the silhouette the
     reflection vector points straight away from the camera, so this is the
     source that draws the clean border all the way round — and it lights the
     transmission through the body at the same time. A ring around the horizon
     instead reflects onto the equator and paints a bar across the middle. */
  { u: 0.25, v: 0.5, halfWidth: 0.1, halfHeight: 0.2, intensity: 6.5, feather: 0.8, warm: 0 },
  /* A small hard source high and camera-left: the crisp catchlight on the
     sphere, which a broad softbox alone can never give. */
  { u: 0.95, v: 0.21, halfWidth: 0.028, halfHeight: 0.032, intensity: 70, feather: 0.45, warm: 0.02 },
  /* Low bounce so the underside is not dead black. */
  { u: 0.5, v: 0.82, halfWidth: 0.5, halfHeight: 0.22, intensity: 0.5, feather: 1, warm: 0.02 },
];

function smoothFalloff(distance: number, extent: number, feather: number) {
  const inner = extent * (1 - feather);
  if (distance <= inner) return 1;
  if (distance >= extent) return 0;
  const t = (distance - inner) / (extent - inner);
  return 1 - t * t * (3 - 2 * t);
}

function buildEnvironmentTexture(palette: Palette) {
  const width = 512;
  const height = 256;
  const data = new Float32Array(width * height * 4);
  const skyTop = srgb(palette.envSkyTop);
  const skyBottom = srgb(palette.envSkyBottom);
  const floor = srgb(palette.envFloor);
  const box = srgb(palette.envSoftbox);

  for (let y = 0; y < height; y += 1) {
    /* three's equirect maps texture V=0 to straight *down*, so the rows have
       to be walked in reverse for `v` to mean what the softbox list says it
       means. Without this the whole studio is upside down and the subject is
       lit from the floor. */
    const v = 1 - y / (height - 1);
    /* Sky above, floor below, blended across a wide band — a hard horizon
       reflects as a seam cutting the capsule in half. */
    const ground = smoothFalloff(Math.max(0, 0.62 - v), 0.34, 1);
    const skyMix = Math.min(1, v / 0.56);
    const skyR = skyTop.r + (skyBottom.r - skyTop.r) * skyMix;
    const skyG = skyTop.g + (skyBottom.g - skyTop.g) * skyMix;
    const skyB = skyTop.b + (skyBottom.b - skyTop.b) * skyMix;
    const fade = 1 - Math.max(0, v - 0.62) * 1.1;
    const baseR = skyR + (floor.r * fade - skyR) * ground;
    const baseG = skyG + (floor.g * fade - skyG) * ground;
    const baseB = skyB + (floor.b * fade - skyB) * ground;

    for (let x = 0; x < width; x += 1) {
      const u = x / (width - 1);
      let r = baseR;
      let g = baseG;
      let b = baseB;

      for (const light of SOFTBOXES) {
        /* Azimuth wraps, so measure the short way round. */
        let du = Math.abs(u - light.u);
        if (du > 0.5) du = 1 - du;
        const dv = Math.abs(v - light.v);
        /* Elliptical, not separable: a product of two 1D falloffs reflects as a
           rectangle with visible corners. */
        const radial = Math.sqrt(
          (du / light.halfWidth) * (du / light.halfWidth)
          + (dv / light.halfHeight) * (dv / light.halfHeight),
        );
        if (radial >= 1) continue;
        const strength = smoothFalloff(radial, 1, light.feather) * light.intensity;
        r += box.r * strength * (1 + light.warm);
        g += box.g * strength;
        b += box.b * strength * (1 - light.warm);
      }

      const index = (y * width + x) * 4;
      data[index] = r;
      data[index + 1] = g;
      data[index + 2] = b;
      data[index + 3] = 1;
    }
  }

  const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType);
  texture.mapping = THREE.EquirectangularReflectionMapping;
  texture.minFilter = THREE.LinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.generateMipmaps = false;
  texture.needsUpdate = true;
  return texture;
}

/* Micro-surface: real glass is never perfectly smooth, and the tiny variation
   is what stops a reflection from reading as a flat gradient. */
function buildMicroTextures() {
  const size = 512;
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const image = ctx.createImageData(size, size);
  const height = new Float32Array(size * size);
  let seed = 0x9e3779b9;
  const random = () => {
    seed ^= seed << 13;
    seed ^= seed >>> 17;
    seed ^= seed << 5;
    return ((seed >>> 0) % 100000) / 100000;
  };

  /* Two octaves of blurred value noise plus a faint polishing grain. */
  const coarse = new Float32Array(64 * 64);
  for (let i = 0; i < coarse.length; i += 1) coarse[i] = random();
  for (let y = 0; y < size; y += 1) {
    for (let x = 0; x < size; x += 1) {
      const u = (x / size) * 64;
      const v = (y / size) * 64;
      const x0 = Math.floor(u);
      const y0 = Math.floor(v);
      const fx = u - x0;
      const fy = v - y0;
      const sx = fx * fx * (3 - 2 * fx);
      const sy = fy * fy * (3 - 2 * fy);
      const at = (ax: number, ay: number) => coarse[((ay & 63) * 64) + (ax & 63)];
      const a = at(x0, y0);
      const b = at(x0 + 1, y0);
      const c = at(x0, y0 + 1);
      const d = at(x0 + 1, y0 + 1);
      const value = (a * (1 - sx) + b * sx) * (1 - sy) + (c * (1 - sx) + d * sx) * sy;
      const grain = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
      height[(y * size) + x] = value * 0.86 + (grain - Math.floor(grain)) * 0.14;
    }
  }

  for (let i = 0; i < height.length; i += 1) {
    /* Mostly smooth, with a shallow floor so roughness never reaches zero. */
    const value = 168 + height[i] * 78;
    image.data[i * 4] = value;
    image.data[i * 4 + 1] = value;
    image.data[i * 4 + 2] = value;
    image.data[i * 4 + 3] = 255;
  }
  ctx.putImageData(image, 0, 0);
  const roughness = new THREE.CanvasTexture(canvas);
  roughness.wrapS = THREE.RepeatWrapping;
  roughness.wrapT = THREE.RepeatWrapping;

  /* Derive a matching normal map so the micro-relief bends highlights too. */
  const normalCanvas = document.createElement("canvas");
  normalCanvas.width = size;
  normalCanvas.height = size;
  const normalCtx = normalCanvas.getContext("2d");
  if (!normalCtx) return { roughness, normal: null };
  const normalImage = normalCtx.createImageData(size, size);
  const at = (x: number, y: number) => height[((y + size) % size) * size + ((x + size) % size)];
  for (let y = 0; y < size; y += 1) {
    for (let x = 0; x < size; x += 1) {
      const dx = at(x + 1, y) - at(x - 1, y);
      const dy = at(x, y + 1) - at(x, y - 1);
      const index = (y * size + x) * 4;
      normalImage.data[index] = 128 + dx * 110;
      normalImage.data[index + 1] = 128 - dy * 110;
      normalImage.data[index + 2] = 255;
      normalImage.data[index + 3] = 255;
    }
  }
  normalCtx.putImageData(normalImage, 0, 0);
  const normal = new THREE.CanvasTexture(normalCanvas);
  normal.wrapS = THREE.RepeatWrapping;
  normal.wrapT = THREE.RepeatWrapping;
  return { roughness, normal };
}

const BACKDROP_VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

/* Deliberately featureless: a seamless sweep with one contact shadow. All the
   detail in the render comes off the environment map. */
const BACKDROP_FRAGMENT = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uAspect;
uniform float uShadow;
uniform vec3 uBackdrop;
uniform vec3 uFloor;

void main() {
  vec2 p = vec2((vUv.x - 0.5) * uAspect, vUv.y - 0.5);
  vec3 color = mix(uFloor, uBackdrop, smoothstep(-0.42, 0.16, p.y));

  /* Contact shadow, baked into the opaque sweep so the glass refracts it. */
  vec2 core = (p - vec2(0.0, -0.052)) / vec2(0.148, 0.038);
  vec2 spread = (p - vec2(0.0, -0.062)) / vec2(0.28, 0.1);
  float occlusion = exp(-dot(core, core) * 0.9) * 0.72 + exp(-dot(spread, spread) * 0.8) * 0.42;
  color *= 1.0 - clamp(occlusion, 0.0, 1.0) * uShadow;

  /* A dither step wide enough to kill banding on a near-flat sweep. */
  float dither = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
  color += (dither - 0.5) * 0.0035;

  gl_FragColor = vec4(color, 1.0);
}
`;

export function createGlassToggleScene({
  canvas,
  mode,
  speed,
  on,
}: GlassToggleSceneOptions): GlassToggleScene {
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.outputEncoding = THREE.sRGBEncoding;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1;

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(26, 1, 0.1, 40);
  camera.position.set(0, 0.22, 9.4);
  camera.lookAt(0, 0, 0);

  const pmrem = new THREE.PMREMGenerator(renderer);
  pmrem.compileEquirectangularShader();

  const backdropUniforms = {
    uAspect: { value: 1 },
    uShadow: { value: PALETTES[mode].shadow },
    uBackdrop: { value: srgb(PALETTES[mode].backdrop) },
    uFloor: { value: srgb(PALETTES[mode].backdropFloor) },
  };

  const backdrop = new THREE.Mesh(
    new THREE.PlaneGeometry(1, 1),
    new THREE.ShaderMaterial({
      uniforms: backdropUniforms,
      vertexShader: BACKDROP_VERTEX,
      fragmentShader: BACKDROP_FRAGMENT,
      depthWrite: false,
    }),
  );
  backdrop.position.z = -3.2;
  backdrop.renderOrder = -1;
  scene.add(backdrop);

  const micro = buildMicroTextures();
  const maxAnisotropy = renderer.capabilities.getMaxAnisotropy();
  if (micro?.roughness) {
    micro.roughness.anisotropy = maxAnisotropy;
    micro.roughness.repeat.set(3, 2);
  }
  if (micro?.normal) {
    micro.normal.anisotropy = maxAnisotropy;
    micro.normal.repeat.set(3, 2);
  }

  /* One rig, turned a few degrees so the capsule reads as a solid object. */
  const rig = new THREE.Group();
  rig.rotation.set(-0.05, 0.13, 0);
  scene.add(rig);

  const glassMaterial = new THREE.MeshPhysicalMaterial({
    color: srgb(PALETTES[mode].glass),
    metalness: 0,
    roughness: 0.115,
    transmission: 1,
    ior: 1.62,
    clearcoat: 1,
    clearcoatRoughness: 0.008,
    attenuationColor: srgb(PALETTES[mode].attenuationOff),
    attenuationDistance: 6,
    envMapIntensity: PALETTES[mode].envIntensity,
    specularIntensity: 1,
    roughnessMap: micro?.roughness ?? null,
    clearcoatNormalMap: micro?.normal ?? null,
    clearcoatNormalScale: new THREE.Vector2(0.13, 0.13),
  });
  /* @types/three 0.149 leaves these out of the parameters interface. */
  glassMaterial.thickness = 1.25;
  /* A whisper of thin-film so the shoulders pick up colour the way real
     coated glass does. */
  glassMaterial.iridescence = 0.2;
  glassMaterial.iridescenceIOR = 1.3;
  glassMaterial.iridescenceThicknessRange = [120, 420];
  glassMaterial.emissive = srgb(PALETTES[mode].filamentOn);
  glassMaterial.emissiveIntensity = 0;

  const track = new THREE.Mesh(
    new THREE.CapsuleGeometry(TRACK_RADIUS, TRACK_LENGTH, 64, 192),
    glassMaterial,
  );
  track.rotation.z = Math.PI / 2;
  /* Flattened in depth: a switch is a lozenge, not a capsule pill. */
  track.scale.set(1, 1, 0.82);
  rig.add(track);

  /* A slim lit filament lying in the bottom of the trough. It stays opaque so
     it reaches the transmission backdrop and the shell actually refracts it. */
  const filamentMaterial = new THREE.MeshStandardMaterial({
    color: srgb(PALETTES[mode].filamentOff),
    emissive: srgb(PALETTES[mode].filamentOn),
    emissiveIntensity: on ? 1 : 0,
    roughness: 0.34,
    metalness: 0,
  });
  const filament = new THREE.Mesh(
    new THREE.CapsuleGeometry(0.105, TRACK_LENGTH + 0.16, 16, 64),
    filamentMaterial,
  );
  filament.rotation.z = Math.PI / 2;
  filament.position.set(0, -0.31, -0.04);
  rig.add(filament);

  /* The knob is a frosted lens standing proud of the capsule, so it refracts
     both the sweep behind the stage and the filament inside the trough. */
  const knobMaterial = new THREE.MeshPhysicalMaterial({
    color: srgb(PALETTES[mode].knob),
    metalness: 0,
    roughness: 0.125,
    transmission: PALETTES[mode].knobTransmission,
    ior: 1.47,
    clearcoat: 1,
    clearcoatRoughness: 0.01,
    attenuationColor: srgb(PALETTES[mode].attenuationOff),
    attenuationDistance: 3,
    envMapIntensity: PALETTES[mode].envIntensity * PALETTES[mode].knobEnv,
    roughnessMap: micro?.roughness ?? null,
    clearcoatNormalMap: micro?.normal ?? null,
    clearcoatNormalScale: new THREE.Vector2(0.1, 0.1),
  });
  knobMaterial.thickness = 0.62;
  knobMaterial.iridescence = 0.12;
  knobMaterial.iridescenceIOR = 1.25;
  knobMaterial.iridescenceThicknessRange = [100, 380];

  const knob = new THREE.Mesh(new THREE.SphereGeometry(THUMB_RADIUS, 160, 96), knobMaterial);
  /* Left perfectly spherical — no depth squash. Its equator has to sit at the
     shell's front surface (TRACK_RADIUS * the depth flattening): any deeper and
     the silhouette is read through the curved wall, which refracts it into an
     egg. At the surface the outline is a true circle and the back half still
     sits down in the trough. */
  knob.position.z = TRACK_RADIUS * 0.82;
  rig.add(knob);

  /* IBL does nearly all the work; the key is only here to keep a terminator. */
  const ambient = new THREE.AmbientLight(srgb(0xffffff), PALETTES[mode].ambient);
  scene.add(ambient);

  const key = new THREE.DirectionalLight(srgb(PALETTES[mode].key), PALETTES[mode].keyIntensity);
  key.position.set(2.2, 3.4, 4);
  scene.add(key);

  let environmentTarget: THREE.WebGLRenderTarget | null = null;

  function applyEnvironment(next: ToggleMode) {
    const source = buildEnvironmentTexture(PALETTES[next]);
    if (!source) return;
    const nextTarget = pmrem.fromEquirectangular(source);
    source.dispose();
    environmentTarget?.dispose();
    environmentTarget = nextTarget;
    scene.environment = nextTarget.texture;
  }

  applyEnvironment(mode);

  let currentMode = mode;
  let currentSpeed = speed;
  let target = on ? 1 : 0;
  let progress = target;
  let velocity = 0;
  let onGlow = target;
  const attenuationOffColor = srgb(PALETTES[mode].attenuationOff);
  const attenuationOnColor = srgb(PALETTES[mode].attenuationOn);
  const filamentOffColor = srgb(PALETTES[mode].filamentOff);
  const filamentOnColor = srgb(PALETTES[mode].filamentOn);
  const pointer = new THREE.Vector2(0, 0);
  const smoothedPointer = new THREE.Vector2(0, 0);
  let width = 1;
  let height = 1;
  let baseDistance = camera.position.z;
  let sizeScale = 1;
  let disposed = false;
  let last = performance.now();
  let elapsed = 0;

  const reduceMotion = typeof window !== "undefined"
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  function applyPalette(next: ToggleMode) {
    const palette = PALETTES[next];
    backdropUniforms.uBackdrop.value = srgb(palette.backdrop);
    backdropUniforms.uFloor.value = srgb(palette.backdropFloor);
    backdropUniforms.uShadow.value = palette.shadow;
    glassMaterial.color = srgb(palette.glass);
    glassMaterial.envMapIntensity = palette.envIntensity;
    knobMaterial.color = srgb(palette.knob);
    knobMaterial.attenuationColor = srgb(palette.attenuationOff);
    knobMaterial.transmission = palette.knobTransmission;
    knobMaterial.envMapIntensity = palette.envIntensity * palette.knobEnv;
    attenuationOffColor.copy(srgb(palette.attenuationOff));
    attenuationOnColor.copy(srgb(palette.attenuationOn));
    filamentOffColor.copy(srgb(palette.filamentOff));
    filamentOnColor.copy(srgb(palette.filamentOn));
    filamentMaterial.emissive = srgb(palette.filamentOn);
    glassMaterial.emissive = srgb(palette.filamentOn);
    ambient.intensity = palette.ambient;
    key.color = srgb(palette.key);
    key.intensity = palette.keyIntensity;
    applyEnvironment(next);
  }

  function resize(nextWidth: number, nextHeight: number) {
    width = Math.max(1, Math.round(nextWidth));
    height = Math.max(1, Math.round(nextHeight));
    renderer.setSize(width, height, false);
    camera.aspect = width / height;

    /* Frame the control identically at every stage size by moving the camera
       rather than scaling the meshes — glass thickness is in world units, so a
       scaled pill would refract differently at every viewport. */
    const halfFov = (camera.fov * Math.PI) / 360;
    const visibleHeight = Math.max(VIEW_HEIGHT, CONTROL_WIDTH / (WIDTH_SHARE * camera.aspect)) / sizeScale;
    baseDistance = visibleHeight / (2 * Math.tan(halfFov));
    camera.position.z = baseDistance;
    camera.updateProjectionMatrix();

    const backdropHeight = 2 * (baseDistance - backdrop.position.z) * Math.tan(halfFov);
    backdrop.scale.set(backdropHeight * camera.aspect * 1.08, backdropHeight * 1.08, 1);
    backdropUniforms.uAspect.value = camera.aspect;
  }

  function measureSwitch() {
    const halfWidth = TRACK_LENGTH / 2 + TRACK_RADIUS;
    const left = new THREE.Vector3(-halfWidth, 0, 0).project(camera);
    const right = new THREE.Vector3(halfWidth, 0, 0).project(camera);
    const top = new THREE.Vector3(0, TRACK_RADIUS, 0).project(camera);
    const bottom = new THREE.Vector3(0, -TRACK_RADIUS, 0).project(camera);
    return {
      width: Math.abs(right.x - left.x) * 0.5 * width,
      height: Math.abs(top.y - bottom.y) * 0.5 * height,
    };
  }

  function frame(now: number) {
    if (disposed) return;
    const delta = Math.min(0.05, (now - last) / 1000);
    last = now;
    elapsed += delta * currentSpeed;

    /* Critically damped travel — it arrives without the rubbery overshoot a
       spring would give a glass object this heavy-looking. */
    const stiffness = reduceMotion ? 400 : 150;
    const damping = 2 * Math.sqrt(stiffness);
    const step = reduceMotion ? Math.min(delta, 0.016) : delta;
    velocity += (target - progress) * stiffness * step - velocity * damping * step;
    progress += velocity * step;

    onGlow += (target - onGlow) * Math.min(1, delta * 6);
    smoothedPointer.lerp(pointer, Math.min(1, delta * 3.2));

    knob.position.x = (progress * 2 - 1) * TRAVEL;
    knob.rotation.z = -progress * Math.PI * 0.9;
    knob.rotation.y = smoothedPointer.x * 0.3;

    filamentMaterial.emissiveIntensity = onGlow * (currentMode === "dark" ? 3.2 : 1.4);
    filamentMaterial.color.copy(filamentOffColor).lerp(filamentOnColor, onGlow * 0.85);
    glassMaterial.attenuationColor.copy(attenuationOffColor).lerp(attenuationOnColor, onGlow);
    glassMaterial.attenuationDistance = 6 - onGlow * 4.8;
    glassMaterial.emissiveIntensity = onGlow * PALETTES[currentMode].shellEmissive;

    camera.position.set(
      smoothedPointer.x * 0.5,
      0.22 + smoothedPointer.y * 0.32,
      baseDistance,
    );
    camera.lookAt(0, 0, 0);

    renderer.render(scene, camera);
    window.requestAnimationFrame(frame);
  }

  window.requestAnimationFrame(frame);

  return {
    setMode(next) {
      if (next === currentMode) return;
      currentMode = next;
      applyPalette(next);
    },
    setSpeed(next) {
      currentSpeed = next;
    },
    setSize(next) {
      sizeScale = next;
      resize(width, height);
    },
    setOn(next) {
      target = next ? 1 : 0;
    },
    setPointer(x, y) {
      pointer.set(x, y);
    },
    resize,
    measureSwitch,
    dispose() {
      disposed = true;
      environmentTarget?.dispose();
      pmrem.dispose();
      micro?.roughness?.dispose();
      micro?.normal?.dispose();
      scene.traverse((object) => {
        if (!(object instanceof THREE.Mesh)) return;
        object.geometry.dispose();
        const material = object.material;
        if (Array.isArray(material)) material.forEach((entry) => entry.dispose());
        else material.dispose();
      });
      renderer.dispose();
    },
  };
}

/* ===== src/shaders/skeuomorphic-toggle/glass-toggle.css (css) ===== */
.glass-toggle,
.glass-toggle *,
.glass-toggle *::before,
.glass-toggle *::after {
  box-sizing: border-box;
}

.glass-toggle {
  --glass-toggle-hue: 0deg;
  --glass-toggle-saturation: 1;
  --glass-toggle-brightness: 1;
  --glass-toggle-stage: #eef1f6;
  --glass-toggle-caption: rgba(17, 22, 34, 0.5);
  --glass-toggle-caption-on: rgba(17, 22, 34, 0.78);
  --glass-toggle-focus: rgba(58, 120, 255, 0.45);
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  isolation: isolate;
  container-type: size;
  background: var(--glass-toggle-stage);
  color-scheme: light;
  filter: hue-rotate(var(--glass-toggle-hue)) saturate(var(--glass-toggle-saturation)) brightness(var(--glass-toggle-brightness));
  font-family: ui-sans-serif, -apple-system, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.glass-toggle[data-mode="dark"] {
  --glass-toggle-stage: #08090d;
  --glass-toggle-caption: rgba(233, 238, 250, 0.46);
  --glass-toggle-caption-on: rgba(233, 238, 250, 0.82);
  --glass-toggle-focus: rgba(122, 168, 255, 0.5);
  color-scheme: dark;
}

.glass-toggle__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* A real control sitting exactly over the rendered pill, so the scene stays
   keyboard- and screen-reader-operable. */
.glass-toggle__switch {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 200px;
  height: 112px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  transform: translate(-50%, -50%);
  cursor: pointer;
  appearance: none;
}

.glass-toggle__switch:focus-visible {
  outline: none;
  box-shadow: 0 0 0 4px var(--glass-toggle-focus);
}

.glass-toggle__caption {
  position: absolute;
  left: 50%;
  /* The control keeps 13% of the stage height, so the caption tracks the centre
     rather than the floor — otherwise it drifts away in a tall frame. */
  top: calc(50% + max(52px, 11cqh));
  transform: translateX(-50%);
  display: flex;
  align-items: baseline;
  gap: 0.55em;
  margin: 0;
  font-size: clamp(11px, 1.9cqw, 14px);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--glass-toggle-caption);
  transition: color 0.45s ease;
  pointer-events: none;
}

.glass-toggle[data-state="on"] .glass-toggle__caption {
  color: var(--glass-toggle-caption-on);
}

.glass-toggle__caption b {
  font-weight: 500;
}

.glass-toggle__fallback {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 24px;
  text-align: center;
  font-size: 13px;
  line-height: 1.5;
  color: var(--glass-toggle-caption);
}

/* ===== src/shaders/skeuomorphic-toggle/ShaderToggle.tsx (tsx) ===== */
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";

import "./shader-toggle.css";
import { createShaderToggleScene, type ShaderToggleScene } from "./shaderToggleScene";
import { clamp, useToggleMode, type ToggleModePreference } from "./toggleMode";

export type ShaderToggleProps = {
  mode?: ToggleModePreference;
  defaultOn?: boolean;
  label?: string;
  speed?: number;
  size?: number;
  opacity?: number;
  hue?: number;
  saturation?: number;
  brightness?: number;
  onChange?: (on: boolean) => void;
  className?: string;
  style?: CSSProperties;
};

export const SHADER_TOGGLE_DEFAULTS = {
  mode: "auto" as ToggleModePreference,
  defaultOn: true,
  label: "Live Sync",
  speed: 1,
  size: 1,
  opacity: 1,
  hue: 0,
  saturation: 1,
  brightness: 1,
} as const;

export function ShaderToggle({
  mode = SHADER_TOGGLE_DEFAULTS.mode,
  defaultOn = SHADER_TOGGLE_DEFAULTS.defaultOn,
  label = SHADER_TOGGLE_DEFAULTS.label,
  speed = SHADER_TOGGLE_DEFAULTS.speed,
  size = SHADER_TOGGLE_DEFAULTS.size,
  opacity = SHADER_TOGGLE_DEFAULTS.opacity,
  hue = SHADER_TOGGLE_DEFAULTS.hue,
  saturation = SHADER_TOGGLE_DEFAULTS.saturation,
  brightness = SHADER_TOGGLE_DEFAULTS.brightness,
  onChange,
  className,
  style,
}: ShaderToggleProps) {
  const resolvedMode = useToggleMode(mode);
  const hostRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const switchRef = useRef<HTMLButtonElement>(null);
  const sceneRef = useRef<ShaderToggleScene | null>(null);
  const [on, setOn] = useState(defaultOn);
  const [supported, setSupported] = useState(true);

  const flip = useCallback(() => {
    setOn((current) => {
      const next = !current;
      onChange?.(next);
      return next;
    });
  }, [onChange]);

  const syncSwitchFootprint = useCallback(() => {
    const control = switchRef.current;
    const footprint = sceneRef.current?.measureSwitch();
    if (!control || !footprint) return;
    control.style.width = `${Math.round(footprint.width)}px`;
    control.style.height = `${Math.round(footprint.height)}px`;
  }, []);

  useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    if (!host || !canvas) return undefined;

    let scene: ShaderToggleScene;
    try {
      scene = createShaderToggleScene({
        canvas,
        mode: resolvedMode,
        speed,
        size: clamp(size, 0.35, 2.5),
        on: defaultOn,
      });
    } catch {
      setSupported(false);
      return undefined;
    }
    sceneRef.current = scene;

    const observer = new ResizeObserver((entries) => {
      const box = entries[0]?.contentRect;
      if (!box) return;
      scene.resize(box.width, box.height);
      syncSwitchFootprint();
    });
    observer.observe(host);
    scene.resize(host.clientWidth, host.clientHeight);
    syncSwitchFootprint();

    return () => {
      observer.disconnect();
      scene.dispose();
      sceneRef.current = null;
    };
    /* Built once for the host element; every knob below is pushed in. */
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    sceneRef.current?.setMode(resolvedMode);
  }, [resolvedMode]);

  useEffect(() => {
    sceneRef.current?.setSpeed(clamp(speed, 0, 3));
  }, [speed]);

  useEffect(() => {
    sceneRef.current?.setSize(clamp(size, 0.35, 2.5));
    syncSwitchFootprint();
  }, [size, syncSwitchFootprint]);

  useEffect(() => {
    sceneRef.current?.setOn(on);
  }, [on]);

  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const host = hostRef.current;
    if (!host) return;
    const rect = host.getBoundingClientRect();
    const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    const y = ((event.clientY - rect.top) / rect.height) * 2 - 1;
    sceneRef.current?.setPointer(clamp(x, -1, 1), clamp(-y, -1, 1));
  };

  const stageStyle = {
    "--shader-toggle-hue": `${clamp(hue, -180, 180)}deg`,
    "--shader-toggle-saturation": clamp(saturation, 0, 2),
    "--shader-toggle-brightness": clamp(brightness, 0.35, 1.65),
    opacity: clamp(opacity, 0.05, 1),
    ...style,
  } as CSSProperties;

  return (
    <div
      ref={hostRef}
      className={`shader-toggle${className ? ` ${className}` : ""}`}
      data-mode={resolvedMode}
      data-state={on ? "on" : "off"}
      style={stageStyle}
      onPointerMove={handlePointerMove}
      onPointerLeave={() => sceneRef.current?.setPointer(0, 0)}
      onClick={flip}
    >
      <canvas ref={canvasRef} className="shader-toggle__canvas" />
      <button
        ref={switchRef}
        type="button"
        className="shader-toggle__switch"
        role="switch"
        aria-checked={on}
        aria-label={label}
        onClick={(event) => {
          /* The whole stage is clickable, so keep the button from toggling twice. */
          event.stopPropagation();
          flip();
        }}
      />
      <p className="shader-toggle__caption">
        <span>{label}</span>
        <b>{on ? "On" : "Off"}</b>
      </p>
      {supported ? null : (
        <p className="shader-toggle__fallback">This variant needs WebGL, which this browser did not provide.</p>
      )}
    </div>
  );
}

/* ===== src/shaders/skeuomorphic-toggle/shaderToggleScene.ts (typescript) ===== */
import { buildShaderToggleFragment, SHADER_TOGGLE_VERTEX } from "./shaderToggleGlsl";
import type { ToggleMode } from "./toggleMode";

export type ShaderToggleSceneOptions = {
  canvas: HTMLCanvasElement;
  mode: ToggleMode;
  speed: number;
  size: number;
  on: boolean;
};

export type ShaderToggleScene = {
  setMode: (mode: ToggleMode) => void;
  setSpeed: (speed: number) => void;
  setSize: (size: number) => void;
  setOn: (on: boolean) => void;
  setPointer: (x: number, y: number) => void;
  resize: (width: number, height: number) => void;
  /* CSS-pixel footprint of the drawn capsule, so the DOM control matches it. */
  measureSwitch: () => { width: number; height: number };
  dispose: () => void;
};

/* Control-space constants, shared with the fragment shader. */
const TRACK_HALF_LENGTH = 1.35;
const TRACK_RADIUS = 1;
const HEIGHT_SHARE = 0.13;
const WIDTH_SHARE = 0.18;

function compile(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);
  if (!shader) throw new Error("Shader could not be created.");
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const log = gl.getShaderInfoLog(shader);
    gl.deleteShader(shader);
    throw new Error(`Shader failed to compile: ${log ?? "unknown error"}`);
  }
  return shader;
}

export function createShaderToggleScene({
  canvas,
  mode,
  speed,
  size,
  on,
}: ShaderToggleSceneOptions): ShaderToggleScene {
  const context = canvas.getContext("webgl", { antialias: true, alpha: false })
    ?? canvas.getContext("experimental-webgl", { antialias: true, alpha: false });
  if (!context) throw new Error("WebGL is unavailable.");
  const gl = context as WebGLRenderingContext;

  /* Derivatives give the distance fields a one-pixel edge at any scale; the
     shader falls back to a fixed width when the extension is absent. */
  const hasDerivatives = Boolean(gl.getExtension("OES_standard_derivatives"));

  const program = gl.createProgram();
  if (!program) throw new Error("Program could not be created.");
  const vertex = compile(gl, gl.VERTEX_SHADER, SHADER_TOGGLE_VERTEX);
  const fragment = compile(gl, gl.FRAGMENT_SHADER, buildShaderToggleFragment(hasDerivatives));
  gl.attachShader(program, vertex);
  gl.attachShader(program, fragment);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    const log = gl.getProgramInfoLog(program);
    throw new Error(`Program failed to link: ${log ?? "unknown error"}`);
  }
  gl.deleteShader(vertex);
  gl.deleteShader(fragment);
  gl.useProgram(program);

  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  const position = gl.getAttribLocation(program, "aPosition");
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);

  const uniforms = {
    res: gl.getUniformLocation(program, "uRes"),
    unit: gl.getUniformLocation(program, "uUnit"),
    time: gl.getUniformLocation(program, "uTime"),
    on: gl.getUniformLocation(program, "uOn"),
    progress: gl.getUniformLocation(program, "uProgress"),
    mode: gl.getUniformLocation(program, "uMode"),
    pointer: gl.getUniformLocation(program, "uPointer"),
  };

  const pixelRatio = Math.min(typeof window === "undefined" ? 1 : window.devicePixelRatio || 1, 2);
  const reduceMotion = typeof window !== "undefined"
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  let width = 1;
  let height = 1;
  let unit = 1;
  let sizeScale = size;
  let currentMode = mode;
  let currentSpeed = speed;
  let target = on ? 1 : 0;
  let progress = target;
  let velocity = 0;
  let glow = target;
  let pointerX = 0;
  let pointerY = 0;
  let smoothX = 0;
  let smoothY = 0;
  let elapsed = 0;
  let last = typeof performance === "undefined" ? 0 : performance.now();
  let disposed = false;
  let handle = 0;

  function layout() {
    /* The capsule keeps ~13% of the stage height and never spills past 18% of
       its width, so all four variants read at the same size in the picker. */
    unit = Math.min(
      (height * HEIGHT_SHARE) / (TRACK_RADIUS * 2),
      (width * WIDTH_SHARE) / ((TRACK_HALF_LENGTH + TRACK_RADIUS) * 2),
    ) * sizeScale;
  }

  function resize(nextWidth: number, nextHeight: number) {
    width = Math.max(1, Math.round(nextWidth));
    height = Math.max(1, Math.round(nextHeight));
    canvas.width = Math.round(width * pixelRatio);
    canvas.height = Math.round(height * pixelRatio);
    canvas.style.width = `${width}px`;
    canvas.style.height = `${height}px`;
    gl.viewport(0, 0, canvas.width, canvas.height);
    layout();
  }

  function measureSwitch() {
    return {
      width: unit * (TRACK_HALF_LENGTH + TRACK_RADIUS) * 2,
      height: unit * TRACK_RADIUS * 2,
    };
  }

  function frame(now: number) {
    if (disposed) return;
    const delta = Math.min(0.05, (now - last) / 1000);
    last = now;
    elapsed += delta * currentSpeed;

    const stiffness = reduceMotion ? 420 : 165;
    const damping = 2 * Math.sqrt(stiffness);
    const step = reduceMotion ? Math.min(delta, 0.016) : delta;
    velocity += (target - progress) * stiffness * step - velocity * damping * step;
    progress += velocity * step;
    glow += (target - glow) * Math.min(1, delta * 5.5);
    smoothX += (pointerX - smoothX) * Math.min(1, delta * 3.4);
    smoothY += (pointerY - smoothY) * Math.min(1, delta * 3.4);

    gl.uniform2f(uniforms.res, canvas.width, canvas.height);
    gl.uniform1f(uniforms.unit, unit * pixelRatio);
    gl.uniform1f(uniforms.time, elapsed);
    gl.uniform1f(uniforms.on, glow);
    gl.uniform1f(uniforms.progress, progress);
    gl.uniform1f(uniforms.mode, currentMode === "dark" ? 1 : 0);
    gl.uniform2f(uniforms.pointer, smoothX, smoothY);
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    handle = window.requestAnimationFrame(frame);
  }

  handle = window.requestAnimationFrame(frame);

  return {
    setMode(next) {
      currentMode = next;
    },
    setSpeed(next) {
      currentSpeed = next;
    },
    setSize(next) {
      sizeScale = next;
      layout();
    },
    setOn(next) {
      target = next ? 1 : 0;
    },
    setPointer(x, y) {
      pointerX = x;
      pointerY = y;
    },
    resize,
    measureSwitch,
    dispose() {
      disposed = true;
      window.cancelAnimationFrame(handle);
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
      /* Never force a context loss here: React remounts this effect on the same
         canvas in StrictMode, and a lost context can never be re-acquired. */
    },
  };
}

/* ===== src/shaders/skeuomorphic-toggle/shaderToggleGlsl.ts (typescript) ===== */
export const SHADER_TOGGLE_VERTEX = `
attribute vec2 aPosition;
void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;

/* One fragment shader draws the whole control. Every edge is antialiased from
   the screen-space derivative of its own distance field rather than a fixed
   smoothstep, so the borders stay one pixel crisp at any size — a constant
   width blurs the rim as soon as the control is scaled up. */
const SHADER_TOGGLE_FRAGMENT_BODY = `
precision highp float;

uniform vec2 uRes;
uniform float uUnit;
uniform float uTime;
uniform float uOn;
uniform float uProgress;
uniform float uMode;
uniform vec2 uPointer;

const float R = 1.0;
const float L = 1.35;
const float TH = 0.78;
const float PI = 3.14159265359;

float hash11(float n) {
  return fract(sin(n * 127.1) * 43758.5453123);
}

vec2 hash21(float n) {
  return fract(sin(vec2(n * 127.1, n * 311.7)) * 43758.5453123);
}

/* Screen-space hash. Feeding raw gl_FragCoord into a sin-based hash runs out of
   float precision at 1080p and bands into visible diagonal wedges, so wrap the
   coordinate into a small range first. */
float hash22(vec2 co) {
  return fract(sin(dot(mod(co, 512.0), vec2(12.9898, 78.233))) * 43758.5453123);
}

float valueNoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  float a = hash11(i.x + i.y * 57.0);
  float b = hash11(i.x + 1.0 + i.y * 57.0);
  float c = hash11(i.x + (i.y + 1.0) * 57.0);
  float d = hash11(i.x + 1.0 + (i.y + 1.0) * 57.0);
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

float fbm(vec2 p) {
  float value = 0.0;
  float amplitude = 0.5;
  for (int i = 0; i < 5; i++) {
    value += amplitude * valueNoise(p);
    p = p * 2.03 + 11.3;
    amplitude *= 0.5;
  }
  return value;
}

/* Ridged noise reads as filament rather than cloud — it is what gives the
   trough its threaded, fibrous look instead of a soft blur. */
float ridged(vec2 p) {
  float value = 0.0;
  float amplitude = 0.5;
  for (int i = 0; i < 4; i++) {
    float n = 1.0 - abs(valueNoise(p) * 2.0 - 1.0);
    value += amplitude * n * n;
    p = p * 2.11 + 7.7;
    amplitude *= 0.5;
  }
  return value;
}

/* Two rounds of domain warping. One fbm on its own drifts; warping it by
   another turns the flow into curling filaments that read as real motion. */
vec2 warp(vec2 p, float t) {
  vec2 q = vec2(fbm(p + vec2(0.0, t * 0.12)), fbm(p + vec2(5.2, 1.3) - t * 0.09));
  vec2 r = vec2(fbm(p + 3.4 * q + vec2(1.7, 9.2)), fbm(p + 3.4 * q + vec2(8.3, 2.8)));
  return p + 0.42 * r;
}

float sdCapsule(vec2 p, float halfLength, float radius) {
  p.x -= clamp(p.x, -halfLength, halfLength);
  return length(p) - radius;
}

float fw(float d) {
#ifdef HAS_DERIVATIVES
  return max(fwidth(d), 1e-5);
#else
  return 1.6 / uUnit;
#endif
}

/* Filled side of a distance field, one pixel of coverage at the boundary. */
float fill(float d) {
  float w = fw(d) * 0.72;
  return smoothstep(w, -w, d);
}

/* A stroke centred on the isoline, measured in control-space units. */
float stroke(float d, float halfWidth) {
  float w = fw(d) * 0.8;
  return smoothstep(halfWidth + w, halfWidth - w, abs(d));
}

float capsuleHalo(vec2 p, float spread) {
  return exp(-max(0.0, sdCapsule(p, L, R)) * spread);
}

void main() {
  vec2 frag = gl_FragCoord.xy - 0.5 * uRes;
  vec2 p = frag / uUnit;
  vec2 screen = frag / max(uRes.x, uRes.y);

  float dark = uMode;
  float energy = mix(0.16, 1.0, uOn);

  vec3 accent = mix(vec3(0.09, 0.32, 0.95), vec3(0.24, 0.55, 1.0), dark);
  vec3 accentWarm = mix(vec3(0.55, 0.18, 0.86), vec3(0.62, 0.32, 1.0), dark);
  vec3 accentCool = mix(vec3(0.06, 0.62, 0.92), vec3(0.36, 0.86, 1.0), dark);
  vec3 sparkColor = mix(vec3(0.15, 0.62, 0.98), vec3(0.55, 0.86, 1.0), dark);
  vec3 dormant = mix(vec3(0.66, 0.69, 0.75), vec3(0.16, 0.19, 0.25), dark);

  /* Stage. */
  vec3 skyTop = mix(vec3(0.965, 0.972, 0.984), vec3(0.055, 0.062, 0.086), dark);
  vec3 skyBottom = mix(vec3(0.886, 0.906, 0.941), vec3(0.016, 0.019, 0.031), dark);
  vec3 color = mix(skyBottom, skyTop, smoothstep(-0.55, 0.55, screen.y));

  /* A faint dot lattice keeps the empty stage from reading as flat paint. */
  vec2 lattice = fract(p * 1.8) - 0.5;
  float dots = smoothstep(0.09, 0.02, length(lattice));
  color += dots * mix(-0.05, 0.05, dark) * mix(vec3(0.32, 0.4, 0.62), accent, dark);

  /* Bloom, one exponential per channel for a touch of chromatic spread. */
  float haloR = capsuleHalo(p * 0.985, 1.35);
  float haloG = capsuleHalo(p, 1.5);
  float haloB = capsuleHalo(p * 1.015, 1.65);
  vec3 halo = vec3(haloR, haloG, haloB) * mix(accent, accentWarm, 0.35);
  color += halo * energy * mix(0.045, 0.3, dark);

  float d = sdCapsule(p, L, R);
  float inside = fill(d);

  /* ---- Trough interior ---------------------------------------------- */
  vec2 flowP = vec2(p.x * 0.72 - uTime * 0.3, p.y * 1.25);
  vec2 w = warp(flowP, uTime);
  float plasma = fbm(w * 1.5);
  float threads = ridged(vec2(w.x * 2.2, w.y * 3.4 + uTime * 0.16));

  /* Brushed striations along the travel axis. */
  float brushed = 0.5 + 0.5 * sin(p.y * 74.0 + fbm(w * 3.0) * 9.0);
  brushed *= 0.5 + 0.5 * valueNoise(vec2(p.x * 26.0, p.y * 4.0));

  /* Interference bands, following the warp so they curve with the flow. */
  float caustic = pow(abs(sin(w.x * 3.6 - uTime * 1.5 + threads * 2.4)), 9.0);

  /* A micro lattice the light catches, crisp because it is derivative-AA'd. */
  vec2 cell = fract(p * vec2(22.0, 19.0)) - 0.5;
  float micro = stroke(length(cell) - 0.2, 0.035);

  /* A travelling charge front. */
  float streak = pow(max(0.0, sin(p.x * 2.1 - uTime * 2.6 + plasma * 3.4)), 7.0);

  vec3 interior = mix(dormant, mix(accent, accentWarm, plasma * 0.42), uOn);
  interior *= mix(0.24, 0.86, plasma) * energy;
  interior += mix(accentCool, vec3(1.0), 0.25) * threads * threads * uOn * 0.15;
  interior += mix(accent, vec3(1.0), 0.35) * caustic * uOn * 0.2;
  interior += accentCool * micro * mix(0.018, 0.05, dark) * energy;
  interior *= 0.82 + 0.18 * brushed;
  interior += mix(accent, vec3(1.0), 0.35) * streak * uOn * 0.34;

  /* Powered down still has to read as a slot, not a hole — on a light stage an
     unlit trough at the on-state's exposure goes nearly black. */
  vec3 restColor = dormant * mix(0.6, 0.2, dark) * (0.72 + 0.4 * plasma);
  restColor += accentCool * micro * mix(0.02, 0.045, dark);
  restColor *= 0.88 + 0.12 * brushed;
  interior = mix(restColor, interior, uOn);

  /* Inner shading: the capsule is still a physical trough. */
  float trough = smoothstep(-R, R * 0.35, p.y);
  interior *= mix(0.42, 1.2, trough);

  color = mix(color, interior, inside);

  /* ---- Borders ------------------------------------------------------- */
  /* A hairline seat just outside the wall reads as the shadow the rim casts. */
  color *= 1.0 - stroke(d - 0.028, 0.026) * mix(0.16, 0.3, dark);
  /* The lit rim itself: one crisp stroke on the boundary. */
  vec3 rimColor = mix(accent, vec3(1.0), mix(0.5, 0.28, dark));
  color += rimColor * stroke(d, 0.013) * mix(0.34, 0.66, dark) * energy;
  /* An inner bevel a hair inside the wall, brightest along the top shoulder. */
  float bevel = stroke(d + 0.062, 0.02) * smoothstep(-0.35, 0.85, p.y / R);
  color += mix(accentCool, vec3(1.0), 0.5) * bevel * mix(0.2, 0.34, dark) * energy;
  /* Soft glow bleeding inward from the wall. */
  color += rimColor * exp(-abs(d) * 18.0) * mix(0.06, 0.13, dark) * energy * inside;

  /* ---- Sparks -------------------------------------------------------- */
  /* A dense fine mist rather than a handful of blobs. Phase and lane come from
     independent hashes — sharing one makes the field collapse into a visible
     lattice — and each mote carries its own size, speed and brightness so the
     drift never reads as one animated sheet. */
  vec3 sparks = vec3(0.0);
  for (int i = 0; i < 64; i++) {
    float fi = float(i);
    vec2 h = hash21(fi + 3.7);
    float phase = hash11(fi * 7.13 + 1.7);
    float weight = hash11(fi * 3.31 + 5.9);
    float life = fract(uTime * (0.2 + h.x * 0.42) * mix(0.35, 1.0, uOn) + phase);
    float x = mix(-L - R * 0.55, L + R * 0.55, life);
    float y = (h.y - 0.5) * 1.52 * (0.32 + 0.68 * sin(life * PI)) + sin(life * 6.4 + fi) * 0.06;
    vec2 sp = p - vec2(x, y);
    /* Stretch along travel so each one carries a comet tail. */
    sp.x *= 0.45;
    float fade = sin(life * PI);
    /* Tight kernels: the big ones were reading as glowing pills. */
    float grain = 1500.0 + 5200.0 * weight;
    float core = exp(-dot(sp, sp) * grain * 3.0);
    float tail = exp(-dot(sp, sp) * grain);
    float brightness = 0.35 + 0.65 * weight;
    sparks += mix(sparkColor, vec3(1.0), 0.6 * fade) * (tail * 0.55 + core) * fade * brightness;
  }
  color += sparks * mix(0.3, 1.15, uOn) * mix(0.85, 1.15, dark) * inside;

  /* ---- Thumb --------------------------------------------------------- */
  float thumbX = mix(-L, L, uProgress);
  vec2 tp = p - vec2(thumbX, 0.0);
  float td = length(tp) - TH;
  float thumbIn = fill(td);
  vec2 disc = tp / TH;
  float discLength = min(1.0, length(disc));
  vec3 normal = normalize(vec3(disc, sqrt(max(1e-4, 1.0 - discLength * discLength))));

  /* Micro relief on the shell, sampled in the sphere's own frame so it wraps
     with the surface instead of sliding across it. */
  vec2 shellUv = vec2(atan(normal.y, normal.x) * 1.6, normal.z * 2.4);
  float relief = valueNoise(shellUv * 15.0) * 0.6 + valueNoise(shellUv * 41.0) * 0.4;
  vec3 bumped = normalize(normal + vec3((relief - 0.5) * 0.035, (relief - 0.5) * 0.035, 0.0));

  vec3 light = normalize(vec3(-0.42 + uPointer.x * 0.35, 0.58 + uPointer.y * 0.35, 0.8));
  /* A cool fill from the opposite side, and a rim from behind: one lamp on a
     sphere gives a flat coin, three give it form. */
  vec3 fillDir = normalize(vec3(0.62, -0.28, 0.55));
  vec3 rimDir = normalize(vec3(0.34, 0.5, -0.72));
  float diffuse = max(0.0, dot(bumped, light));
  float specular = pow(max(0.0, dot(reflect(-light, bumped), vec3(0.0, 0.0, 1.0))), 120.0);
  /* A second, stretched lobe: the brushed anisotropic streak. */
  vec3 stretched = normalize(vec3(bumped.x * 0.28, bumped.y, bumped.z));
  float sheen = pow(max(0.0, dot(reflect(-light, stretched), vec3(0.0, 0.0, 1.0))), 16.0);
  float fillTerm = max(0.0, dot(bumped, fillDir));
  float rimTerm = pow(max(0.0, dot(bumped, rimDir)), 2.2) * pow(1.0 - max(0.0, normal.z), 1.6);
  float fresnel = pow(1.0 - max(0.0, normal.z), 2.6);

  vec3 thumbBase = mix(vec3(0.96, 0.97, 0.99), vec3(0.78, 0.82, 0.9), dark);
  vec3 thumbColor = thumbBase * (0.17 + 0.86 * diffuse * diffuse);
  thumbColor += thumbBase * mix(accentCool, vec3(1.0), 0.35) * fillTerm * 0.3;
  thumbColor += mix(accentCool, vec3(1.0), 0.5) * rimTerm * mix(0.3, 0.55, dark);
  thumbColor *= 0.975 + 0.05 * relief;
  thumbColor += mix(accent, accentWarm, 0.4) * fresnel * mix(0.34, 0.6, dark) * energy;
  thumbColor += vec3(1.0) * specular * 1.6;
  thumbColor += mix(vec3(1.0), accentCool, 0.35) * sheen * 0.13;
  thumbColor += mix(accent, accentWarm, 0.6) * uOn * 0.1;
  /* Roll the highlights off instead of letting them clip — a clipped sphere
     reads as a white sticker rather than a lit object. */
  /* Bounce off the lit trough onto the underside. */
  thumbColor += accentCool * smoothstep(0.2, -0.9, disc.y) * uOn * mix(0.1, 0.16, dark);
  thumbColor = thumbColor / (1.0 + thumbColor * 0.22);

  /* Contact shadow under the thumb. */
  float contact = exp(-dot(tp * vec2(0.8, 1.6), tp * vec2(0.8, 1.6)) * 1.9);
  color *= 1.0 - contact * 0.42 * inside;

  color = mix(color, thumbColor, thumbIn);
  /* Crisp terminator ring so the thumb keeps a defined edge. */
  color += mix(vec3(1.0), accentCool, 0.4) * stroke(td, 0.01) * mix(0.15, 0.26, dark);
  color += mix(accent, accentWarm, 0.35) * exp(-max(0.0, td) * 7.0) * uOn * 0.22 * inside;

  /* ---- Grade --------------------------------------------------------- */
  color *= mix(0.86, 1.0, smoothstep(1.0, 0.2, length(screen * vec2(1.0, 1.25))));
  float grain = hash22(gl_FragCoord.xy + vec2(mod(uTime * 61.0, 512.0), mod(uTime * 37.0, 512.0)));
  color += (grain - 0.5) * mix(0.014, 0.03, dark);

  gl_FragColor = vec4(max(color, 0.0), 1.0);
}
`;

export function buildShaderToggleFragment(hasDerivatives: boolean) {
  /* The extension pragma has to lead the source, before any other directive. */
  const prefix = hasDerivatives
    ? "#extension GL_OES_standard_derivatives : enablen#define HAS_DERIVATIVESn"
    : "";
  return prefix + SHADER_TOGGLE_FRAGMENT_BODY;
}

/* ===== src/shaders/skeuomorphic-toggle/shader-toggle.css (css) ===== */
.shader-toggle,
.shader-toggle *,
.shader-toggle *::before,
.shader-toggle *::after {
  box-sizing: border-box;
}

.shader-toggle {
  --shader-toggle-hue: 0deg;
  --shader-toggle-saturation: 1;
  --shader-toggle-brightness: 1;
  --shader-toggle-stage: #f4f6fa;
  --shader-toggle-caption: rgba(17, 22, 34, 0.5);
  --shader-toggle-caption-on: rgba(17, 22, 34, 0.78);
  --shader-toggle-focus: rgba(58, 120, 255, 0.45);
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  isolation: isolate;
  container-type: size;
  background: var(--shader-toggle-stage);
  color-scheme: light;
  filter: hue-rotate(var(--shader-toggle-hue)) saturate(var(--shader-toggle-saturation)) brightness(var(--shader-toggle-brightness));
  font-family: ui-sans-serif, -apple-system, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.shader-toggle[data-mode="dark"] {
  --shader-toggle-stage: #06070b;
  --shader-toggle-caption: rgba(233, 238, 250, 0.46);
  --shader-toggle-caption-on: rgba(233, 238, 250, 0.82);
  --shader-toggle-focus: rgba(122, 168, 255, 0.5);
  color-scheme: dark;
}

.shader-toggle__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* A real control sitting exactly over the drawn capsule, so the scene stays
   keyboard- and screen-reader-operable. */
.shader-toggle__switch {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 200px;
  height: 112px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  transform: translate(-50%, -50%);
  cursor: pointer;
  appearance: none;
}

.shader-toggle__switch:focus-visible {
  outline: none;
  box-shadow: 0 0 0 4px var(--shader-toggle-focus);
}

.shader-toggle__caption {
  position: absolute;
  left: 50%;
  /* The control keeps 13% of the stage height, so the caption tracks the centre
     rather than the floor — otherwise it drifts away in a tall frame. */
  top: calc(50% + max(52px, 11cqh));
  transform: translateX(-50%);
  display: flex;
  align-items: baseline;
  gap: 0.55em;
  margin: 0;
  font-size: clamp(11px, 1.9cqw, 14px);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--shader-toggle-caption);
  transition: color 0.45s ease;
  pointer-events: none;
}

.shader-toggle[data-state="on"] .shader-toggle__caption {
  color: var(--shader-toggle-caption-on);
}

.shader-toggle__caption b {
  font-weight: 500;
}

.shader-toggle__fallback {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 24px;
  text-align: center;
  font-size: 13px;
  line-height: 1.5;
  color: var(--shader-toggle-caption);
}

/* ===== src/shaders/skeuomorphic-toggle/toggleMode.ts (typescript) ===== */
import { useEffect, useState } from "react";

export type ToggleMode = "dark" | "light";
export type ToggleModePreference = ToggleMode | "auto";

export function readAutomaticToggleMode(): ToggleMode {
  if (typeof document === "undefined" || typeof window === "undefined") return "dark";
  const root = document.documentElement;
  const declared = root.dataset.theme ?? root.dataset.scheme;
  if (declared === "light" || declared === "dark") return declared;
  if (root.classList.contains("light")) return "light";
  if (root.classList.contains("dark")) return "dark";
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

/* Mirrors the packaged export's automatic appearance: the site's own
   data-theme/data-scheme attribute wins, then the system preference. */
export function useToggleMode(preference: ToggleModePreference): ToggleMode {
  const enabled = preference === "auto";
  const [automatic, setAutomatic] = useState<ToggleMode>(readAutomaticToggleMode);

  useEffect(() => {
    if (!enabled || typeof document === "undefined" || typeof window === "undefined") return undefined;
    const media = window.matchMedia("(prefers-color-scheme: dark)");
    const update = () => setAutomatic(readAutomaticToggleMode());
    const observer = new MutationObserver(update);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-scheme", "data-theme"],
    });
    media.addEventListener("change", update);
    update();
    return () => {
      observer.disconnect();
      media.removeEventListener("change", update);
    };
  }, [enabled]);

  if (preference === "light" || preference === "dark") return preference;
  return automatic;
}

export function clamp(value: number, minimum: number, maximum: number) {
  return Math.min(maximum, Math.max(minimum, value));
}

/* ===== src/shaders/neuform-isolated/sources/skeuomorphic-toggle.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Skeuomorphic Toggle UI - Blue Remix</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
</head>
<body class="bg-slate-50 flex items-center justify-center min-h-screen p-4 antialiased" style="background-image: linear-gradient(to right, #e2e8f0 1px, transparent 1px), linear-gradient(to bottom, #e2e8f0 1px, transparent 1px); background-size: 120px 120px; background-position: center center;">

    <!-- Card Container with Premium Gradient Border -->
    <div id="toggle-card" class="relative rounded-[2rem] w-full max-w-[380px] shadow-[0_25px_50px_-12px_rgba(15,23,42,0.12)] p-[1px] bg-gradient-to-b from-slate-200/80 via-slate-100/50 to-slate-50/20 z-10">
        
        <div class="bg-white rounded-[calc(2rem-1px)] w-full h-full overflow-hidden flex flex-col relative">
            
            <!-- Top Section: 3D Environment -->
            <div class="relative h-64 w-full flex items-center justify-center overflow-hidden" style="background: radial-gradient(circle at 50% 50%, #f0f7ff 0%, #ffffff 80%);">
                
                <!-- Light Rays -->
                <div class="absolute inset-0 pointer-events-none opacity-40" style="background: repeating-conic-gradient(from 0deg at 50% -10%, rgba(59, 130, 246, 0.04) 0deg, transparent 4deg, transparent 8deg, rgba(59, 130, 246, 0.04) 12deg);"></div>

                <!-- Soft Overlay Shadows -->
                <div class="absolute inset-0 pointer-events-none" style="box-shadow: inset 0 20px 40px -10px rgba(0,0,0,0.04), inset 0 -20px 40px -10px rgba(59, 130, 246, 0.05);"></div>

                <!-- Simulated WebGL Waves / Ripples -->
                <div class="absolute inset-0 flex items-center justify-center pointer-events-none">
                    <!-- Ambient Wave Base -->
                    <div class="absolute bottom-0 w-full h-32" style="background: radial-gradient(ellipse at 50% 100%, rgba(59, 130, 246, 0.08) 0%, transparent 70%);"></div>
                    
                    <!-- Outer Ripple -->
                    <div class="absolute w-[320px] h-[160px] rounded-[80px]" style="border: 1px solid rgba(96, 165, 250, 0.05); box-shadow: 0 0 30px rgba(96, 165, 250, 0.06), inset 0 0 20px rgba(96, 165, 250, 0.03);"></div>
                    <!-- Middle Ripple -->
                    <div class="absolute w-[260px] h-[120px] rounded-[60px]" style="border: 1px solid rgba(96, 165, 250, 0.12); box-shadow: 0 0 20px rgba(96, 165, 250, 0.1), inset 0 0 15px rgba(96, 165, 250, 0.05);"></div>
                    <!-- Inner Ripple -->
                    <div class="absolute w-[220px] h-[90px] rounded-[45px]" style="border: 1px solid rgba(96, 165, 250, 0.2); box-shadow: 0 0 15px rgba(96, 165, 250, 0.18), inset 0 0 10px rgba(96, 165, 250, 0.1);"></div>
                </div>

                <!-- Dotted Pattern Arc -->
                <div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[180px] h-[120px] pointer-events-none" style="mask-image: radial-gradient(circle at bottom, transparent 50px, black 51px); -webkit-mask-image: radial-gradient(circle at bottom, transparent 50px, black 51px);">
                    <div class="w-full h-full opacity-40" style="background-image: radial-gradient(rgba(59, 130, 246, 0.7) 1.5px, transparent 1.5px); background-size: 8px 8px; mask-image: linear-gradient(to bottom, black, transparent); -webkit-mask-image: linear-gradient(to bottom, black, transparent);"></div>
                </div>

                <!-- Skeuomorphic Toggle Control -->
                <div id="skeuomorphic-toggle" role="switch" aria-checked="true" tabindex="0" class="relative z-10 w-48 h-16 rounded-full p-1.5 cursor-pointer select-none" style="background: repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px), linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%); box-shadow: inset 0 4px 8px rgba(0,0,0,0.1), inset 0 -2px 4px rgba(255,255,255,0.7), 0 0 0 6px rgba(239, 246, 255, 0.8), 0 0 25px 5px rgba(96, 165, 250, 0.4); border: 1px solid #60a5fa; transition: background 0.35s ease, box-shadow 0.35s ease, border-color 0.35s ease;">
                    <div id="skeuomorphic-thumb" class="absolute top-1.5 left-1.5 w-[116px] h-[calc(100%-0.75rem)] rounded-full flex items-center justify-center" style="background: linear-gradient(180deg, #ffffff 0%, #f4f8ff 100%); box-shadow: 0 10px 20px -4px rgba(37, 99, 235, 0.3), 0 4px 6px -2px rgba(0,0,0,0.04), inset 0 3px 4px rgba(255,255,255,1), inset 0 -2px 4px rgba(96, 165, 250, 0.2); border: 1px solid #e0edfa; transform: translateX(64px); transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.35s ease;">
                        <span class="text-sm font-normal text-slate-800 tracking-wide" style="text-shadow: 0 1px 1px rgba(255,255,255,0.9); transition: color 0.35s ease, opacity 0.35s ease;">Live Sync</span>
                    </div>
                </div>
            </div>

            <!-- Bottom Section: Content -->
            <div class="p-7 pb-9 bg-white z-20 relative">
                <!-- Icon -->
                <div class="w-8 h-8 rounded-[0.6rem] mb-4 flex items-center justify-center" style="background: linear-gradient(135deg, #60a5fa 0%, #2563eb 100%); box-shadow: inset 0 2px 2px rgba(255,255,255,0.3), inset 0 -2px 4px rgba(0,0,0,0.1), 0 4px 8px -2px rgba(37, 99, 235, 0.4); border: 1px solid #1d4ed8;">
                    <iconify-icon icon="solar:server-square-linear" class="text-white text-base" style="filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2));"></iconify-icon>
                </div>
                
                <!-- Text -->
                <h2 class="text-xl font-normal tracking-tight text-slate-900 mb-1.5 mask-reveal">Real-time Updates</h2>
                <p class="text-sm text-slate-500 leading-relaxed mask-reveal">Seamless background integration keeps everything current.</p>
            </div>
            
        </div>
    </div>

    <script>
        (function () {
            var toggle = document.getElementById('skeuomorphic-toggle');
            var thumb = document.getElementById('skeuomorphic-thumb');
            var label = thumb && thumb.querySelector('span');
            if (!toggle || !thumb) return;

            var ON_BG = 'repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px), linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%)';
            var OFF_BG = 'repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px), linear-gradient(180deg, #e2e8f0 0%, #cbd5e1 100%)';
            var ON_SHADOW = 'inset 0 4px 8px rgba(0,0,0,0.1), inset 0 -2px 4px rgba(255,255,255,0.7), 0 0 0 6px rgba(239, 246, 255, 0.8), 0 0 25px 5px rgba(96, 165, 250, 0.4)';
            var OFF_SHADOW = 'inset 0 4px 8px rgba(0,0,0,0.12), inset 0 -2px 4px rgba(255,255,255,0.55), 0 0 0 6px rgba(241, 245, 249, 0.9), 0 0 18px 2px rgba(148, 163, 184, 0.25)';
            var ON_BORDER = '#60a5fa';
            var OFF_BORDER = '#94a3b8';
            var ON_THUMB_SHADOW = '0 10px 20px -4px rgba(37, 99, 235, 0.3), 0 4px 6px -2px rgba(0,0,0,0.04), inset 0 3px 4px rgba(255,255,255,1), inset 0 -2px 4px rgba(96, 165, 250, 0.2)';
            var OFF_THUMB_SHADOW = '0 8px 16px -4px rgba(15, 23, 42, 0.18), 0 3px 5px -2px rgba(0,0,0,0.05), inset 0 3px 4px rgba(255,255,255,1), inset 0 -2px 4px rgba(148, 163, 184, 0.25)';

            function travel() {
                return Math.max(0, toggle.clientWidth - thumb.clientWidth - 12);
            }

            function apply(on) {
                toggle.setAttribute('aria-checked', on ? 'true' : 'false');
                toggle.style.background = on ? ON_BG : OFF_BG;
                toggle.style.boxShadow = on ? ON_SHADOW : OFF_SHADOW;
                toggle.style.borderColor = on ? ON_BORDER : OFF_BORDER;
                thumb.style.transform = 'translateX(' + (on ? travel() : 0) + 'px)';
                thumb.style.boxShadow = on ? ON_THUMB_SHADOW : OFF_THUMB_SHADOW;
                if (label) {
                    label.style.opacity = on ? '1' : '0.72';
                    label.style.color = on ? '#1e293b' : '#475569';
                }
            }

            function flip() {
                apply(toggle.getAttribute('aria-checked') !== 'true');
            }

            apply(true);
            toggle.addEventListener('click', flip);
            toggle.addEventListener('keydown', function (e) {
                if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    flip();
                }
            });
            window.addEventListener('resize', function () {
                apply(toggle.getAttribute('aria-checked') === 'true');
            });
        })();

        document.addEventListener("DOMContentLoaded", () => {
            if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') return;
            gsap.registerPlugin(ScrollTrigger);
            
            document.querySelectorAll('.mask-reveal').forEach(el => {
                const text = el.innerText;
                el.innerHTML = '';
                
                text.split(/(s+)/).forEach(part => {
                    if (!part.trim()) {
                        el.appendChild(document.createTextNode(part));
                        return;
                    }
                    
                    const wrapper = document.createElement('span');
                    wrapper.style.display = 'inline-block';
                    wrapper.style.overflow = 'hidden';
                    wrapper.style.verticalAlign = 'bottom';
                    
                    const inner = document.createElement('span');
                    inner.innerText = part;
                    inner.style.display = 'inline-block';
                    inner.className = 'reveal-word';
                    inner.style.transform = 'translateY(110%)';
                    inner.style.opacity = '0';
                    
                    wrapper.appendChild(inner);
                    el.appendChild(wrapper);
                });
                
                gsap.to(el.querySelectorAll('.reveal-word'), {
                    y: '0%',
                    opacity: 1,
                    duration: 0.6,
                    stagger: 0.04,
                    ease: 'power3.out',
                    scrollTrigger: {
                        trigger: el,
                        start: 'top 95%',
                    }
                });
            });
        });
    </script>
</body>
</html>
[/hidecontent]
← 返回首页