diff --git a/components/evil-buttons/thruster-button.tsx b/components/evil-buttons/thruster-button.tsx new file mode 100644 index 00000000..721a0760 --- /dev/null +++ b/components/evil-buttons/thruster-button.tsx @@ -0,0 +1,920 @@ +"use client"; + +import * as React from "react"; +import { + motion, + useMotionValue, + useReducedMotion, + useSpring, + type MotionValue, +} from "motion/react"; +import { cn } from "@/lib/utils"; + +export interface ThrusterButtonProps + extends Omit< + React.ButtonHTMLAttributes, + "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" + > { + /** Net thruster acceleration in px/s². Lower values drift, higher values snap. */ + thrust?: number; + /** Maximum bank in degrees while the craft accelerates sideways. */ + tilt?: number; + /** Classes for the playground, which fills the nearest positioned ancestor by default. */ + playgroundClassName?: string; +} + +type Side = "left" | "right" | "bottom"; + +// Autopilot gains: a slightly underdamped spring, so the craft overshoots the +// pointer and fires its opposite jets to brake. +const KP = 90; +const KD = 2 * 0.55 * Math.sqrt(KP); +// There are no top jets, so the craft descends by easing off the bottom ones. +const GRAVITY = 1100; +// Jet visuals are scaled against this fixed force, not the `thrust` prop, so the +// same push always looks the same: a stronger craft only blasts harder when it +// actually accelerates harder. +const FULL_BLAST = 3200; +// Valve chatter in px at full blast. +const RUMBLE = 0.35; +// Nozzle length in px; gas leaves from its mouth. +const NOZZLE = 5; +// Bottom pair sits this far in from the corners; side pairs this far from the middle. +const BOTTOM_INSET = 14; +const SIDE_SPREAD = 8; +// Gap kept between the craft and the playground walls. +const WALL_GAP = 2; +// How long the smoke keeps drifting after the craft docks. +const LINGER = 2.2; + +// Two valves per edge. Each is drawn pointing down, then rotated onto its edge. +const NOZZLES: { + side: Side; + left: string; + top: string; + rotate: number; +}[] = [ + { side: "bottom", left: `${BOTTOM_INSET}px`, top: "100%", rotate: 0 }, + { side: "bottom", left: `calc(100% - ${BOTTOM_INSET}px)`, top: "100%", rotate: 0 }, + { side: "left", left: "0px", top: `calc(50% - ${SIDE_SPREAD}px)`, rotate: 90 }, + { side: "left", left: "0px", top: `calc(50% + ${SIDE_SPREAD}px)`, rotate: 90 }, + { side: "right", left: "100%", top: `calc(50% - ${SIDE_SPREAD}px)`, rotate: -90 }, + { side: "right", left: "100%", top: `calc(50% + ${SIDE_SPREAD}px)`, rotate: -90 }, +]; + +/** Resolves any CSS color (oklch included) to 0–1 RGB. */ +function toRgb(color: string): [number, number, number] { + const probe = document.createElement("canvas"); + probe.width = probe.height = 1; + const ctx = probe.getContext("2d"); + if (!ctx) return [0.8, 0.8, 0.8]; + ctx.fillStyle = color; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + return [r / 255, g / 255, b / 255]; +} + +/* ------------------------------------------------------------------------ */ +/* Smoke: a small GPU stable-fluids solver (advect, project, confine curl). */ +/* ------------------------------------------------------------------------ */ + +const MAX_JETS = 6; +const SIM_RES = 128; +const DYE_RES = 384; +const PRESSURE_ITERATIONS = 20; +const VORTICITY = 25; +const VELOCITY_DISSIPATION = 0.6; +const DYE_DISSIPATION = 1.5; + +const VERTEX = `#version 300 es +in vec2 aPosition; +out vec2 vUv, vL, vR, vT, vB; +uniform vec2 texelSize; +void main() { + vUv = aPosition * 0.5 + 0.5; + vL = vUv - vec2(texelSize.x, 0.0); + vR = vUv + vec2(texelSize.x, 0.0); + vT = vUv + vec2(0.0, texelSize.y); + vB = vUv - vec2(0.0, texelSize.y); + gl_Position = vec4(aPosition, 0.0, 1.0); +}`; + +const HEADER = `#version 300 es +precision highp float; +precision highp sampler2D; +in vec2 vUv, vL, vR, vT, vB; +out vec4 fragColor; +`; + +const SHADERS = { + // Elongated gaussians stretched along each jet's direction. Velocity is + // pinned to the jet's (an inflow), while smoke density accumulates. + splat: `${HEADER} +uniform sampler2D uTarget; +uniform float aspectRatio; +uniform float radius; +uniform bool pin; +uniform int count; +uniform vec2 points[${MAX_JETS}]; +uniform vec2 dirs[${MAX_JETS}]; +uniform vec3 values[${MAX_JETS}]; +void main() { + vec3 base = texture(uTarget, vUv).xyz; + for (int i = 0; i < ${MAX_JETS}; i++) { + if (i >= count) break; + vec2 p = vUv - points[i]; + p.x *= aspectRatio; + float along = dot(p, dirs[i]); + float across = dot(p, vec2(-dirs[i].y, dirs[i].x)); + float g = exp(-(along * along * 0.3 + across * across) / radius); + base = pin ? mix(base, values[i], min(g, 1.0)) : base + values[i] * g; + } + fragColor = vec4(base, 1.0); +}`, + advection: `${HEADER} +uniform sampler2D uVelocity; +uniform sampler2D uSource; +uniform vec2 velocityTexel; +uniform float dt; +uniform float dissipation; +void main() { + vec2 coord = vUv - dt * texture(uVelocity, vUv).xy * velocityTexel; + fragColor = texture(uSource, coord) / (1.0 + dissipation * dt); +}`, + divergence: `${HEADER} +uniform sampler2D uVelocity; +void main() { + float L = texture(uVelocity, vL).x; + float R = texture(uVelocity, vR).x; + float T = texture(uVelocity, vT).y; + float B = texture(uVelocity, vB).y; + vec2 C = texture(uVelocity, vUv).xy; + if (vL.x < 0.0) L = -C.x; + if (vR.x > 1.0) R = -C.x; + if (vT.y > 1.0) T = -C.y; + if (vB.y < 0.0) B = -C.y; + fragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0); +}`, + curl: `${HEADER} +uniform sampler2D uVelocity; +void main() { + float L = texture(uVelocity, vL).y; + float R = texture(uVelocity, vR).y; + float T = texture(uVelocity, vT).x; + float B = texture(uVelocity, vB).x; + fragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0); +}`, + // Vorticity confinement feeds the small eddies back so the smoke keeps curling. + vorticity: `${HEADER} +uniform sampler2D uVelocity; +uniform sampler2D uCurl; +uniform float curl; +uniform float dt; +void main() { + float L = texture(uCurl, vL).x; + float R = texture(uCurl, vR).x; + float T = texture(uCurl, vT).x; + float B = texture(uCurl, vB).x; + float C = texture(uCurl, vUv).x; + vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); + force /= length(force) + 0.0001; + force *= curl * C; + force.y *= -1.0; + vec2 velocity = texture(uVelocity, vUv).xy + force * dt; + fragColor = vec4(clamp(velocity, -1000.0, 1000.0), 0.0, 1.0); +}`, + pressure: `${HEADER} +uniform sampler2D uPressure; +uniform sampler2D uDivergence; +void main() { + float L = texture(uPressure, vL).x; + float R = texture(uPressure, vR).x; + float T = texture(uPressure, vT).x; + float B = texture(uPressure, vB).x; + float divergence = texture(uDivergence, vUv).x; + fragColor = vec4((L + R + B + T - divergence) * 0.25, 0.0, 0.0, 1.0); +}`, + gradientSubtract: `${HEADER} +uniform sampler2D uPressure; +uniform sampler2D uVelocity; +void main() { + float L = texture(uPressure, vL).x; + float R = texture(uPressure, vR).x; + float T = texture(uPressure, vT).x; + float B = texture(uPressure, vB).x; + vec2 velocity = texture(uVelocity, vUv).xy - vec2(R - L, T - B); + fragColor = vec4(velocity, 0.0, 1.0); +}`, + scale: `${HEADER} +uniform sampler2D uTexture; +uniform float value; +void main() { + fragColor = value * texture(uTexture, vUv); +}`, + // Density becomes flat, soft smoke in the tint color. + display: `${HEADER} +uniform sampler2D uTexture; +uniform vec3 tint; +uniform float opacity; +void main() { + float alpha = (1.0 - exp(-texture(uTexture, vUv).r * 1.8)) * opacity; + fragColor = vec4(tint * alpha, alpha); +}`, +} as const; + +type ShaderName = keyof typeof SHADERS; + +type Target = { + texture: WebGLTexture; + fbo: WebGLFramebuffer; + width: number; + height: number; +}; + +type DoubleTarget = { read: Target; write: Target; swap: () => void }; + +type Jet = { + /** Mouth position in canvas UV, y up. */ + x: number; + y: number; + /** Unit direction in aspect-corrected space, y up. */ + dx: number; + dy: number; + /** Jet speed in px/s. */ + speed: number; + /** Smoke emitted this frame. */ + density: number; +}; + +function createSmoke(canvas: HTMLCanvasElement) { + const gl = canvas.getContext("webgl2", { + alpha: true, + premultipliedAlpha: true, + antialias: false, + depth: false, + stencil: false, + preserveDrawingBuffer: false, + }); + if (!gl || !gl.getExtension("EXT_color_buffer_float")) return null; + + const compile = (type: number, source: string) => { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, source); + gl.compileShader(shader); + return shader; + }; + const vertex = compile(gl.VERTEX_SHADER, VERTEX); + + const programs = {} as Record< + ShaderName, + { program: WebGLProgram; uniform: (name: string) => WebGLUniformLocation | null } + >; + for (const name of Object.keys(SHADERS) as ShaderName[]) { + const program = gl.createProgram()!; + gl.attachShader(program, vertex); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, SHADERS[name])); + gl.bindAttribLocation(program, 0, "aPosition"); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null; + const cache = new Map(); + programs[name] = { + program, + uniform: (uniform) => { + if (!cache.has(uniform)) cache.set(uniform, gl.getUniformLocation(program, uniform)); + return cache.get(uniform)!; + }, + }; + } + + const quad = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, quad); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + const targets: Target[] = []; + const createTarget = (width: number, height: number): Target => { + const texture = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, texture); + 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.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null); + const fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + const target = { texture, fbo, width, height }; + targets.push(target); + return target; + }; + const createDouble = (width: number, height: number): DoubleTarget => { + const pair = { + read: createTarget(width, height), + write: createTarget(width, height), + swap: () => { + [pair.read, pair.write] = [pair.write, pair.read]; + }, + }; + return pair; + }; + + let velocity: DoubleTarget; + let dye: DoubleTarget; + let pressure: DoubleTarget; + let divergence: Target; + let curl: Target; + let aspect = 1; + + const program = (name: ShaderName) => { + const entry = programs[name]; + gl.useProgram(entry.program); + return entry.uniform; + }; + const bindTexture = ( + uniform: (name: string) => WebGLUniformLocation | null, + name: string, + target: Target, + unit: number, + ) => { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, target.texture); + gl.uniform1i(uniform(name), unit); + }; + const draw = ( + uniform: (name: string) => WebGLUniformLocation | null, + target: Target | null, + texel: Target, + ) => { + gl.uniform2f(uniform("texelSize"), 1 / texel.width, 1 / texel.height); + gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fbo : null); + gl.viewport(0, 0, target ? target.width : gl.drawingBufferWidth, target ? target.height : gl.drawingBufferHeight); + gl.drawArrays(gl.TRIANGLES, 0, 6); + }; + + const gridFor = (base: number) => + aspect >= 1 + ? [Math.round(base * aspect), base] + : [base, Math.round(base / aspect)]; + + const resize = (width: number, height: number) => { + for (const target of targets.splice(0)) { + gl.deleteTexture(target.texture); + gl.deleteFramebuffer(target.fbo); + } + aspect = width / height; + const [simW, simH] = gridFor(SIM_RES); + const [dyeW, dyeH] = gridFor(DYE_RES); + velocity = createDouble(simW, simH); + pressure = createDouble(simW, simH); + divergence = createTarget(simW, simH); + curl = createTarget(simW, simH); + dye = createDouble(dyeW, dyeH); + }; + + const splat = ( + field: DoubleTarget, + jets: Jet[], + radius: number, + pin: boolean, + value: (jet: Jet) => number[], + ) => { + const u = program("splat"); + bindTexture(u, "uTarget", field.read, 0); + gl.uniform1i(u("pin"), pin ? 1 : 0); + gl.uniform1f(u("aspectRatio"), aspect); + gl.uniform1f(u("radius"), radius); + gl.uniform1i(u("count"), jets.length); + gl.uniform2fv(u("points"), jets.flatMap((jet) => [jet.x, jet.y])); + gl.uniform2fv(u("dirs"), jets.flatMap((jet) => [jet.dx, jet.dy])); + gl.uniform3fv(u("values"), jets.flatMap(value)); + draw(u, field.write, field.read); + field.swap(); + }; + + return { + resize, + clear() { + for (const target of targets) { + gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + } + }, + /** `cssWidth`/`cssHeight` map jet speeds in px/s onto the grid. */ + step(dt: number, jets: Jet[], jetRadiusPx: number, cssWidth: number, cssHeight: number) { + gl.disable(gl.BLEND); + + if (jets.length) { + const radius = (jetRadiusPx / cssHeight) ** 2; + const pxToGridX = velocity.read.width / cssWidth; + const pxToGridY = velocity.read.height / cssHeight; + splat(velocity, jets, radius, true, (jet) => [ + jet.dx * jet.speed * pxToGridX, + jet.dy * jet.speed * pxToGridY, + 0, + ]); + splat(dye, jets, radius, false, (jet) => [jet.density, jet.density, jet.density]); + } + + let u = program("curl"); + bindTexture(u, "uVelocity", velocity.read, 0); + draw(u, curl, velocity.read); + + u = program("vorticity"); + bindTexture(u, "uVelocity", velocity.read, 0); + bindTexture(u, "uCurl", curl, 1); + gl.uniform1f(u("curl"), VORTICITY); + gl.uniform1f(u("dt"), dt); + draw(u, velocity.write, velocity.read); + velocity.swap(); + + u = program("divergence"); + bindTexture(u, "uVelocity", velocity.read, 0); + draw(u, divergence, velocity.read); + + u = program("scale"); + bindTexture(u, "uTexture", pressure.read, 0); + gl.uniform1f(u("value"), 0.8); + draw(u, pressure.write, pressure.read); + pressure.swap(); + + u = program("pressure"); + bindTexture(u, "uDivergence", divergence, 1); + for (let i = 0; i < PRESSURE_ITERATIONS; i++) { + bindTexture(u, "uPressure", pressure.read, 0); + draw(u, pressure.write, pressure.read); + pressure.swap(); + } + + u = program("gradientSubtract"); + bindTexture(u, "uPressure", pressure.read, 0); + bindTexture(u, "uVelocity", velocity.read, 1); + draw(u, velocity.write, velocity.read); + velocity.swap(); + + u = program("advection"); + gl.uniform2f(u("velocityTexel"), 1 / velocity.read.width, 1 / velocity.read.height); + gl.uniform1f(u("dt"), dt); + bindTexture(u, "uVelocity", velocity.read, 0); + bindTexture(u, "uSource", velocity.read, 1); + gl.uniform1f(u("dissipation"), VELOCITY_DISSIPATION); + draw(u, velocity.write, velocity.read); + velocity.swap(); + + bindTexture(u, "uVelocity", velocity.read, 0); + bindTexture(u, "uSource", dye.read, 1); + gl.uniform1f(u("dissipation"), DYE_DISSIPATION); + draw(u, dye.write, dye.read); + dye.swap(); + }, + render(tint: [number, number, number], opacity: number) { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + const u = program("display"); + bindTexture(u, "uTexture", dye.read, 0); + gl.uniform3f(u("tint"), ...tint); + gl.uniform1f(u("opacity"), opacity); + draw(u, null, dye.read); + }, + // Frees GPU memory but keeps the context alive: the same canvas can be set + // up again (an effect re-run, or Strict Mode's double mount), and a lost + // context would come back dead. + dispose() { + for (const target of targets.splice(0)) { + gl.deleteTexture(target.texture); + gl.deleteFramebuffer(target.fbo); + } + for (const { program } of Object.values(programs)) gl.deleteProgram(program); + gl.deleteShader(vertex); + gl.deleteBuffer(quad); + }, + }; +} + +type Smoke = NonNullable>; + +/* ------------------------------------------------------------------------ */ + +function Nozzle({ + left, + top, + rotate, + power, +}: { + left: string; + top: string; + rotate: number; + power: MotionValue; +}) { + return ( + + {/* Valve body */} + + {/* Jet core: the narrow, fast stream right at the mouth */} + + + ); +} + +const ThrusterButton = React.forwardRef( + ( + { + children, + className, + thrust = 3200, + tilt = 10, + playgroundClassName, + disabled, + type = "button", + ...props + }, + ref, + ) => { + const playgroundRef = React.useRef(null); + const craftRef = React.useRef(null); + const canvasRef = React.useRef(null); + const reduceMotion = useReducedMotion(); + + // Offset from the landing pad at the bottom middle; y is negative in the air. + const x = useMotionValue(0); + const y = useMotionValue(0); + const bank = useMotionValue(0); + const rotate = useSpring(bank, { stiffness: 220, damping: 18 }); + const jetLeft = useMotionValue(0); + const jetRight = useMotionValue(0); + const jetBottom = useMotionValue(0); + + // Read live by the flight loop, so tuning these mid-flight doesn't restart it. + const settings = React.useRef({ thrust, tilt }); + React.useEffect(() => { + settings.current = { thrust, tilt }; + }, [thrust, tilt]); + + React.useEffect(() => { + const playground = playgroundRef.current; + const craft = craftRef.current; + if (!playground || !craft || reduceMotion || disabled) return; + + const pos = { x: x.get(), y: 0 }; + const vel = { x: 0, y: 0 }; + const target = { x: 0, y: 0 }; + let mode: "landed" | "flying" | "landing" = "landed"; + let frame = 0; + let last = 0; + let lingering = 0; + // Set on landing so the next frame blasts a cloud along the floor. + let touchdown = false; + // undefined: not tried yet; null: no WebGL2, so the craft flies without smoke. + let smoke: Smoke | null | undefined; + let tint: [number, number, number] = [0.8, 0.8, 0.8]; + let opacity = 0.6; + let width = 0; + let height = 0; + let simW = 0; + let simH = 0; + let halfW = 0; + let halfH = 0; + + // How far the craft can travel from its pad before touching a wall. + const bounds = () => { + const reachX = Math.max(0, width / 2 - halfW - NOZZLE - WALL_GAP); + const ceiling = Math.min(0, -(height - NOZZLE - halfH * 2 - WALL_GAP)); + return { reachX, ceiling }; + }; + + const measure = () => { + width = playground.clientWidth; + height = playground.clientHeight; + halfW = craft.offsetWidth / 2; + halfH = craft.offsetHeight / 2; + }; + + const prepareSmoke = () => { + const canvas = canvasRef.current; + if (!canvas) return; + if (smoke === undefined) smoke = createSmoke(canvas); + if (!smoke) return; + if (width !== simW || height !== simH) { + simW = width; + simH = height; + const dpr = Math.min(window.devicePixelRatio || 1, 1.5); + canvas.width = Math.max(1, Math.round(width * dpr)); + canvas.height = Math.max(1, Math.round(height * dpr)); + smoke.resize(Math.max(width, 1), Math.max(height, 1)); + } + // The smoke tint follows the theme through the canvas's text color. + const style = getComputedStyle(canvas); + tint = toRgb(style.color); + opacity = Number.parseFloat(style.getPropertyValue("--smoke-opacity")) || 0.6; + }; + + const jetPower = (force: number, max: number) => { + if (force <= max * 0.03) return 0; + return Math.min(1, force / max) ** 0.7; + }; + + const cutJets = () => { + jetLeft.set(0); + jetRight.set(0); + jetBottom.set(0); + bank.set(0); + }; + + const step = (now: number) => { + const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; + last = now; + const jets: Jet[] = []; + const sideThrust = Math.max(settings.current.thrust, 1); + // The bottom jets also carry the craft's weight. + const liftThrust = sideThrust + GRAVITY; + if (touchdown && smoke) { + touchdown = false; + // Trapped air bursts out sideways from under the craft as it settles. + const floorY = 1 - (height - 1) / height; + for (const side of [-1, 1]) { + for (const reach of [0.4, 1]) { + jets.push({ + x: (width / 2 + pos.x + side * halfW * reach) / width, + y: floorY, + dx: side, + dy: 0.15, + speed: 900, + density: 0.8, + }); + } + } + } + + if (mode !== "landed") { + const { reachX, ceiling } = bounds(); + const wantX = KP * (target.x - pos.x) - KD * vel.x; + let wantY = KP * (target.y - pos.y) - KD * vel.y; + // Only gravity can stop a climb, so cut lift once the craft would + // need all of it to stop in the height that's left. + if (vel.y < 0) { + const stopping = (vel.y * vel.y) / (2 * GRAVITY * 0.7); + if (stopping >= pos.y - target.y) wantY = Math.max(wantY, GRAVITY * 0.7); + } + const sideForce = Math.max(-sideThrust, Math.min(sideThrust, wantX)); + // Thrusting up is negative y; the jets can only push, never pull. + const liftForce = Math.max(0, Math.min(liftThrust, GRAVITY - wantY)); + + vel.x += sideForce * dt; + vel.y += (GRAVITY - liftForce) * dt; + pos.x += vel.x * dt; + pos.y += vel.y * dt; + + // Walls and ceiling knock it back; the floor soaks up most of the hit. + if (Math.abs(pos.x) > reachX) { + pos.x = Math.sign(pos.x) * reachX; + vel.x *= -0.35; + } + if (pos.y < ceiling) { + pos.y = ceiling; + vel.y *= -0.35; + } + if (pos.y > 0) { + pos.y = 0; + if (vel.y > 0) vel.y *= -0.2; + vel.x *= 0.85; + } + + if (mode === "landing" && pos.y > -0.5 && Math.hypot(vel.x, vel.y) < 8) { + // Touched down: valves shut and the smoke is left to drift. + mode = "landed"; + lingering = LINGER; + touchdown = true; + pos.y = 0; + vel.x = vel.y = 0; + x.set(pos.x); + y.set(0); + cutJets(); + } else { + // Pushing right means the left jets fire, and so on. + const power: Record = { + left: jetPower(sideForce, FULL_BLAST), + right: jetPower(-sideForce, FULL_BLAST), + bottom: jetPower(liftForce, FULL_BLAST + GRAVITY), + }; + jetLeft.set(power.left); + jetRight.set(power.right); + jetBottom.set(power.bottom); + bank.set(Math.max(-1, Math.min(1, sideForce / FULL_BLAST)) * settings.current.tilt); + + const blast = Math.max(power.left, power.right, power.bottom) * RUMBLE; + x.set(pos.x + (Math.random() - 0.5) * blast); + y.set(Math.min(0, pos.y + (Math.random() - 0.5) * blast)); + + if (smoke) { + // Craft center in playground pixels, and its current bank. + const centerX = width / 2 + pos.x; + const centerY = height - NOZZLE - halfH + pos.y; + const angle = (rotate.get() * Math.PI) / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const mouths: [Side, number, number, number, number][] = [ + ["bottom", -halfW + BOTTOM_INSET, halfH + NOZZLE, 0, 1], + ["bottom", halfW - BOTTOM_INSET, halfH + NOZZLE, 0, 1], + ["left", -halfW - NOZZLE, -SIDE_SPREAD, -1, 0], + ["left", -halfW - NOZZLE, SIDE_SPREAD, -1, 0], + ["right", halfW + NOZZLE, -SIDE_SPREAD, 1, 0], + ["right", halfW + NOZZLE, SIDE_SPREAD, 1, 0], + ]; + for (const [side, mx, my, dx, dy] of mouths) { + const p = power[side]; + if (p <= 0) continue; + const ex = dx * cos - dy * sin; + const ey = dx * sin + dy * cos; + // Valves sputter a little, so paired jets never look identical. + const sputter = 0.75 + Math.random() * 0.5; + jets.push({ + x: (centerX + mx * cos - my * sin) / width, + y: 1 - (centerY + mx * sin + my * cos) / height, + dx: ex, + dy: -ey, + speed: (380 + 520 * p) * sputter, + density: p * 0.3 * sputter * dt * 60, + }); + } + } + } + } else { + lingering -= dt; + } + + if (smoke) { + smoke.step(dt, jets, 3, width, height); + // Ease the last of the smoke out before the loop stops and clears it. + const fade = mode === "landed" ? Math.min(1, lingering / 1.2) : 1; + smoke.render(tint, opacity * Math.max(0, fade)); + } + + if (mode === "landed" && lingering <= 0) { + smoke?.clear(); + smoke?.render(tint, opacity); + frame = 0; + last = 0; + return; + } + frame = requestAnimationFrame(step); + }; + + const ignite = () => { + if (frame) return; + measure(); + prepareSmoke(); + frame = requestAnimationFrame(step); + }; + + const land = () => { + if (mode !== "flying") return; + // Come straight down from wherever it is. + mode = "landing"; + target.x = pos.x; + target.y = 0; + ignite(); + }; + + const onPointerMove = (event: PointerEvent) => { + const rect = playground.getBoundingClientRect(); + measure(); + // Back out any ancestor scale so the pointer lands in layout pixels. + const px = (event.clientX - rect.left) * (width / (rect.width || 1)); + const py = (event.clientY - rect.top) * (height / (rect.height || 1)); + if (px < 0 || py < 0 || px > width || py > height) { + land(); + return; + } + + const { reachX, ceiling } = bounds(); + // Fly the craft's center onto the pointer, as far as the walls allow. + target.x = Math.max(-reachX, Math.min(reachX, px - width / 2)); + target.y = Math.max(ceiling, Math.min(0, py - (height - NOZZLE - halfH))); + mode = "flying"; + ignite(); + }; + + const onPointerEnd = (event: PointerEvent) => { + if (event.pointerType === "touch") land(); + }; + + // Keep a landed craft inside the playground when it resizes. + const observer = new ResizeObserver(() => { + measure(); + const { reachX } = bounds(); + if (Math.abs(pos.x) > reachX) { + pos.x = Math.sign(pos.x) * reachX; + if (mode === "landed") x.set(pos.x); + } + }); + observer.observe(playground); + + window.addEventListener("pointermove", onPointerMove, { passive: true }); + window.addEventListener("pointerdown", onPointerMove, { passive: true }); + window.addEventListener("pointerup", onPointerEnd); + window.addEventListener("pointercancel", onPointerEnd); + document.documentElement.addEventListener("pointerleave", land); + window.addEventListener("blur", land); + return () => { + observer.disconnect(); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerdown", onPointerMove); + window.removeEventListener("pointerup", onPointerEnd); + window.removeEventListener("pointercancel", onPointerEnd); + document.documentElement.removeEventListener("pointerleave", land); + window.removeEventListener("blur", land); + cancelAnimationFrame(frame); + smoke?.dispose(); + x.set(pos.x); + y.set(0); + cutJets(); + }; + }, [ + reduceMotion, + disabled, + x, + y, + bank, + rotate, + jetLeft, + jetRight, + jetBottom, + ]); + + return ( +
+ {/* Smoke fills the playground; its floor is the bottom edge. */} + + + {NOZZLES.map((nozzle, i) => ( + + ))} + + {children} + + +
+ ); + }, +); + +ThrusterButton.displayName = "ThrusterButton"; + +export { ThrusterButton }; +export default ThrusterButton; diff --git a/components/landing/previews/index.tsx b/components/landing/previews/index.tsx index 4bee7038..5b89fa7d 100644 --- a/components/landing/previews/index.tsx +++ b/components/landing/previews/index.tsx @@ -33,6 +33,7 @@ import { MoviePassButtonPreview, ShinyButtonPreview, StickyButtonPreview, + ThrusterButtonPreview, ThreeDButtonPreview, TrollButtonPreview, MinecraftButtonPreview, @@ -63,6 +64,7 @@ import { StaticMoviePassButtonPreview, StaticShinyButtonPreview, StaticStickyButtonPreview, + StaticThrusterButtonPreview, StaticThreeDButtonPreview, StaticTrollButtonPreview, StaticMinecraftButtonPreview, @@ -87,6 +89,7 @@ const dialButtonPreviews: Record = { "movie-pass": MoviePassButtonPreview, "shiny-button": ShinyButtonPreview, sticky: StickyButtonPreview, + "thruster-button": ThrusterButtonPreview, "3d-button": ThreeDButtonPreview, "troll-button": TrollButtonPreview, "doubt-button": DoubtButtonPreview, @@ -118,6 +121,7 @@ const staticButtonPreviews: Record = { "movie-pass": StaticMoviePassButtonPreview, "shiny-button": StaticShinyButtonPreview, sticky: StaticStickyButtonPreview, + "thruster-button": StaticThrusterButtonPreview, "3d-button": StaticThreeDButtonPreview, "troll-button": StaticTrollButtonPreview, "doubt-button": StaticDoubtButtonPreview, diff --git a/components/landing/previews/simple.tsx b/components/landing/previews/simple.tsx index ca4250f8..a92373b9 100644 --- a/components/landing/previews/simple.tsx +++ b/components/landing/previews/simple.tsx @@ -12,6 +12,7 @@ import MoviePassButton, { } from "@/components/evil-buttons/movie-pass"; import ShinyButton from "@/components/evil-buttons/shiny-button"; import StickyButton from "@/components/evil-buttons/sticky"; +import ThrusterButton from "@/components/evil-buttons/thruster-button"; import { ThreeDButton } from "@/components/evil-buttons/3d-button"; import { DeferredWebGLPreview } from "./shared"; import { LiveProps } from "./live-props"; @@ -149,6 +150,26 @@ export function StickyButtonPreview() { ); } +export function ThrusterButtonPreview() { + const p = useDialKit( + "ThrusterButton", + { + label: "Liftoff", + thrust: [3200, 400, 8000], + tilt: [10, 0, 30], + }, + { id: "thruster-button" }, + ); + + return ( + + + {p.label} + + + ); +} + export function ThreeDButtonPreview() { const p = useDialKit( "ThreeDButton", diff --git a/components/landing/previews/static.tsx b/components/landing/previews/static.tsx index cf158659..5f79da28 100644 --- a/components/landing/previews/static.tsx +++ b/components/landing/previews/static.tsx @@ -30,6 +30,7 @@ import MinimalButton from "@/components/evil-buttons/minimal"; import MoviePassButton from "@/components/evil-buttons/movie-pass"; import ShinyButton from "@/components/evil-buttons/shiny-button"; import StickyButton from "@/components/evil-buttons/sticky"; +import ThrusterButton from "@/components/evil-buttons/thruster-button"; import { ThreeDButton } from "@/components/evil-buttons/3d-button"; import { DeferredWebGLPreview } from "./shared"; import { themeColors } from "./theme"; @@ -251,6 +252,15 @@ export function StaticStickyButtonPreview() { return Try to Click; } +export function StaticThrusterButtonPreview() { + // The card scales its preview, so give the craft a fixed-size playground. + return ( +
+ Liftoff +
+ ); +} + export function StaticThreeDButtonPreview() { return Continue; } diff --git a/components/landing/showcase.ts b/components/landing/showcase.ts index 31f5c6f5..f6705c2a 100644 --- a/components/landing/showcase.ts +++ b/components/landing/showcase.ts @@ -25,6 +25,7 @@ export const showcase: ShowcaseEntry[] = [ { name: "MoviePassButton", href: "/docs/movie-pass", registryName: "movie-pass", variants: ["tilt", "snap"] }, { name: "ShinyButton", href: "/docs/shiny-button", registryName: "shiny-button" }, { name: "StickyButton", href: "/docs/sticky-button", registryName: "sticky" }, + { name: "ThrusterButton", href: "/docs/thruster-button", registryName: "thruster-button" }, { name: "ThreeDButton", href: "/docs/3d-button", registryName: "3d-button" }, { name: "TrollButton", href: "/docs/troll-button", registryName: "troll-button" }, { name: "DoubtButton", href: "/docs/doubt-button", registryName: "doubt-button" }, diff --git a/components/live-preview.tsx b/components/live-preview.tsx index 342d2962..0c11615e 100644 --- a/components/live-preview.tsx +++ b/components/live-preview.tsx @@ -100,7 +100,7 @@ export function LivePreview({ source, fallback }: LivePreviewProps) { return ( -
+
diff --git a/components/mdx-custom-components.tsx b/components/mdx-custom-components.tsx index 58d0071f..5cc046b8 100644 --- a/components/mdx-custom-components.tsx +++ b/components/mdx-custom-components.tsx @@ -2,6 +2,7 @@ import { CliBlock } from "@/components/cli-block"; import { ClickPowerUp } from "@/components/evil-buttons/click-powerup"; import MinimalButton from "@/components/evil-buttons/minimal"; import StickyButton from "@/components/evil-buttons/sticky"; +import ThrusterButton from "@/components/evil-buttons/thruster-button"; import { PreviewCard } from "@/components/preview-card"; import type { MDXComponents } from "mdx/types"; import { isValidElement, type ReactNode } from "react"; @@ -101,6 +102,7 @@ export function getCustomMDXComponents(): MDXComponents { Cmd, ClickPowerUp, StickyButton, + ThrusterButton, ShinyButton, MoviePassButton, MinimalButton, diff --git a/content/docs/thruster-button.mdx b/content/docs/thruster-button.mdx new file mode 100644 index 00000000..28c5c66a --- /dev/null +++ b/content/docs/thruster-button.mdx @@ -0,0 +1,68 @@ +--- +title: ThrusterButton +description: A magnetic button that rests on the floor of its container, then flies to the cursor anywhere inside it on compressed-air jets, trailing WebGL smoke, and lands where it is when the cursor leaves. +category: Mischief +--- + +## Preview + + + Liftoff + + +## Install + +@evilbuttons/thruster-button + +## Usage + +The button fills its nearest positioned ancestor and treats it as its playground, so give it a `relative` container with some height. + +```tsx +import ThrusterButton from "@/components/evil-buttons/thruster-button"; + +export function ButtonDemo() { + return ( +
+ Launch +
+ ); +} +``` + +## Variants + +### Afterburner + +High thrust makes it snap across the playground and slam on the brakes. + + + Full Burn + + +### Low Fuel + +Weak jets make it drift over slowly, sag and overshoot before it settles. + + + Running on Fumes + + +## Props + +The component spreads native `button` props and forwards its ref to the button. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `children` | `React.ReactNode` | - | The button label or content. | +| `thrust` | `number` | `3200` | Net jet acceleration in px/s². Lower values drift, higher values snap. | +| `tilt` | `number` | `10` | Maximum bank in degrees while the craft accelerates sideways. | +| `playgroundClassName` | `string` | - | Classes for the playground, which is `absolute inset-0` by default. Use it to inset the floor or walls. | +| `className` | `string` | - | Extra classes passed to the button. | + +## Good to know + +- It starts parked on the floor. Move the cursor into the playground and it lifts off and flies its center onto the pointer, bouncing off the walls and ceiling. Move the cursor out and it comes straight down and lands where it is. +- Two air jets sit on each of the bottom, left and right edges. There are no top jets, so it descends by easing off the bottom pair and letting gravity pull it down, and it hisses softly to hold a hover. +- The smoke is a small WebGL2 fluid simulation (advection, pressure projection and vorticity confinement) filling the playground. The jets inject velocity and density into it, so the gas curls, rolls along the floor on landing and drifts where it was vented. Without WebGL2 the button still flies, just without smoke. +- The loop stops once it has landed and the smoke clears. Reduced motion and `disabled` keep it parked. diff --git a/public/llms.txt b/public/llms.txt index c1b0c89f..3da76897 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -18,6 +18,7 @@ npx shadcn@latest add @evilbuttons/ - [ClickPowerUp](https://www.evilbuttons.com/docs/click-power-up): A bracketed button that arms on hover and powers up with an accent flash and bracket burst on every click. - [StickyButton](https://www.evilbuttons.com/docs/sticky-button): A magnetic button whose body and label are pulled toward the cursor from a wide field, with parallax depth and tilt. +- [ThrusterButton](https://www.evilbuttons.com/docs/thruster-button): A magnetic button that rests on the floor of its container, then flies to the cursor anywhere inside it on compressed-air jets, trailing WebGL smoke, and lands where it is when the cursor leaves. - [ShinyButton](https://www.evilbuttons.com/docs/shiny-button): A glossy, gradient-styled button with a layered inner glow and a glint that sweeps across on every click. - [MoviePassButton](https://www.evilbuttons.com/docs/movie-pass): A cinema ticket button that tears along its perforation on click, hinging open or snapping apart. - [MinimalButton](https://www.evilbuttons.com/docs/minimal-button): A quiet dark button with a hairline ring, inner highlight, soft drop shadow, and a chevron that nudges on hover. diff --git a/public/r/index.json b/public/r/index.json index bceed754..3ac089b8 100644 --- a/public/r/index.json +++ b/public/r/index.json @@ -21,6 +21,15 @@ "components/evil-buttons/sticky.tsx" ] }, + { + "name": "thruster-button", + "type": "registry:ui", + "title": "ThrusterButton", + "description": "A magnetic button that rests on the floor of its container, then flies to the cursor anywhere inside it on compressed-air jets, trailing WebGL smoke, and lands where it is when the cursor leaves.", + "files": [ + "components/evil-buttons/thruster-button.tsx" + ] + }, { "name": "shiny-button", "type": "registry:ui", diff --git a/public/r/thruster-button.json b/public/r/thruster-button.json new file mode 100644 index 00000000..966c1635 --- /dev/null +++ b/public/r/thruster-button.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "thruster-button", + "type": "registry:ui", + "title": "ThrusterButton", + "description": "A magnetic button that rests on the floor of its container, then flies to the cursor anywhere inside it on compressed-air jets, trailing WebGL smoke, and lands where it is when the cursor leaves.", + "files": [ + { + "path": "components/evil-buttons/thruster-button.tsx", + "type": "registry:ui", + "target": "components/evil-buttons/thruster-button.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n motion,\n useMotionValue,\n useReducedMotion,\n useSpring,\n type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ThrusterButtonProps\n extends Omit<\n React.ButtonHTMLAttributes,\n \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n > {\n /** Net thruster acceleration in px/s². Lower values drift, higher values snap. */\n thrust?: number;\n /** Maximum bank in degrees while the craft accelerates sideways. */\n tilt?: number;\n /** Classes for the playground, which fills the nearest positioned ancestor by default. */\n playgroundClassName?: string;\n}\n\ntype Side = \"left\" | \"right\" | \"bottom\";\n\n// Autopilot gains: a slightly underdamped spring, so the craft overshoots the\n// pointer and fires its opposite jets to brake.\nconst KP = 90;\nconst KD = 2 * 0.55 * Math.sqrt(KP);\n// There are no top jets, so the craft descends by easing off the bottom ones.\nconst GRAVITY = 1100;\n// Jet visuals are scaled against this fixed force, not the `thrust` prop, so the\n// same push always looks the same: a stronger craft only blasts harder when it\n// actually accelerates harder.\nconst FULL_BLAST = 3200;\n// Valve chatter in px at full blast.\nconst RUMBLE = 0.35;\n// Nozzle length in px; gas leaves from its mouth.\nconst NOZZLE = 5;\n// Bottom pair sits this far in from the corners; side pairs this far from the middle.\nconst BOTTOM_INSET = 14;\nconst SIDE_SPREAD = 8;\n// Gap kept between the craft and the playground walls.\nconst WALL_GAP = 2;\n// How long the smoke keeps drifting after the craft docks.\nconst LINGER = 2.2;\n\n// Two valves per edge. Each is drawn pointing down, then rotated onto its edge.\nconst NOZZLES: {\n side: Side;\n left: string;\n top: string;\n rotate: number;\n}[] = [\n { side: \"bottom\", left: `${BOTTOM_INSET}px`, top: \"100%\", rotate: 0 },\n { side: \"bottom\", left: `calc(100% - ${BOTTOM_INSET}px)`, top: \"100%\", rotate: 0 },\n { side: \"left\", left: \"0px\", top: `calc(50% - ${SIDE_SPREAD}px)`, rotate: 90 },\n { side: \"left\", left: \"0px\", top: `calc(50% + ${SIDE_SPREAD}px)`, rotate: 90 },\n { side: \"right\", left: \"100%\", top: `calc(50% - ${SIDE_SPREAD}px)`, rotate: -90 },\n { side: \"right\", left: \"100%\", top: `calc(50% + ${SIDE_SPREAD}px)`, rotate: -90 },\n];\n\n/** Resolves any CSS color (oklch included) to 0–1 RGB. */\nfunction toRgb(color: string): [number, number, number] {\n const probe = document.createElement(\"canvas\");\n probe.width = probe.height = 1;\n const ctx = probe.getContext(\"2d\");\n if (!ctx) return [0.8, 0.8, 0.8];\n ctx.fillStyle = color;\n ctx.fillRect(0, 0, 1, 1);\n const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;\n return [r / 255, g / 255, b / 255];\n}\n\n/* ------------------------------------------------------------------------ */\n/* Smoke: a small GPU stable-fluids solver (advect, project, confine curl). */\n/* ------------------------------------------------------------------------ */\n\nconst MAX_JETS = 6;\nconst SIM_RES = 128;\nconst DYE_RES = 384;\nconst PRESSURE_ITERATIONS = 20;\nconst VORTICITY = 25;\nconst VELOCITY_DISSIPATION = 0.6;\nconst DYE_DISSIPATION = 1.5;\n\nconst VERTEX = `#version 300 es\nin vec2 aPosition;\nout vec2 vUv, vL, vR, vT, vB;\nuniform vec2 texelSize;\nvoid main() {\n vUv = aPosition * 0.5 + 0.5;\n vL = vUv - vec2(texelSize.x, 0.0);\n vR = vUv + vec2(texelSize.x, 0.0);\n vT = vUv + vec2(0.0, texelSize.y);\n vB = vUv - vec2(0.0, texelSize.y);\n gl_Position = vec4(aPosition, 0.0, 1.0);\n}`;\n\nconst HEADER = `#version 300 es\nprecision highp float;\nprecision highp sampler2D;\nin vec2 vUv, vL, vR, vT, vB;\nout vec4 fragColor;\n`;\n\nconst SHADERS = {\n // Elongated gaussians stretched along each jet's direction. Velocity is\n // pinned to the jet's (an inflow), while smoke density accumulates.\n splat: `${HEADER}\nuniform sampler2D uTarget;\nuniform float aspectRatio;\nuniform float radius;\nuniform bool pin;\nuniform int count;\nuniform vec2 points[${MAX_JETS}];\nuniform vec2 dirs[${MAX_JETS}];\nuniform vec3 values[${MAX_JETS}];\nvoid main() {\n vec3 base = texture(uTarget, vUv).xyz;\n for (int i = 0; i < ${MAX_JETS}; i++) {\n if (i >= count) break;\n vec2 p = vUv - points[i];\n p.x *= aspectRatio;\n float along = dot(p, dirs[i]);\n float across = dot(p, vec2(-dirs[i].y, dirs[i].x));\n float g = exp(-(along * along * 0.3 + across * across) / radius);\n base = pin ? mix(base, values[i], min(g, 1.0)) : base + values[i] * g;\n }\n fragColor = vec4(base, 1.0);\n}`,\n advection: `${HEADER}\nuniform sampler2D uVelocity;\nuniform sampler2D uSource;\nuniform vec2 velocityTexel;\nuniform float dt;\nuniform float dissipation;\nvoid main() {\n vec2 coord = vUv - dt * texture(uVelocity, vUv).xy * velocityTexel;\n fragColor = texture(uSource, coord) / (1.0 + dissipation * dt);\n}`,\n divergence: `${HEADER}\nuniform sampler2D uVelocity;\nvoid main() {\n float L = texture(uVelocity, vL).x;\n float R = texture(uVelocity, vR).x;\n float T = texture(uVelocity, vT).y;\n float B = texture(uVelocity, vB).y;\n vec2 C = texture(uVelocity, vUv).xy;\n if (vL.x < 0.0) L = -C.x;\n if (vR.x > 1.0) R = -C.x;\n if (vT.y > 1.0) T = -C.y;\n if (vB.y < 0.0) B = -C.y;\n fragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);\n}`,\n curl: `${HEADER}\nuniform sampler2D uVelocity;\nvoid main() {\n float L = texture(uVelocity, vL).y;\n float R = texture(uVelocity, vR).y;\n float T = texture(uVelocity, vT).x;\n float B = texture(uVelocity, vB).x;\n fragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);\n}`,\n // Vorticity confinement feeds the small eddies back so the smoke keeps curling.\n vorticity: `${HEADER}\nuniform sampler2D uVelocity;\nuniform sampler2D uCurl;\nuniform float curl;\nuniform float dt;\nvoid main() {\n float L = texture(uCurl, vL).x;\n float R = texture(uCurl, vR).x;\n float T = texture(uCurl, vT).x;\n float B = texture(uCurl, vB).x;\n float C = texture(uCurl, vUv).x;\n vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));\n force /= length(force) + 0.0001;\n force *= curl * C;\n force.y *= -1.0;\n vec2 velocity = texture(uVelocity, vUv).xy + force * dt;\n fragColor = vec4(clamp(velocity, -1000.0, 1000.0), 0.0, 1.0);\n}`,\n pressure: `${HEADER}\nuniform sampler2D uPressure;\nuniform sampler2D uDivergence;\nvoid main() {\n float L = texture(uPressure, vL).x;\n float R = texture(uPressure, vR).x;\n float T = texture(uPressure, vT).x;\n float B = texture(uPressure, vB).x;\n float divergence = texture(uDivergence, vUv).x;\n fragColor = vec4((L + R + B + T - divergence) * 0.25, 0.0, 0.0, 1.0);\n}`,\n gradientSubtract: `${HEADER}\nuniform sampler2D uPressure;\nuniform sampler2D uVelocity;\nvoid main() {\n float L = texture(uPressure, vL).x;\n float R = texture(uPressure, vR).x;\n float T = texture(uPressure, vT).x;\n float B = texture(uPressure, vB).x;\n vec2 velocity = texture(uVelocity, vUv).xy - vec2(R - L, T - B);\n fragColor = vec4(velocity, 0.0, 1.0);\n}`,\n scale: `${HEADER}\nuniform sampler2D uTexture;\nuniform float value;\nvoid main() {\n fragColor = value * texture(uTexture, vUv);\n}`,\n // Density becomes flat, soft smoke in the tint color.\n display: `${HEADER}\nuniform sampler2D uTexture;\nuniform vec3 tint;\nuniform float opacity;\nvoid main() {\n float alpha = (1.0 - exp(-texture(uTexture, vUv).r * 1.8)) * opacity;\n fragColor = vec4(tint * alpha, alpha);\n}`,\n} as const;\n\ntype ShaderName = keyof typeof SHADERS;\n\ntype Target = {\n texture: WebGLTexture;\n fbo: WebGLFramebuffer;\n width: number;\n height: number;\n};\n\ntype DoubleTarget = { read: Target; write: Target; swap: () => void };\n\ntype Jet = {\n /** Mouth position in canvas UV, y up. */\n x: number;\n y: number;\n /** Unit direction in aspect-corrected space, y up. */\n dx: number;\n dy: number;\n /** Jet speed in px/s. */\n speed: number;\n /** Smoke emitted this frame. */\n density: number;\n};\n\nfunction createSmoke(canvas: HTMLCanvasElement) {\n const gl = canvas.getContext(\"webgl2\", {\n alpha: true,\n premultipliedAlpha: true,\n antialias: false,\n depth: false,\n stencil: false,\n preserveDrawingBuffer: false,\n });\n if (!gl || !gl.getExtension(\"EXT_color_buffer_float\")) return null;\n\n const compile = (type: number, source: string) => {\n const shader = gl.createShader(type)!;\n gl.shaderSource(shader, source);\n gl.compileShader(shader);\n return shader;\n };\n const vertex = compile(gl.VERTEX_SHADER, VERTEX);\n\n const programs = {} as Record<\n ShaderName,\n { program: WebGLProgram; uniform: (name: string) => WebGLUniformLocation | null }\n >;\n for (const name of Object.keys(SHADERS) as ShaderName[]) {\n const program = gl.createProgram()!;\n gl.attachShader(program, vertex);\n gl.attachShader(program, compile(gl.FRAGMENT_SHADER, SHADERS[name]));\n gl.bindAttribLocation(program, 0, \"aPosition\");\n gl.linkProgram(program);\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;\n const cache = new Map();\n programs[name] = {\n program,\n uniform: (uniform) => {\n if (!cache.has(uniform)) cache.set(uniform, gl.getUniformLocation(program, uniform));\n return cache.get(uniform)!;\n },\n };\n }\n\n const quad = gl.createBuffer();\n gl.bindBuffer(gl.ARRAY_BUFFER, quad);\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),\n gl.STATIC_DRAW,\n );\n gl.enableVertexAttribArray(0);\n gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n\n const targets: Target[] = [];\n const createTarget = (width: number, height: number): Target => {\n const texture = gl.createTexture()!;\n gl.bindTexture(gl.TEXTURE_2D, texture);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null);\n const fbo = gl.createFramebuffer()!;\n gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n const target = { texture, fbo, width, height };\n targets.push(target);\n return target;\n };\n const createDouble = (width: number, height: number): DoubleTarget => {\n const pair = {\n read: createTarget(width, height),\n write: createTarget(width, height),\n swap: () => {\n [pair.read, pair.write] = [pair.write, pair.read];\n },\n };\n return pair;\n };\n\n let velocity: DoubleTarget;\n let dye: DoubleTarget;\n let pressure: DoubleTarget;\n let divergence: Target;\n let curl: Target;\n let aspect = 1;\n\n const program = (name: ShaderName) => {\n const entry = programs[name];\n gl.useProgram(entry.program);\n return entry.uniform;\n };\n const bindTexture = (\n uniform: (name: string) => WebGLUniformLocation | null,\n name: string,\n target: Target,\n unit: number,\n ) => {\n gl.activeTexture(gl.TEXTURE0 + unit);\n gl.bindTexture(gl.TEXTURE_2D, target.texture);\n gl.uniform1i(uniform(name), unit);\n };\n const draw = (\n uniform: (name: string) => WebGLUniformLocation | null,\n target: Target | null,\n texel: Target,\n ) => {\n gl.uniform2f(uniform(\"texelSize\"), 1 / texel.width, 1 / texel.height);\n gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fbo : null);\n gl.viewport(0, 0, target ? target.width : gl.drawingBufferWidth, target ? target.height : gl.drawingBufferHeight);\n gl.drawArrays(gl.TRIANGLES, 0, 6);\n };\n\n const gridFor = (base: number) =>\n aspect >= 1\n ? [Math.round(base * aspect), base]\n : [base, Math.round(base / aspect)];\n\n const resize = (width: number, height: number) => {\n for (const target of targets.splice(0)) {\n gl.deleteTexture(target.texture);\n gl.deleteFramebuffer(target.fbo);\n }\n aspect = width / height;\n const [simW, simH] = gridFor(SIM_RES);\n const [dyeW, dyeH] = gridFor(DYE_RES);\n velocity = createDouble(simW, simH);\n pressure = createDouble(simW, simH);\n divergence = createTarget(simW, simH);\n curl = createTarget(simW, simH);\n dye = createDouble(dyeW, dyeH);\n };\n\n const splat = (\n field: DoubleTarget,\n jets: Jet[],\n radius: number,\n pin: boolean,\n value: (jet: Jet) => number[],\n ) => {\n const u = program(\"splat\");\n bindTexture(u, \"uTarget\", field.read, 0);\n gl.uniform1i(u(\"pin\"), pin ? 1 : 0);\n gl.uniform1f(u(\"aspectRatio\"), aspect);\n gl.uniform1f(u(\"radius\"), radius);\n gl.uniform1i(u(\"count\"), jets.length);\n gl.uniform2fv(u(\"points\"), jets.flatMap((jet) => [jet.x, jet.y]));\n gl.uniform2fv(u(\"dirs\"), jets.flatMap((jet) => [jet.dx, jet.dy]));\n gl.uniform3fv(u(\"values\"), jets.flatMap(value));\n draw(u, field.write, field.read);\n field.swap();\n };\n\n return {\n resize,\n clear() {\n for (const target of targets) {\n gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n }\n },\n /** `cssWidth`/`cssHeight` map jet speeds in px/s onto the grid. */\n step(dt: number, jets: Jet[], jetRadiusPx: number, cssWidth: number, cssHeight: number) {\n gl.disable(gl.BLEND);\n\n if (jets.length) {\n const radius = (jetRadiusPx / cssHeight) ** 2;\n const pxToGridX = velocity.read.width / cssWidth;\n const pxToGridY = velocity.read.height / cssHeight;\n splat(velocity, jets, radius, true, (jet) => [\n jet.dx * jet.speed * pxToGridX,\n jet.dy * jet.speed * pxToGridY,\n 0,\n ]);\n splat(dye, jets, radius, false, (jet) => [jet.density, jet.density, jet.density]);\n }\n\n let u = program(\"curl\");\n bindTexture(u, \"uVelocity\", velocity.read, 0);\n draw(u, curl, velocity.read);\n\n u = program(\"vorticity\");\n bindTexture(u, \"uVelocity\", velocity.read, 0);\n bindTexture(u, \"uCurl\", curl, 1);\n gl.uniform1f(u(\"curl\"), VORTICITY);\n gl.uniform1f(u(\"dt\"), dt);\n draw(u, velocity.write, velocity.read);\n velocity.swap();\n\n u = program(\"divergence\");\n bindTexture(u, \"uVelocity\", velocity.read, 0);\n draw(u, divergence, velocity.read);\n\n u = program(\"scale\");\n bindTexture(u, \"uTexture\", pressure.read, 0);\n gl.uniform1f(u(\"value\"), 0.8);\n draw(u, pressure.write, pressure.read);\n pressure.swap();\n\n u = program(\"pressure\");\n bindTexture(u, \"uDivergence\", divergence, 1);\n for (let i = 0; i < PRESSURE_ITERATIONS; i++) {\n bindTexture(u, \"uPressure\", pressure.read, 0);\n draw(u, pressure.write, pressure.read);\n pressure.swap();\n }\n\n u = program(\"gradientSubtract\");\n bindTexture(u, \"uPressure\", pressure.read, 0);\n bindTexture(u, \"uVelocity\", velocity.read, 1);\n draw(u, velocity.write, velocity.read);\n velocity.swap();\n\n u = program(\"advection\");\n gl.uniform2f(u(\"velocityTexel\"), 1 / velocity.read.width, 1 / velocity.read.height);\n gl.uniform1f(u(\"dt\"), dt);\n bindTexture(u, \"uVelocity\", velocity.read, 0);\n bindTexture(u, \"uSource\", velocity.read, 1);\n gl.uniform1f(u(\"dissipation\"), VELOCITY_DISSIPATION);\n draw(u, velocity.write, velocity.read);\n velocity.swap();\n\n bindTexture(u, \"uVelocity\", velocity.read, 0);\n bindTexture(u, \"uSource\", dye.read, 1);\n gl.uniform1f(u(\"dissipation\"), DYE_DISSIPATION);\n draw(u, dye.write, dye.read);\n dye.swap();\n },\n render(tint: [number, number, number], opacity: number) {\n gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n const u = program(\"display\");\n bindTexture(u, \"uTexture\", dye.read, 0);\n gl.uniform3f(u(\"tint\"), ...tint);\n gl.uniform1f(u(\"opacity\"), opacity);\n draw(u, null, dye.read);\n },\n // Frees GPU memory but keeps the context alive: the same canvas can be set\n // up again (an effect re-run, or Strict Mode's double mount), and a lost\n // context would come back dead.\n dispose() {\n for (const target of targets.splice(0)) {\n gl.deleteTexture(target.texture);\n gl.deleteFramebuffer(target.fbo);\n }\n for (const { program } of Object.values(programs)) gl.deleteProgram(program);\n gl.deleteShader(vertex);\n gl.deleteBuffer(quad);\n },\n };\n}\n\ntype Smoke = NonNullable>;\n\n/* ------------------------------------------------------------------------ */\n\nfunction Nozzle({\n left,\n top,\n rotate,\n power,\n}: {\n left: string;\n top: string;\n rotate: number;\n power: MotionValue;\n}) {\n return (\n \n {/* Valve body */}\n \n {/* Jet core: the narrow, fast stream right at the mouth */}\n \n \n );\n}\n\nconst ThrusterButton = React.forwardRef(\n (\n {\n children,\n className,\n thrust = 3200,\n tilt = 10,\n playgroundClassName,\n disabled,\n type = \"button\",\n ...props\n },\n ref,\n ) => {\n const playgroundRef = React.useRef(null);\n const craftRef = React.useRef(null);\n const canvasRef = React.useRef(null);\n const reduceMotion = useReducedMotion();\n\n // Offset from the landing pad at the bottom middle; y is negative in the air.\n const x = useMotionValue(0);\n const y = useMotionValue(0);\n const bank = useMotionValue(0);\n const rotate = useSpring(bank, { stiffness: 220, damping: 18 });\n const jetLeft = useMotionValue(0);\n const jetRight = useMotionValue(0);\n const jetBottom = useMotionValue(0);\n\n // Read live by the flight loop, so tuning these mid-flight doesn't restart it.\n const settings = React.useRef({ thrust, tilt });\n React.useEffect(() => {\n settings.current = { thrust, tilt };\n }, [thrust, tilt]);\n\n React.useEffect(() => {\n const playground = playgroundRef.current;\n const craft = craftRef.current;\n if (!playground || !craft || reduceMotion || disabled) return;\n\n const pos = { x: x.get(), y: 0 };\n const vel = { x: 0, y: 0 };\n const target = { x: 0, y: 0 };\n let mode: \"landed\" | \"flying\" | \"landing\" = \"landed\";\n let frame = 0;\n let last = 0;\n let lingering = 0;\n // Set on landing so the next frame blasts a cloud along the floor.\n let touchdown = false;\n // undefined: not tried yet; null: no WebGL2, so the craft flies without smoke.\n let smoke: Smoke | null | undefined;\n let tint: [number, number, number] = [0.8, 0.8, 0.8];\n let opacity = 0.6;\n let width = 0;\n let height = 0;\n let simW = 0;\n let simH = 0;\n let halfW = 0;\n let halfH = 0;\n\n // How far the craft can travel from its pad before touching a wall.\n const bounds = () => {\n const reachX = Math.max(0, width / 2 - halfW - NOZZLE - WALL_GAP);\n const ceiling = Math.min(0, -(height - NOZZLE - halfH * 2 - WALL_GAP));\n return { reachX, ceiling };\n };\n\n const measure = () => {\n width = playground.clientWidth;\n height = playground.clientHeight;\n halfW = craft.offsetWidth / 2;\n halfH = craft.offsetHeight / 2;\n };\n\n const prepareSmoke = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n if (smoke === undefined) smoke = createSmoke(canvas);\n if (!smoke) return;\n if (width !== simW || height !== simH) {\n simW = width;\n simH = height;\n const dpr = Math.min(window.devicePixelRatio || 1, 1.5);\n canvas.width = Math.max(1, Math.round(width * dpr));\n canvas.height = Math.max(1, Math.round(height * dpr));\n smoke.resize(Math.max(width, 1), Math.max(height, 1));\n }\n // The smoke tint follows the theme through the canvas's text color.\n const style = getComputedStyle(canvas);\n tint = toRgb(style.color);\n opacity = Number.parseFloat(style.getPropertyValue(\"--smoke-opacity\")) || 0.6;\n };\n\n const jetPower = (force: number, max: number) => {\n if (force <= max * 0.03) return 0;\n return Math.min(1, force / max) ** 0.7;\n };\n\n const cutJets = () => {\n jetLeft.set(0);\n jetRight.set(0);\n jetBottom.set(0);\n bank.set(0);\n };\n\n const step = (now: number) => {\n const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;\n last = now;\n const jets: Jet[] = [];\n const sideThrust = Math.max(settings.current.thrust, 1);\n // The bottom jets also carry the craft's weight.\n const liftThrust = sideThrust + GRAVITY;\n if (touchdown && smoke) {\n touchdown = false;\n // Trapped air bursts out sideways from under the craft as it settles.\n const floorY = 1 - (height - 1) / height;\n for (const side of [-1, 1]) {\n for (const reach of [0.4, 1]) {\n jets.push({\n x: (width / 2 + pos.x + side * halfW * reach) / width,\n y: floorY,\n dx: side,\n dy: 0.15,\n speed: 900,\n density: 0.8,\n });\n }\n }\n }\n\n if (mode !== \"landed\") {\n const { reachX, ceiling } = bounds();\n const wantX = KP * (target.x - pos.x) - KD * vel.x;\n let wantY = KP * (target.y - pos.y) - KD * vel.y;\n // Only gravity can stop a climb, so cut lift once the craft would\n // need all of it to stop in the height that's left.\n if (vel.y < 0) {\n const stopping = (vel.y * vel.y) / (2 * GRAVITY * 0.7);\n if (stopping >= pos.y - target.y) wantY = Math.max(wantY, GRAVITY * 0.7);\n }\n const sideForce = Math.max(-sideThrust, Math.min(sideThrust, wantX));\n // Thrusting up is negative y; the jets can only push, never pull.\n const liftForce = Math.max(0, Math.min(liftThrust, GRAVITY - wantY));\n\n vel.x += sideForce * dt;\n vel.y += (GRAVITY - liftForce) * dt;\n pos.x += vel.x * dt;\n pos.y += vel.y * dt;\n\n // Walls and ceiling knock it back; the floor soaks up most of the hit.\n if (Math.abs(pos.x) > reachX) {\n pos.x = Math.sign(pos.x) * reachX;\n vel.x *= -0.35;\n }\n if (pos.y < ceiling) {\n pos.y = ceiling;\n vel.y *= -0.35;\n }\n if (pos.y > 0) {\n pos.y = 0;\n if (vel.y > 0) vel.y *= -0.2;\n vel.x *= 0.85;\n }\n\n if (mode === \"landing\" && pos.y > -0.5 && Math.hypot(vel.x, vel.y) < 8) {\n // Touched down: valves shut and the smoke is left to drift.\n mode = \"landed\";\n lingering = LINGER;\n touchdown = true;\n pos.y = 0;\n vel.x = vel.y = 0;\n x.set(pos.x);\n y.set(0);\n cutJets();\n } else {\n // Pushing right means the left jets fire, and so on.\n const power: Record = {\n left: jetPower(sideForce, FULL_BLAST),\n right: jetPower(-sideForce, FULL_BLAST),\n bottom: jetPower(liftForce, FULL_BLAST + GRAVITY),\n };\n jetLeft.set(power.left);\n jetRight.set(power.right);\n jetBottom.set(power.bottom);\n bank.set(Math.max(-1, Math.min(1, sideForce / FULL_BLAST)) * settings.current.tilt);\n\n const blast = Math.max(power.left, power.right, power.bottom) * RUMBLE;\n x.set(pos.x + (Math.random() - 0.5) * blast);\n y.set(Math.min(0, pos.y + (Math.random() - 0.5) * blast));\n\n if (smoke) {\n // Craft center in playground pixels, and its current bank.\n const centerX = width / 2 + pos.x;\n const centerY = height - NOZZLE - halfH + pos.y;\n const angle = (rotate.get() * Math.PI) / 180;\n const cos = Math.cos(angle);\n const sin = Math.sin(angle);\n const mouths: [Side, number, number, number, number][] = [\n [\"bottom\", -halfW + BOTTOM_INSET, halfH + NOZZLE, 0, 1],\n [\"bottom\", halfW - BOTTOM_INSET, halfH + NOZZLE, 0, 1],\n [\"left\", -halfW - NOZZLE, -SIDE_SPREAD, -1, 0],\n [\"left\", -halfW - NOZZLE, SIDE_SPREAD, -1, 0],\n [\"right\", halfW + NOZZLE, -SIDE_SPREAD, 1, 0],\n [\"right\", halfW + NOZZLE, SIDE_SPREAD, 1, 0],\n ];\n for (const [side, mx, my, dx, dy] of mouths) {\n const p = power[side];\n if (p <= 0) continue;\n const ex = dx * cos - dy * sin;\n const ey = dx * sin + dy * cos;\n // Valves sputter a little, so paired jets never look identical.\n const sputter = 0.75 + Math.random() * 0.5;\n jets.push({\n x: (centerX + mx * cos - my * sin) / width,\n y: 1 - (centerY + mx * sin + my * cos) / height,\n dx: ex,\n dy: -ey,\n speed: (380 + 520 * p) * sputter,\n density: p * 0.3 * sputter * dt * 60,\n });\n }\n }\n }\n } else {\n lingering -= dt;\n }\n\n if (smoke) {\n smoke.step(dt, jets, 3, width, height);\n // Ease the last of the smoke out before the loop stops and clears it.\n const fade = mode === \"landed\" ? Math.min(1, lingering / 1.2) : 1;\n smoke.render(tint, opacity * Math.max(0, fade));\n }\n\n if (mode === \"landed\" && lingering <= 0) {\n smoke?.clear();\n smoke?.render(tint, opacity);\n frame = 0;\n last = 0;\n return;\n }\n frame = requestAnimationFrame(step);\n };\n\n const ignite = () => {\n if (frame) return;\n measure();\n prepareSmoke();\n frame = requestAnimationFrame(step);\n };\n\n const land = () => {\n if (mode !== \"flying\") return;\n // Come straight down from wherever it is.\n mode = \"landing\";\n target.x = pos.x;\n target.y = 0;\n ignite();\n };\n\n const onPointerMove = (event: PointerEvent) => {\n const rect = playground.getBoundingClientRect();\n measure();\n // Back out any ancestor scale so the pointer lands in layout pixels.\n const px = (event.clientX - rect.left) * (width / (rect.width || 1));\n const py = (event.clientY - rect.top) * (height / (rect.height || 1));\n if (px < 0 || py < 0 || px > width || py > height) {\n land();\n return;\n }\n\n const { reachX, ceiling } = bounds();\n // Fly the craft's center onto the pointer, as far as the walls allow.\n target.x = Math.max(-reachX, Math.min(reachX, px - width / 2));\n target.y = Math.max(ceiling, Math.min(0, py - (height - NOZZLE - halfH)));\n mode = \"flying\";\n ignite();\n };\n\n const onPointerEnd = (event: PointerEvent) => {\n if (event.pointerType === \"touch\") land();\n };\n\n // Keep a landed craft inside the playground when it resizes.\n const observer = new ResizeObserver(() => {\n measure();\n const { reachX } = bounds();\n if (Math.abs(pos.x) > reachX) {\n pos.x = Math.sign(pos.x) * reachX;\n if (mode === \"landed\") x.set(pos.x);\n }\n });\n observer.observe(playground);\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n window.addEventListener(\"pointerdown\", onPointerMove, { passive: true });\n window.addEventListener(\"pointerup\", onPointerEnd);\n window.addEventListener(\"pointercancel\", onPointerEnd);\n document.documentElement.addEventListener(\"pointerleave\", land);\n window.addEventListener(\"blur\", land);\n return () => {\n observer.disconnect();\n window.removeEventListener(\"pointermove\", onPointerMove);\n window.removeEventListener(\"pointerdown\", onPointerMove);\n window.removeEventListener(\"pointerup\", onPointerEnd);\n window.removeEventListener(\"pointercancel\", onPointerEnd);\n document.documentElement.removeEventListener(\"pointerleave\", land);\n window.removeEventListener(\"blur\", land);\n cancelAnimationFrame(frame);\n smoke?.dispose();\n x.set(pos.x);\n y.set(0);\n cutJets();\n };\n }, [\n reduceMotion,\n disabled,\n x,\n y,\n bank,\n rotate,\n jetLeft,\n jetRight,\n jetBottom,\n ]);\n\n return (\n \n {/* Smoke fills the playground; its floor is the bottom edge. */}\n \n \n {NOZZLES.map((nozzle, i) => (\n \n ))}\n \n {children}\n \n \n
\n );\n },\n);\n\nThrusterButton.displayName = \"ThrusterButton\";\n\nexport { ThrusterButton };\nexport default ThrusterButton;\n" + } + ], + "dependencies": [ + "motion", + "clsx", + "tailwind-merge" + ] +} diff --git a/registry.components.json b/registry.components.json index 5fc00d67..f8d1fb97 100644 --- a/registry.components.json +++ b/registry.components.json @@ -17,6 +17,15 @@ "file": "components/evil-buttons/sticky.tsx", "dependencies": ["motion", "clsx", "tailwind-merge"] }, + { + "name": "thruster-button", + "exportName": "ThrusterButton", + "docSlug": "thruster-button", + "title": "ThrusterButton", + "description": "A magnetic button that rests on the floor of its container, then flies to the cursor anywhere inside it on compressed-air jets, trailing WebGL smoke, and lands where it is when the cursor leaves.", + "file": "components/evil-buttons/thruster-button.tsx", + "dependencies": ["motion", "clsx", "tailwind-merge"] + }, { "name": "shiny-button", "exportName": "ShinyButton",