"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface EvilEyeButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Label used when no children are provided. */
label?: React.ReactNode;
/** Iris and glow color, as a hex string. */
eyeColor?: string;
/** Brightness multiplier for the flames. */
intensity?: number;
/** Size of the slit pupil. Lower values make it tighter. */
pupilSize?: number;
/** Width of the iris ring. */
irisWidth?: number;
/** Strength of the glow around the iris. */
glowIntensity?: number;
/** Zoom of the eye inside the button. Lower values zoom in. */
scale?: number;
/** Scale of the flame noise. */
noiseScale?: number;
/** How far the pupil tracks the cursor, 0 to 1. */
pupilFollow?: number;
/** Speed of the flames licking around the iris. */
flameSpeed?: number;
/** Blink every few seconds on its own, as well as on press. */
blink?: boolean;
/** Extra classes for the eye layer behind the label. */
eyeClassName?: string;
/** Extra classes for the label. */
labelClassName?: string;
}
/* ---------- Shader ---------- */
const VERTEX = `
attribute vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;
// The React Bits evil eye: polar-mapped noise makes flames around an iris
// ring, with a vertical slit pupil. uFocus narrows the slit and stokes the
// flames while hovered.
const FRAGMENT = `
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform sampler2D uNoise;
uniform float uPupilSize;
uniform float uIrisWidth;
uniform float uGlow;
uniform float uIntensity;
uniform float uScale;
uniform float uNoiseScale;
uniform vec2 uLook;
uniform float uFocus;
uniform vec3 uEyeColor;
uniform vec3 uBgColor;
void main() {
vec2 uv = (gl_FragCoord.xy * 2.0 - uRes) / uRes.y;
uv /= uScale;
float ft = uTime;
vec2 polarUv = vec2(length(uv) * 2.0, (2.0 * atan(uv.x, uv.y)) / 6.28 * 0.3);
vec4 noiseA = texture2D(uNoise, polarUv * vec2(0.2, 7.0) * uNoiseScale + vec2(-ft * 0.1, 0.0));
vec4 noiseB = texture2D(uNoise, polarUv * vec2(0.3, 4.0) * uNoiseScale + vec2(-ft * 0.2, 0.0));
vec4 noiseC = texture2D(uNoise, polarUv * vec2(0.1, 5.0) * uNoiseScale + vec2(-ft * 0.1, 0.0));
float distanceMask = 1.0 - length(uv);
float innerRing = clamp(-1.0 * ((distanceMask - 0.7) / uIrisWidth), 0.0, 1.0);
innerRing = (innerRing * distanceMask - 0.2) / 0.28;
innerRing += noiseA.r - 0.5;
innerRing *= 1.3;
innerRing = clamp(innerRing, 0.0, 1.0);
float outerRing = clamp(-1.0 * ((distanceMask - 0.5) / 0.2), 0.0, 1.0);
outerRing = (outerRing * distanceMask - 0.1) / 0.38;
outerRing += noiseC.r - 0.5;
outerRing *= 1.3;
outerRing = clamp(outerRing, 0.0, 1.0);
innerRing += outerRing;
float innerEye = distanceMask - 0.2;
innerEye *= noiseB.r * 2.0;
vec2 pupilUv = uv - uLook * 0.32;
float slit = mix(9.0, 14.0, uFocus);
float pupil = 1.0 - length(pupilUv * vec2(slit, 2.3));
pupil *= uPupilSize;
pupil = clamp(pupil, 0.0, 1.0);
pupil /= 0.35;
float outerEyeGlow = 1.0 - length(uv * vec2(0.5, 1.5));
outerEyeGlow = clamp(outerEyeGlow + 0.5, 0.0, 1.0);
outerEyeGlow += noiseC.r - 0.5;
float outerBgGlow = outerEyeGlow;
outerEyeGlow = pow(outerEyeGlow, 2.0);
outerEyeGlow += distanceMask;
outerEyeGlow *= uGlow;
outerEyeGlow = clamp(outerEyeGlow, 0.0, 1.0);
outerEyeGlow *= pow(1.0 - distanceMask, 2.0) * 2.5;
outerBgGlow += distanceMask;
outerBgGlow = pow(outerBgGlow, 0.5);
outerBgGlow *= 0.15;
float lit = clamp(max(innerRing + innerEye, outerEyeGlow + outerBgGlow) - pupil, 0.0, 3.0);
vec3 color = uEyeColor * uIntensity * (1.0 + uFocus * 0.12) * lit;
gl_FragColor = vec4(color + uBgColor, 1.0);
}
`;
/** Tileable fractal value noise, built once and shared by every eye. */
let noiseCache: Uint8Array | null = null;
function noiseTexture(size = 256) {
if (noiseCache) return noiseCache;
const data = new Uint8Array(size * size * 4);
const hash = (x: number, y: number, s: number) => {
let n = x * 374761393 + y * 668265263 + s * 1274126177;
n = Math.imul(n ^ (n >>> 13), 1274126177);
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
};
const noise = (px: number, py: number, freq: number, seed: number) => {
const fx = (px / size) * freq;
const fy = (py / size) * freq;
const ix = Math.floor(fx);
const iy = Math.floor(fy);
const tx = fx - ix;
const ty = fy - iy;
const w = freq | 0;
const wrap = (v: number) => ((v % w) + w) % w;
const v00 = hash(wrap(ix), wrap(iy), seed);
const v10 = hash(wrap(ix + 1), wrap(iy), seed);
const v01 = hash(wrap(ix), wrap(iy + 1), seed);
const v11 = hash(wrap(ix + 1), wrap(iy + 1), seed);
return (
v00 * (1 - tx) * (1 - ty) +
v10 * tx * (1 - ty) +
v01 * (1 - tx) * ty +
v11 * tx * ty
);
};
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let v = 0;
let amp = 0.4;
let total = 0;
for (let o = 0; o < 8; o++) {
v += amp * noise(x, y, 32 * (1 << o), o * 31);
total += amp;
amp *= 0.65;
}
v = Math.max(0, Math.min(1, (v / total - 0.5) * 2.2 + 0.5));
const i = (y * size + x) * 4;
data[i] = data[i + 1] = data[i + 2] = Math.round(v * 255);
data[i + 3] = 255;
}
}
noiseCache = data;
return data;
}
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.replace(/./g, "$&$&") : h;
const n = parseInt(full.slice(0, 6), 16) || 0;
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
const UNIFORMS = [
"uTime",
"uRes",
"uNoise",
"uPupilSize",
"uIrisWidth",
"uGlow",
"uIntensity",
"uScale",
"uNoiseScale",
"uLook",
"uFocus",
"uEyeColor",
"uBgColor",
] as const;
type EyeGL = {
gl: WebGLRenderingContext;
u: Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
};
function createEyeGL(canvas: HTMLCanvasElement): EyeGL | null {
const gl = canvas.getContext("webgl", {
alpha: false,
antialias: false,
powerPreference: "low-power",
});
if (!gl) return null;
const compile = (type: number, source: string) => {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;
};
const vs = compile(gl.VERTEX_SHADER, VERTEX);
const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
if (!vs || !fs) return null;
const program = gl.createProgram()!;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
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 texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
256,
256,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
noiseTexture(256),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
const u = Object.fromEntries(
UNIFORMS.map((name) => [name, gl.getUniformLocation(program, name)]),
) as EyeGL["u"];
gl.uniform1i(u.uNoise, 0);
return { gl, u };
}
/* ---------- Component ---------- */
/** The face color; the shader paints it behind the eye so the socket blends in. */
const FACE = "#0c0706";
export const EvilEyeButton = React.forwardRef<
HTMLButtonElement,
EvilEyeButtonProps
>(
(
{
children,
label = "I see you",
eyeColor = "#ff6f37",
intensity = 1.3,
pupilSize = 0.62,
irisWidth = 0.22,
glowIntensity = 0.5,
scale = 1.7,
noiseScale = 0.8,
pupilFollow = 0.8,
flameSpeed = 0.8,
blink = true,
eyeClassName,
labelClassName,
className,
style,
disabled,
type = "button",
onPointerEnter,
onPointerLeave,
onClick,
...props
},
ref,
) => {
const hostRef = React.useRef<HTMLSpanElement | null>(null);
const topLidRef = React.useRef<HTMLSpanElement | null>(null);
const bottomLidRef = React.useRef<HTMLSpanElement | null>(null);
const [painted, setPainted] = React.useState(false);
// Live tuning the render loop reads, so slider drags never rebuild the GL.
const live = React.useRef({
eyeColor,
intensity,
pupilSize,
irisWidth,
glowIntensity,
scale,
noiseScale,
pupilFollow,
flameSpeed,
focusTarget: 0,
});
Object.assign(live.current, {
eyeColor,
intensity,
pupilSize,
irisWidth,
glowIntensity,
scale,
noiseScale,
pupilFollow,
flameSpeed,
});
const blinkNow = React.useCallback(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timing = { duration: 200 };
// Animate `scale`, the property Tailwind's scale-y-0 sets; animating
// transform instead would multiply with it and stay shut at zero.
const shut = [
{ scale: "1 0", easing: "cubic-bezier(0.5, 0, 0.75, 0)" },
{ scale: "1 1", offset: 0.4 },
{ scale: "1 1", offset: 0.55, easing: "cubic-bezier(0.25, 1, 0.5, 1)" },
{ scale: "1 0" },
];
topLidRef.current?.animate(shut, timing);
bottomLidRef.current?.animate(shut, timing);
}, []);
React.useEffect(() => {
const host = hostRef.current;
if (!host) return;
// A fresh canvas per run: a lost context never comes back on the same
// canvas, which would freeze the eye after Strict Mode's remount.
const canvas = document.createElement("canvas");
canvas.style.cssText = "display:block;width:100%;height:100%";
const eye = createEyeGL(canvas);
if (!eye) return;
host.appendChild(canvas);
const { gl, u } = eye;
const state = live.current;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let raf = 0;
let last = 0;
let time = 0;
let focus = 0;
let visible = true;
let first = true;
const look = { x: 0, y: 0, tx: 0, ty: 0 };
const draw = () => {
gl.uniform1f(u.uTime, time);
gl.uniform2f(u.uRes, canvas.width, canvas.height);
gl.uniform1f(u.uPupilSize, state.pupilSize);
gl.uniform1f(u.uIrisWidth, state.irisWidth);
gl.uniform1f(u.uGlow, state.glowIntensity);
gl.uniform1f(u.uIntensity, state.intensity);
gl.uniform1f(u.uScale, state.scale);
gl.uniform1f(u.uNoiseScale, state.noiseScale);
gl.uniform2f(u.uLook, look.x, look.y);
gl.uniform1f(u.uFocus, focus);
gl.uniform3f(u.uEyeColor, ...hexToRgb(state.eyeColor));
gl.uniform3f(u.uBgColor, ...hexToRgb(FACE));
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (first) {
first = false;
setPainted(true);
}
};
const frame = (now: number) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
time += dt * state.flameSpeed;
focus += (state.focusTarget - focus) * Math.min(1, dt * 7);
look.x += (look.tx - look.x) * Math.min(1, dt * 6);
look.y += (look.ty - look.y) * Math.min(1, dt * 6);
draw();
raf = requestAnimationFrame(frame);
};
const start = () => {
if (raf || reduceMotion || !visible || document.hidden) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
// The eye watches the cursor anywhere on the page, not just over itself.
const onPointerMove = (event: PointerEvent) => {
const rect = host.getBoundingClientRect();
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
const reach = 240;
const dist = Math.hypot(dx, dy);
const k = (Math.min(dist, reach) / reach / (dist || 1)) * state.pupilFollow;
look.tx = dx * k;
look.ty = -dy * k;
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
const resize = () => {
// At least 2x even on 1x screens: downsampling turns the flame noise
// into smooth licks instead of speckle.
const dpr = Math.min(Math.max(window.devicePixelRatio || 1, 2), 3);
canvas.width = Math.max(1, Math.round(host.clientWidth * dpr));
canvas.height = Math.max(1, Math.round(host.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
draw();
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(host);
const intersection = new IntersectionObserver(([entry]) => {
visible = !!entry?.isIntersecting;
if (visible) start();
else stop();
});
intersection.observe(host);
const onVisibility = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVisibility);
const onLost = (event: Event) => {
event.preventDefault();
stop();
setPainted(false);
};
canvas.addEventListener("webglcontextlost", onLost);
resize();
start();
return () => {
stop();
window.removeEventListener("pointermove", onPointerMove);
resizeObserver.disconnect();
intersection.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("webglcontextlost", onLost);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
setPainted(false);
};
}, []);
// Idle blinks at uneven intervals, so it never feels like a metronome.
React.useEffect(() => {
if (!blink) return;
let timer = 0;
const schedule = () => {
timer = window.setTimeout(
() => {
if (!document.hidden) blinkNow();
schedule();
},
2800 + Math.random() * 4200,
);
};
schedule();
return () => window.clearTimeout(timer);
}, [blink, blinkNow]);
return (
<button
ref={ref}
type={type}
disabled={disabled}
onPointerEnter={(event) => {
onPointerEnter?.(event);
live.current.focusTarget = 1;
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
live.current.focusTarget = 0;
}}
onClick={(event) => {
onClick?.(event);
blinkNow();
}}
style={{ "--eye": eyeColor, ...style } as React.CSSProperties}
className={cn(
"group/eye relative isolate inline-flex h-14 min-w-56 cursor-pointer items-center justify-center overflow-hidden rounded-full px-10 text-sm font-semibold tracking-[0.2em] text-white uppercase outline-none select-none",
// Obsidian shell with an ember hairline, faint top highlight, and a soft drop.
"bg-[#0c0706] shadow-[0_0_0_1px_color-mix(in_oklab,var(--eye)_30%,black),inset_0_1px_0_rgb(255_255_255/0.1),0_1px_2px_rgb(0_0_0/0.4),0_10px_24px_-10px_rgb(0_0_0/0.7)]",
"transition-[scale,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)]",
// Hover: the hairline heats up and the eye's light spills around the button.
"hover:shadow-[0_0_0_1px_color-mix(in_oklab,var(--eye)_60%,black),inset_0_1px_0_rgb(255_255_255/0.12),0_1px_2px_rgb(0_0_0/0.4),0_10px_32px_-8px_color-mix(in_oklab,var(--eye)_50%,transparent)]",
"active:scale-[0.97] active:duration-100 active:ease-out motion-reduce:active:scale-100",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{/* The eye fills the button. */}
<span
aria-hidden
className={cn("pointer-events-none absolute inset-0 -z-10", eyeClassName)}
>
<span
ref={hostRef}
className={cn(
"absolute inset-0 transition-opacity duration-500",
painted ? "opacity-100" : "opacity-0",
)}
/>
{/* Static iris until the shader paints, or for good without WebGL. */}
{!painted ? (
<span className="absolute inset-0 bg-[radial-gradient(closest-side,black_0_10%,var(--eye)_16%,color-mix(in_oklab,var(--eye)_45%,black)_45%,transparent_80%)]" />
) : null}
{/* Lids close across the whole button, with curved lash lines. */}
<span
ref={topLidRef}
className="absolute inset-x-[-10%] top-0 h-[54%] origin-top scale-y-0 rounded-b-[50%] shadow-[0_1px_0_color-mix(in_oklab,var(--eye)_40%,transparent)]"
style={{ background: FACE }}
/>
<span
ref={bottomLidRef}
className="absolute inset-x-[-10%] bottom-0 h-[54%] origin-bottom scale-y-0 rounded-t-[50%] shadow-[0_-1px_0_color-mix(in_oklab,var(--eye)_40%,transparent)]"
style={{ background: FACE }}
/>
</span>
{/* Readability: a soft dark band behind the label, fading out before the
iris edge, and a rim vignette that frames the eye in the pill. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(30%_38%_at_50%_50%,rgb(12_7_6/0.4),transparent_100%),radial-gradient(ellipse_at_center,transparent_60%,rgb(12_7_6/0.7)_100%)]"
/>
{/* Glass lip along the top edge. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 rounded-full bg-[linear-gradient(180deg,rgb(255_255_255/0.1),transparent_35%)]"
/>
<span
className={cn(
"relative whitespace-nowrap [text-shadow:0_1px_2px_rgb(0_0_0/0.9),0_0_12px_color-mix(in_oklab,var(--eye)_55%,transparent)]",
labelClassName,
)}
>
{children ?? label}
</span>
</button>
);
},
);
EvilEyeButton.displayName = "EvilEyeButton";
export default EvilEyeButton;
EvilEyeButton
By jay sharma
Try the interaction, copy the React source files, or download the complete example.
About EvilEyeButton
EvilEyeButton is an animated React and Tailwind CSS button by jay sharma. Try the interaction, copy the React source, or download the example with creator credit.
- Effects and categories
- Hover effects, Animated
- Using this example
- Copy a TSX file or download the original source and standalone demo. Test keyboard focus, motion and contrast in your own project.
Usage, Tailwind and licensing questions
Read the source files
import-examples/evil-eye-button.tsx
import { EvilEyeButton } from "@/components/evil-buttons/evil-eye-button";
export function ButtonDemo() {
return <EvilEyeButton eyeColor="#38bdf8">I see you</EvilEyeButton>;
}
components/evil-buttons/evil-eye-button.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface EvilEyeButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Label used when no children are provided. */
label?: React.ReactNode;
/** Iris and glow color, as a hex string. */
eyeColor?: string;
/** Brightness multiplier for the flames. */
intensity?: number;
/** Size of the slit pupil. Lower values make it tighter. */
pupilSize?: number;
/** Width of the iris ring. */
irisWidth?: number;
/** Strength of the glow around the iris. */
glowIntensity?: number;
/** Zoom of the eye inside the button. Lower values zoom in. */
scale?: number;
/** Scale of the flame noise. */
noiseScale?: number;
/** How far the pupil tracks the cursor, 0 to 1. */
pupilFollow?: number;
/** Speed of the flames licking around the iris. */
flameSpeed?: number;
/** Blink every few seconds on its own, as well as on press. */
blink?: boolean;
/** Extra classes for the eye layer behind the label. */
eyeClassName?: string;
/** Extra classes for the label. */
labelClassName?: string;
}
/* ---------- Shader ---------- */
const VERTEX = `
attribute vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;
// The React Bits evil eye: polar-mapped noise makes flames around an iris
// ring, with a vertical slit pupil. uFocus narrows the slit and stokes the
// flames while hovered.
const FRAGMENT = `
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform sampler2D uNoise;
uniform float uPupilSize;
uniform float uIrisWidth;
uniform float uGlow;
uniform float uIntensity;
uniform float uScale;
uniform float uNoiseScale;
uniform vec2 uLook;
uniform float uFocus;
uniform vec3 uEyeColor;
uniform vec3 uBgColor;
void main() {
vec2 uv = (gl_FragCoord.xy * 2.0 - uRes) / uRes.y;
uv /= uScale;
float ft = uTime;
vec2 polarUv = vec2(length(uv) * 2.0, (2.0 * atan(uv.x, uv.y)) / 6.28 * 0.3);
vec4 noiseA = texture2D(uNoise, polarUv * vec2(0.2, 7.0) * uNoiseScale + vec2(-ft * 0.1, 0.0));
vec4 noiseB = texture2D(uNoise, polarUv * vec2(0.3, 4.0) * uNoiseScale + vec2(-ft * 0.2, 0.0));
vec4 noiseC = texture2D(uNoise, polarUv * vec2(0.1, 5.0) * uNoiseScale + vec2(-ft * 0.1, 0.0));
float distanceMask = 1.0 - length(uv);
float innerRing = clamp(-1.0 * ((distanceMask - 0.7) / uIrisWidth), 0.0, 1.0);
innerRing = (innerRing * distanceMask - 0.2) / 0.28;
innerRing += noiseA.r - 0.5;
innerRing *= 1.3;
innerRing = clamp(innerRing, 0.0, 1.0);
float outerRing = clamp(-1.0 * ((distanceMask - 0.5) / 0.2), 0.0, 1.0);
outerRing = (outerRing * distanceMask - 0.1) / 0.38;
outerRing += noiseC.r - 0.5;
outerRing *= 1.3;
outerRing = clamp(outerRing, 0.0, 1.0);
innerRing += outerRing;
float innerEye = distanceMask - 0.2;
innerEye *= noiseB.r * 2.0;
vec2 pupilUv = uv - uLook * 0.32;
float slit = mix(9.0, 14.0, uFocus);
float pupil = 1.0 - length(pupilUv * vec2(slit, 2.3));
pupil *= uPupilSize;
pupil = clamp(pupil, 0.0, 1.0);
pupil /= 0.35;
float outerEyeGlow = 1.0 - length(uv * vec2(0.5, 1.5));
outerEyeGlow = clamp(outerEyeGlow + 0.5, 0.0, 1.0);
outerEyeGlow += noiseC.r - 0.5;
float outerBgGlow = outerEyeGlow;
outerEyeGlow = pow(outerEyeGlow, 2.0);
outerEyeGlow += distanceMask;
outerEyeGlow *= uGlow;
outerEyeGlow = clamp(outerEyeGlow, 0.0, 1.0);
outerEyeGlow *= pow(1.0 - distanceMask, 2.0) * 2.5;
outerBgGlow += distanceMask;
outerBgGlow = pow(outerBgGlow, 0.5);
outerBgGlow *= 0.15;
float lit = clamp(max(innerRing + innerEye, outerEyeGlow + outerBgGlow) - pupil, 0.0, 3.0);
vec3 color = uEyeColor * uIntensity * (1.0 + uFocus * 0.12) * lit;
gl_FragColor = vec4(color + uBgColor, 1.0);
}
`;
/** Tileable fractal value noise, built once and shared by every eye. */
let noiseCache: Uint8Array | null = null;
function noiseTexture(size = 256) {
if (noiseCache) return noiseCache;
const data = new Uint8Array(size * size * 4);
const hash = (x: number, y: number, s: number) => {
let n = x * 374761393 + y * 668265263 + s * 1274126177;
n = Math.imul(n ^ (n >>> 13), 1274126177);
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
};
const noise = (px: number, py: number, freq: number, seed: number) => {
const fx = (px / size) * freq;
const fy = (py / size) * freq;
const ix = Math.floor(fx);
const iy = Math.floor(fy);
const tx = fx - ix;
const ty = fy - iy;
const w = freq | 0;
const wrap = (v: number) => ((v % w) + w) % w;
const v00 = hash(wrap(ix), wrap(iy), seed);
const v10 = hash(wrap(ix + 1), wrap(iy), seed);
const v01 = hash(wrap(ix), wrap(iy + 1), seed);
const v11 = hash(wrap(ix + 1), wrap(iy + 1), seed);
return (
v00 * (1 - tx) * (1 - ty) +
v10 * tx * (1 - ty) +
v01 * (1 - tx) * ty +
v11 * tx * ty
);
};
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let v = 0;
let amp = 0.4;
let total = 0;
for (let o = 0; o < 8; o++) {
v += amp * noise(x, y, 32 * (1 << o), o * 31);
total += amp;
amp *= 0.65;
}
v = Math.max(0, Math.min(1, (v / total - 0.5) * 2.2 + 0.5));
const i = (y * size + x) * 4;
data[i] = data[i + 1] = data[i + 2] = Math.round(v * 255);
data[i + 3] = 255;
}
}
noiseCache = data;
return data;
}
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.replace(/./g, "$&$&") : h;
const n = parseInt(full.slice(0, 6), 16) || 0;
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
const UNIFORMS = [
"uTime",
"uRes",
"uNoise",
"uPupilSize",
"uIrisWidth",
"uGlow",
"uIntensity",
"uScale",
"uNoiseScale",
"uLook",
"uFocus",
"uEyeColor",
"uBgColor",
] as const;
type EyeGL = {
gl: WebGLRenderingContext;
u: Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
};
function createEyeGL(canvas: HTMLCanvasElement): EyeGL | null {
const gl = canvas.getContext("webgl", {
alpha: false,
antialias: false,
powerPreference: "low-power",
});
if (!gl) return null;
const compile = (type: number, source: string) => {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;
};
const vs = compile(gl.VERTEX_SHADER, VERTEX);
const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
if (!vs || !fs) return null;
const program = gl.createProgram()!;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
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 texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
256,
256,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
noiseTexture(256),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
const u = Object.fromEntries(
UNIFORMS.map((name) => [name, gl.getUniformLocation(program, name)]),
) as EyeGL["u"];
gl.uniform1i(u.uNoise, 0);
return { gl, u };
}
/* ---------- Component ---------- */
/** The face color; the shader paints it behind the eye so the socket blends in. */
const FACE = "#0c0706";
export const EvilEyeButton = React.forwardRef<
HTMLButtonElement,
EvilEyeButtonProps
>(
(
{
children,
label = "I see you",
eyeColor = "#ff6f37",
intensity = 1.3,
pupilSize = 0.62,
irisWidth = 0.22,
glowIntensity = 0.5,
scale = 1.7,
noiseScale = 0.8,
pupilFollow = 0.8,
flameSpeed = 0.8,
blink = true,
eyeClassName,
labelClassName,
className,
style,
disabled,
type = "button",
onPointerEnter,
onPointerLeave,
onClick,
...props
},
ref,
) => {
const hostRef = React.useRef<HTMLSpanElement | null>(null);
const topLidRef = React.useRef<HTMLSpanElement | null>(null);
const bottomLidRef = React.useRef<HTMLSpanElement | null>(null);
const [painted, setPainted] = React.useState(false);
// Live tuning the render loop reads, so slider drags never rebuild the GL.
const live = React.useRef({
eyeColor,
intensity,
pupilSize,
irisWidth,
glowIntensity,
scale,
noiseScale,
pupilFollow,
flameSpeed,
focusTarget: 0,
});
Object.assign(live.current, {
eyeColor,
intensity,
pupilSize,
irisWidth,
glowIntensity,
scale,
noiseScale,
pupilFollow,
flameSpeed,
});
const blinkNow = React.useCallback(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timing = { duration: 200 };
// Animate `scale`, the property Tailwind's scale-y-0 sets; animating
// transform instead would multiply with it and stay shut at zero.
const shut = [
{ scale: "1 0", easing: "cubic-bezier(0.5, 0, 0.75, 0)" },
{ scale: "1 1", offset: 0.4 },
{ scale: "1 1", offset: 0.55, easing: "cubic-bezier(0.25, 1, 0.5, 1)" },
{ scale: "1 0" },
];
topLidRef.current?.animate(shut, timing);
bottomLidRef.current?.animate(shut, timing);
}, []);
React.useEffect(() => {
const host = hostRef.current;
if (!host) return;
// A fresh canvas per run: a lost context never comes back on the same
// canvas, which would freeze the eye after Strict Mode's remount.
const canvas = document.createElement("canvas");
canvas.style.cssText = "display:block;width:100%;height:100%";
const eye = createEyeGL(canvas);
if (!eye) return;
host.appendChild(canvas);
const { gl, u } = eye;
const state = live.current;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let raf = 0;
let last = 0;
let time = 0;
let focus = 0;
let visible = true;
let first = true;
const look = { x: 0, y: 0, tx: 0, ty: 0 };
const draw = () => {
gl.uniform1f(u.uTime, time);
gl.uniform2f(u.uRes, canvas.width, canvas.height);
gl.uniform1f(u.uPupilSize, state.pupilSize);
gl.uniform1f(u.uIrisWidth, state.irisWidth);
gl.uniform1f(u.uGlow, state.glowIntensity);
gl.uniform1f(u.uIntensity, state.intensity);
gl.uniform1f(u.uScale, state.scale);
gl.uniform1f(u.uNoiseScale, state.noiseScale);
gl.uniform2f(u.uLook, look.x, look.y);
gl.uniform1f(u.uFocus, focus);
gl.uniform3f(u.uEyeColor, ...hexToRgb(state.eyeColor));
gl.uniform3f(u.uBgColor, ...hexToRgb(FACE));
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (first) {
first = false;
setPainted(true);
}
};
const frame = (now: number) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
time += dt * state.flameSpeed;
focus += (state.focusTarget - focus) * Math.min(1, dt * 7);
look.x += (look.tx - look.x) * Math.min(1, dt * 6);
look.y += (look.ty - look.y) * Math.min(1, dt * 6);
draw();
raf = requestAnimationFrame(frame);
};
const start = () => {
if (raf || reduceMotion || !visible || document.hidden) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
// The eye watches the cursor anywhere on the page, not just over itself.
const onPointerMove = (event: PointerEvent) => {
const rect = host.getBoundingClientRect();
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
const reach = 240;
const dist = Math.hypot(dx, dy);
const k = (Math.min(dist, reach) / reach / (dist || 1)) * state.pupilFollow;
look.tx = dx * k;
look.ty = -dy * k;
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
const resize = () => {
// At least 2x even on 1x screens: downsampling turns the flame noise
// into smooth licks instead of speckle.
const dpr = Math.min(Math.max(window.devicePixelRatio || 1, 2), 3);
canvas.width = Math.max(1, Math.round(host.clientWidth * dpr));
canvas.height = Math.max(1, Math.round(host.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
draw();
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(host);
const intersection = new IntersectionObserver(([entry]) => {
visible = !!entry?.isIntersecting;
if (visible) start();
else stop();
});
intersection.observe(host);
const onVisibility = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVisibility);
const onLost = (event: Event) => {
event.preventDefault();
stop();
setPainted(false);
};
canvas.addEventListener("webglcontextlost", onLost);
resize();
start();
return () => {
stop();
window.removeEventListener("pointermove", onPointerMove);
resizeObserver.disconnect();
intersection.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("webglcontextlost", onLost);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
setPainted(false);
};
}, []);
// Idle blinks at uneven intervals, so it never feels like a metronome.
React.useEffect(() => {
if (!blink) return;
let timer = 0;
const schedule = () => {
timer = window.setTimeout(
() => {
if (!document.hidden) blinkNow();
schedule();
},
2800 + Math.random() * 4200,
);
};
schedule();
return () => window.clearTimeout(timer);
}, [blink, blinkNow]);
return (
<button
ref={ref}
type={type}
disabled={disabled}
onPointerEnter={(event) => {
onPointerEnter?.(event);
live.current.focusTarget = 1;
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
live.current.focusTarget = 0;
}}
onClick={(event) => {
onClick?.(event);
blinkNow();
}}
style={{ "--eye": eyeColor, ...style } as React.CSSProperties}
className={cn(
"group/eye relative isolate inline-flex h-14 min-w-56 cursor-pointer items-center justify-center overflow-hidden rounded-full px-10 text-sm font-semibold tracking-[0.2em] text-white uppercase outline-none select-none",
// Obsidian shell with an ember hairline, faint top highlight, and a soft drop.
"bg-[#0c0706] shadow-[0_0_0_1px_color-mix(in_oklab,var(--eye)_30%,black),inset_0_1px_0_rgb(255_255_255/0.1),0_1px_2px_rgb(0_0_0/0.4),0_10px_24px_-10px_rgb(0_0_0/0.7)]",
"transition-[scale,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)]",
// Hover: the hairline heats up and the eye's light spills around the button.
"hover:shadow-[0_0_0_1px_color-mix(in_oklab,var(--eye)_60%,black),inset_0_1px_0_rgb(255_255_255/0.12),0_1px_2px_rgb(0_0_0/0.4),0_10px_32px_-8px_color-mix(in_oklab,var(--eye)_50%,transparent)]",
"active:scale-[0.97] active:duration-100 active:ease-out motion-reduce:active:scale-100",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{/* The eye fills the button. */}
<span
aria-hidden
className={cn("pointer-events-none absolute inset-0 -z-10", eyeClassName)}
>
<span
ref={hostRef}
className={cn(
"absolute inset-0 transition-opacity duration-500",
painted ? "opacity-100" : "opacity-0",
)}
/>
{/* Static iris until the shader paints, or for good without WebGL. */}
{!painted ? (
<span className="absolute inset-0 bg-[radial-gradient(closest-side,black_0_10%,var(--eye)_16%,color-mix(in_oklab,var(--eye)_45%,black)_45%,transparent_80%)]" />
) : null}
{/* Lids close across the whole button, with curved lash lines. */}
<span
ref={topLidRef}
className="absolute inset-x-[-10%] top-0 h-[54%] origin-top scale-y-0 rounded-b-[50%] shadow-[0_1px_0_color-mix(in_oklab,var(--eye)_40%,transparent)]"
style={{ background: FACE }}
/>
<span
ref={bottomLidRef}
className="absolute inset-x-[-10%] bottom-0 h-[54%] origin-bottom scale-y-0 rounded-t-[50%] shadow-[0_-1px_0_color-mix(in_oklab,var(--eye)_40%,transparent)]"
style={{ background: FACE }}
/>
</span>
{/* Readability: a soft dark band behind the label, fading out before the
iris edge, and a rim vignette that frames the eye in the pill. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(30%_38%_at_50%_50%,rgb(12_7_6/0.4),transparent_100%),radial-gradient(ellipse_at_center,transparent_60%,rgb(12_7_6/0.7)_100%)]"
/>
{/* Glass lip along the top edge. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 rounded-full bg-[linear-gradient(180deg,rgb(255_255_255/0.1),transparent_35%)]"
/>
<span
className={cn(
"relative whitespace-nowrap [text-shadow:0_1px_2px_rgb(0_0_0/0.9),0_0_12px_color-mix(in_oklab,var(--eye)_55%,transparent)]",
labelClassName,
)}
>
{children ?? label}
</span>
</button>
);
},
);
EvilEyeButton.displayName = "EvilEyeButton";
export default EvilEyeButton;
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
demo.tsx
export { ButtonDemo as default } from "@/import-examples/evil-eye-button";CSS
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--color-white: #fff;
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--font-weight-semibold: 600;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--default-mono-font-family: var(--font-geist-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.pointer-events-none {
pointer-events: none;
}
.\!visible {
visibility: visible !important;
}
.visible {
visibility: visible;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.inset-0 {
inset: 0px;
}
.inset-x-\[-10\%\] {
inset-inline: -10%;
}
.top-0 {
top: 0px;
}
.bottom-0 {
bottom: 0px;
}
.isolate {
isolation: isolate;
}
.-z-10 {
z-index: calc(10 * -1);
}
.hidden {
display: none;
}
.inline-flex {
display: inline-flex;
}
.h-14 {
height: calc(var(--spacing) * 14);
}
.h-\[54\%\] {
height: 54%;
}
.min-w-56 {
min-width: calc(var(--spacing) * 56);
}
.origin-bottom {
transform-origin: bottom;
}
.origin-top {
transform-origin: top;
}
.scale-y-0 {
--tw-scale-y: 0%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
.cursor-pointer {
cursor: pointer;
}
.resize {
resize: both;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.overflow-hidden {
overflow: hidden;
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-t-\[50\%\] {
border-top-left-radius: 50%;
border-top-right-radius: 50%;
}
.rounded-b-\[50\%\] {
border-bottom-right-radius: 50%;
border-bottom-left-radius: 50%;
}
.bg-\[\#0c0706\] {
background-color: #0c0706;
}
.bg-\[linear-gradient\(180deg\,rgb\(255_255_255\/0\.1\)\,transparent_35\%\)\] {
background-image: linear-gradient(180deg,rgb(255 255 255/0.1),transparent 35%);
}
.bg-\[radial-gradient\(30\%_38\%_at_50\%_50\%\,rgb\(12_7_6\/0\.4\)\,transparent_100\%\)\,radial-gradient\(ellipse_at_center\,transparent_60\%\,rgb\(12_7_6\/0\.7\)_100\%\)\] {
background-image: radial-gradient(30% 38% at 50% 50%,rgb(12 7 6/0.4),transparent 100%),radial-gradient(ellipse at center,transparent 60%,rgb(12 7 6/0.7) 100%);
}
.bg-\[radial-gradient\(closest-side\,black_0_10\%\,var\(--eye\)_16\%\,color-mix\(in_oklab\,var\(--eye\)_45\%\,black\)_45\%\,transparent_80\%\)\] {
background-image: radial-gradient(closest-side,black 0 10%,var(--eye) 16%,var(--eye) 45%,transparent 80%);
@supports (color: color-mix(in lab, red, red)) {
background-image: radial-gradient(closest-side,black 0 10%,var(--eye) 16%,color-mix(in oklab,var(--eye) 45%,black) 45%,transparent 80%);
}
}
.px-10 {
padding-inline: calc(var(--spacing) * 10);
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.font-semibold {
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
}
.tracking-\[0\.2em\] {
--tw-tracking: 0.2em;
letter-spacing: 0.2em;
}
.whitespace-nowrap {
white-space: nowrap;
}
.text-white {
color: var(--color-white);
}
.uppercase {
text-transform: uppercase;
}
.opacity-0 {
opacity: 0%;
}
.opacity-100 {
opacity: 100%;
}
.shadow-\[0_-1px_0_color-mix\(in_oklab\,var\(--eye\)_40\%\,transparent\)\] {
--tw-shadow: 0 -1px 0 var(--tw-shadow-color, var(--eye));
@supports (color: color-mix(in lab, red, red)) {
--tw-shadow: 0 -1px 0 var(--tw-shadow-color, color-mix(in oklab,var(--eye) 40%,transparent));
}
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-\[0_0_0_1px_color-mix\(in_oklab\,var\(--eye\)_30\%\,black\)\,inset_0_1px_0_rgb\(255_255_255\/0\.1\)\,0_1px_2px_rgb\(0_0_0\/0\.4\)\,0_10px_24px_-10px_rgb\(0_0_0\/0\.7\)\] {
--tw-shadow: 0 0 0 1px var(--tw-shadow-color, var(--eye)), inset 0 1px 0 var(--tw-shadow-color, rgb(255 255 255/0.1)), 0 1px 2px var(--tw-shadow-color, rgb(0 0 0/0.4)), 0 10px 24px -10px var(--tw-shadow-color, rgb(0 0 0/0.7));
@supports (color: color-mix(in lab, red, red)) {
--tw-shadow: 0 0 0 1px var(--tw-shadow-color, color-mix(in oklab,var(--eye) 30%,black)), inset 0 1px 0 var(--tw-shadow-color, rgb(255 255 255/0.1)), 0 1px 2px var(--tw-shadow-color, rgb(0 0 0/0.4)), 0 10px 24px -10px var(--tw-shadow-color, rgb(0 0 0/0.7));
}
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-\[0_1px_0_color-mix\(in_oklab\,var\(--eye\)_40\%\,transparent\)\] {
--tw-shadow: 0 1px 0 var(--tw-shadow-color, var(--eye));
@supports (color: color-mix(in lab, red, red)) {
--tw-shadow: 0 1px 0 var(--tw-shadow-color, color-mix(in oklab,var(--eye) 40%,transparent));
}
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.transition-\[scale\,box-shadow\] {
transition-property: scale,box-shadow;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-opacity {
transition-property: opacity;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.duration-300 {
--tw-duration: 300ms;
transition-duration: 300ms;
}
.duration-500 {
--tw-duration: 500ms;
transition-duration: 500ms;
}
.ease-\[cubic-bezier\(0\.34\,1\.4\,0\.64\,1\)\] {
--tw-ease: cubic-bezier(0.34,1.4,0.64,1);
transition-timing-function: cubic-bezier(0.34,1.4,0.64,1);
}
.outline-none {
--tw-outline-style: none;
outline-style: none;
}
.select-none {
-webkit-user-select: none;
user-select: none;
}
.\[text-shadow\:0_1px_2px_rgb\(0_0_0\/0\.9\)\,0_0_12px_color-mix\(in_oklab\,var\(--eye\)_55\%\,transparent\)\] {
text-shadow: 0 1px 2px rgb(0 0 0/0.9),0 0 12px var(--eye);
@supports (color: color-mix(in lab, red, red)) {
text-shadow: 0 1px 2px rgb(0 0 0/0.9),0 0 12px color-mix(in oklab,var(--eye) 55%,transparent);
}
}
@media (hover: hover) {
.hover\:shadow-\[0_0_0_1px_color-mix\(in_oklab\,var\(--eye\)_60\%\,black\)\,inset_0_1px_0_rgb\(255_255_255\/0\.12\)\,0_1px_2px_rgb\(0_0_0\/0\.4\)\,0_10px_32px_-8px_color-mix\(in_oklab\,var\(--eye\)_50\%\,transparent\)\]:hover {
--tw-shadow: 0 0 0 1px var(--tw-shadow-color, var(--eye)), inset 0 1px 0 var(--tw-shadow-color, rgb(255 255 255/0.12)), 0 1px 2px var(--tw-shadow-color, rgb(0 0 0/0.4)), 0 10px 32px -8px var(--tw-shadow-color, var(--eye));
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:shadow-\[0_0_0_1px_color-mix\(in_oklab\,var\(--eye\)_60\%\,black\)\,inset_0_1px_0_rgb\(255_255_255\/0\.12\)\,0_1px_2px_rgb\(0_0_0\/0\.4\)\,0_10px_32px_-8px_color-mix\(in_oklab\,var\(--eye\)_50\%\,transparent\)\]:hover {
--tw-shadow: 0 0 0 1px var(--tw-shadow-color, color-mix(in oklab,var(--eye) 60%,black)), inset 0 1px 0 var(--tw-shadow-color, rgb(255 255 255/0.12)), 0 1px 2px var(--tw-shadow-color, rgb(0 0 0/0.4)), 0 10px 32px -8px var(--tw-shadow-color, color-mix(in oklab,var(--eye) 50%,transparent));
}
}
.hover\:shadow-\[0_0_0_1px_color-mix\(in_oklab\,var\(--eye\)_60\%\,black\)\,inset_0_1px_0_rgb\(255_255_255\/0\.12\)\,0_1px_2px_rgb\(0_0_0\/0\.4\)\,0_10px_32px_-8px_color-mix\(in_oklab\,var\(--eye\)_50\%\,transparent\)\]:hover {
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus-visible\:ring-2:focus-visible {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.focus-visible\:ring-ring:focus-visible {
--tw-ring-color: var(--ring);
}
.focus-visible\:ring-offset-2:focus-visible {
--tw-ring-offset-width: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
}
.focus-visible\:ring-offset-background:focus-visible {
--tw-ring-offset-color: var(--background);
}
.active\:scale-\[0\.97\]:active {
scale: 0.97;
}
.active\:duration-100:active {
--tw-duration: 100ms;
transition-duration: 100ms;
}
.active\:ease-out:active {
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.disabled\:cursor-not-allowed:disabled {
cursor: not-allowed;
}
.disabled\:opacity-50:disabled {
opacity: 50%;
}
@media (prefers-reduced-motion: reduce) {
.motion-reduce\:active\:scale-100:active {
--tw-scale-x: 100%;
--tw-scale-y: 100%;
--tw-scale-z: 100%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
}
}
:root {
--background: #fff;
--foreground: #111;
--primary: #171717;
--primary-foreground: #fff;
--secondary: #eee;
--secondary-foreground: #171717;
--muted: #eee;
--muted-foreground: #666;
--accent: #eee;
--accent-foreground: #171717;
--border: #ddd;
--input: #ddd;
--ring: #777;
--destructive: #dc2626;
--destructive-foreground: #fff;
--popover: #fff;
--popover-foreground: #111;
--success: #15803d;
--success-foreground: #fff;
--radius: .625rem;
--hairline: 1px;
}
.dark {
--background: #090909;
--foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a3a3a3;
--accent: #262626;
--accent-foreground: #fafafa;
--border: #333;
--input: #333;
--popover: #171717;
--popover-foreground: #fafafa;
}
:root {
--card: oklch(1 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--border: oklch(0.922 0 0);
--primary: oklch(0.205 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--chrome: oklch(0.967 0 0);
--brand: oklch(0.6 0.23 29);
--chart-background: oklch(1 0 0);
--chart-foreground: oklch(0.145 0.004 285);
--chart-foreground-muted: oklch(0.55 0.014 260);
--chart-line-primary: var(--chart-1);
--chart-line-secondary: var(--chart-2);
--chart-crosshair: oklch(0.4 0.1828 274.34);
--chart-grid: oklch(0.9 0 0);
--chart-brush-border: var(--chart-grid);
--chart-tooltip-background: oklch(0.21 0.006 285 / 0.8);
--chart-tooltip-foreground: oklch(0.985 0 0);
--chart-tooltip-muted: oklch(0.65 0.01 260);
--chart-marker-background: oklch(0.97 0.005 260);
--chart-marker-border: oklch(0.85 0.01 260);
--chart-marker-foreground: oklch(0.3 0.01 260);
--chart-label: oklch(0.45 0.01 260);
--chart-scale-01: oklch(0.98 0.003 106);
--chart-scale-02: oklch(0.92 0.008 106);
--chart-scale-03: oklch(0.82 0.015 106);
--chart-scale-04: oklch(0.68 0.02 106);
--chart-scale-05: oklch(0.55 0.025 106);
--chart-scale-pattern-color: oklch(0.96 0.005 106);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--chrome: oklch(0.18 0 0);
--card: oklch(0.205 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(1 0 0 / 10%);
--primary: oklch(0.922 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--brand: oklch(0.66 0.23 32);
--chart-background: oklch(0.145 0 0);
--chart-foreground: oklch(0.45 0 0);
--chart-foreground-muted: oklch(0.65 0.01 260);
--chart-crosshair: oklch(0.45 0 0);
--chart-grid: oklch(0.25 0 0);
--chart-brush-border: var(--chart-grid);
--chart-marker-background: oklch(0.25 0.01 260);
--chart-marker-border: oklch(0.4 0.01 260);
--chart-marker-foreground: oklch(0.9 0 0);
--chart-label: oklch(0.75 0.01 260);
--chart-scale-01: oklch(0.28 0.03 270);
--chart-scale-02: oklch(0.38 0.04 270);
--chart-scale-03: oklch(0.48 0.05 270);
--chart-scale-04: oklch(0.58 0.06 270);
--chart-scale-05: oklch(0.68 0.07 270);
--chart-scale-pattern-color: oklch(0.145 0 0);
}
@keyframes docs-dot-swing {
0%, 100% {
transform: translateX(0) scale(1);
}
45% {
transform: translateX(-6px) scale(0.7);
}
}
@keyframes hell-flicker {
0%, 100% {
opacity: 1;
}
41% {
opacity: 0.7;
}
43% {
opacity: 1;
}
77% {
opacity: 0.85;
}
78% {
opacity: 1;
}
}
@keyframes lights-off {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
30% {
opacity: 0.2;
}
45% {
opacity: 1;
}
60% {
opacity: 0.5;
}
70%, 100% {
opacity: 1;
}
}
@keyframes lights-on {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
32% {
opacity: 1;
}
50% {
opacity: 0.1;
}
60% {
opacity: 0.9;
}
75%, 100% {
opacity: 0;
}
}
@keyframes lights-fade {
to {
opacity: 0;
}
}
@keyframes eyes-dim {
from {
opacity: 0;
}
}
@keyframes eyes-open {
from {
opacity: 0;
transform: scaleY(0.05);
}
}
@keyframes eyes-blink {
0%, 92%, 100% {
transform: none;
}
95% {
transform: scaleY(0.08);
}
}
@keyframes crack-life {
0% {
opacity: 0;
transform: scale(0.92);
}
4% {
opacity: 1;
transform: none;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes egg-wobble {
0%, 84%, 100% {
transform: none;
}
87% {
transform: rotate(-14deg);
}
90% {
transform: rotate(10deg);
}
93% {
transform: rotate(-6deg);
}
96% {
transform: rotate(3deg);
}
}
@keyframes page-glitch {
0% {
transform: none;
filter: none;
clip-path: none;
}
12% {
transform: translate(-6px, 2px) skewX(-2deg);
filter: hue-rotate(90deg) saturate(3);
}
24% {
transform: translate(5px, -1px);
filter: invert(1) hue-rotate(180deg);
clip-path: inset(18% 0 52% 0);
}
36% {
transform: none;
filter: contrast(2.2) saturate(0);
clip-path: none;
}
48% {
transform: translate(9px, 0) skewX(4deg);
filter: hue-rotate(-60deg);
clip-path: inset(0 0 70% 0);
}
60% {
transform: translate(-3px, 3px);
filter: none;
clip-path: inset(40% 0 20% 0);
}
72% {
transform: translate(2px, -4px) scaleY(1.02);
filter: saturate(4) contrast(1.4);
clip-path: none;
}
86% {
transform: translate(-8px, 0);
filter: invert(1);
clip-path: inset(62% 0 8% 0);
}
}
@keyframes glitch-label {
0% {
transform: translate(0, 0);
opacity: 1;
}
33% {
transform: translate(-4px, 2px);
opacity: 0.6;
}
66% {
transform: translate(5px, -2px);
opacity: 1;
}
}
@keyframes deleted-progress {
from {
width: 0;
}
to {
width: 100%;
}
}
@keyframes deleted-restore {
0%, 70% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes creeper-swell {
0% {
transform: scale(0.3) translateY(40px);
opacity: 0;
filter: none;
}
20% {
transform: scale(1);
opacity: 1;
}
45%, 65%, 85% {
filter: brightness(3) saturate(0);
}
55%, 75% {
filter: none;
}
100% {
transform: scale(1.35);
filter: brightness(4) saturate(0);
}
}
@keyframes creeper-flash {
from {
opacity: 0.85;
}
to {
opacity: 0;
}
}
@property --tw-scale-x {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-y {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-z {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-tracking {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-duration {
syntax: "*";
inherits: false;
}
@property --tw-ease {
syntax: "*";
inherits: false;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-scale-z: 1;
--tw-rotate-x: initial;
--tw-rotate-y: initial;
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-font-weight: initial;
--tw-tracking: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-duration: initial;
--tw-ease: initial;
}
}
}