useless-buttons
v0.3.0
Published
A collection of needlessly elaborate buttons — Web Components buttons with a Rust/wasm simulation (boids, falling sand, Conway's Life, Mandelbrot, bouncing pixels, a first-person maze crawl) painted on their canvas background.
Maintainers
Readme
useless-buttons
Buttons for bad UX. Give your users a bad experience.
A collection of needlessly elaborate buttons — buttons that do far more computation than any button should.
Each <useless-button> is a real, focusable, form-participating
<button> whose background is a live Rust/wasm simulation — a boid
swarm, falling sand, Conway's Game of Life, an endless Mandelbrot
zoom, a bouncing-pixel collision chamber, a first-person maze crawl, a
sky of rotating Van Gogh swirls, a Voronoi diagram over drifting
points, or a jump to lightspeed — rendered straight into the button's own canvas. Or it's a
tiny arcade game you have to beat before the button even shows its label. Ships as a
dependency-free Web Component, so it works in React, Vue, Svelte, or a
plain .html file identically.
- No Canvas2D calls from Rust. The Rust side only ever computes an
RGBA pixel buffer in wasm linear memory; JS reads it once per frame
through a raw pointer and hands it to
putImageData. This is what lets a swarm variant push several hundred independent objects at 60fps — once you have that many objects, per-object calls across the JS/wasm boundary (asweb-sysCanvas2D bindings require) become the bottleneck, not the drawing itself. - Zero bundler config. The compiled
.wasmis base64-inlined into the published JS at build time and loaded withinitSync. You don't need a.wasmloader,asset/wasm,vite-plugin-wasm, or anything else. - One shared render loop. However many
<useless-button>s are on the page, there is exactly onerequestAnimationFrameloop, paused when the tab is hidden and per-instance-excluded when off-screen.
Install
npm install useless-buttonsUsage
<script type="module">
import "useless-buttons";
</script>
<useless-button variant="swarm">Flee the swarm</useless-button>
<useless-button variant="sand">Pile up sand</useless-button>
<useless-button variant="life">Game of life</useless-button>
<useless-button variant="fractal">Fractal zoom</useless-button>
<useless-button variant="bounce">Pixel chaos</useless-button>
<useless-button variant="dungeon">Dungeon crawl</useless-button>
<useless-button variant="starry">Starry night</useless-button>
<useless-button variant="voronoi">Voronoi cells</useless-button>
<useless-button variant="hyperdrive">Punch it</useless-button>Importing the package auto-registers <useless-button>. It behaves like
a normal <button>: it's keyboard-focusable, activates on Enter
and Space, participates in forms, and fires a real click
event — because under the shadow DOM, it is a <button type="button">.
document.querySelector("useless-button").addEventListener("click", () => {
console.log("clicked, as buttons do");
});If you want a different tag name
import { defineUselessButton } from "useless-buttons";
defineUselessButton("my-button"); // registers an additional alias tagVariants
Variants come in two categories:
- Games run a tiny arcade game in the background and keep the button locked until you beat it.
- Visual effects are an animated background behind a label that's always there and always clickable.
An unrecognized or misspelled variant ("SWRAM ", "boidz", ...) never
throws or renders a broken button — it silently falls back to swarm.
Games
Game variants run a tiny arcade game in the button's background, and the button stays locked until you beat it:
- The game starts by itself and plays on its own. Left alone, it ends in a game over within a few seconds (a red flash) and restarts.
- Pressing the button with a mouse, pen or finger is the game's input;
the keyboard doesn't play games. Some games also care where you
press:
crossyandfrogread the left/right half,dodgeandshooterthe top/bottom half,survivorwalks to wherever you press, and several (django,balloon,memory,mole,numbers,rhythm,crawler) need you to click on the thing itself.heliis the one game you hold down rather than tap. While locked, the label is hidden and noclickevent reaches your page, not even from a programmaticel.click(). - Beat the game and the label fades in, a
game-clearevent fires, and from then on it's an ordinary button. It stays unlocked until the simulation is recreated (resetGame(), or a newvariant/seed).
<useless-button variant="flappy">You can fly!</useless-button>
<useless-button variant="runner" text-fx="slot">Stage clear</useless-button>
<useless-button variant="timing" text-fx="explode">Perfect timing</useless-button>
<useless-button variant="crossy" text-fx="ricochet">Why did the chicken…</useless-button>
<useless-button variant="django" text-fx="slot">Fastest gun in the West</useless-button>
<useless-button variant="balloon" text-fx="explode">Pop goes the button</useless-button>
<useless-button variant="memory" text-fx="snake">Total recall</useless-button>
<useless-button variant="dodge" text-fx="streak">Made it home</useless-button>
<useless-button variant="breakout" text-fx="explode">Level complete</useless-button>
<useless-button variant="invaders" text-fx="matrix">Earth is saved</useless-button>
<useless-button variant="pong" text-fx="ricochet">Game, set, match</useless-button>
<useless-button variant="mole" text-fx="slot">Whack attack</useless-button>
<useless-button variant="stack" text-fx="spin">Tower of power</useless-button>
<useless-button variant="heli" text-fx="streak">Clear skies</useless-button>
<useless-button variant="numbers" text-fx="slot">In order</useless-button>
<useless-button variant="rhythm" text-fx="supernova">On beat</useless-button>
<useless-button variant="survivor" text-fx="explode">Survived the night</useless-button>
<useless-button variant="frog" text-fx="ricochet">Hop to it</useless-button>
<useless-button variant="crawler" text-fx="corridor">Dungeon cleared</useless-button>
<useless-button variant="shooter" text-fx="streak">Mission complete</useless-button>| variant | Game | To clear |
|---|---|---|
| flappy (aliases flappy-bird, bird) | A Flappy Bird-style bird that only rises when you press. | Thread 10 pipe pairs and reach the checkered flag. Hitting a pipe or the ground is a game over. |
| runner (aliases jump, platformer, wonderboy) | A Wonder Boy-style side-scroller: the kid runs forward on their own. | Press to jump each of 10 chasms and reach the flag. Falling in is a game over. Three or four of the ledges between chasms are barely wider than a landing: jump too early before one and you sail past it into the next chasm, and once you're on it the next jump comes right away. |
| timing | A ball sweeps back and forth along a line that has a ring on it. | Press while the ball is inside the ring; the ring then jumps somewhere else. Land 10 hits. There's one chance per ring: pressing while the ball is outside it is a miss, and so is letting the ball sweep through it without a press. |
| crossy (aliases crossy-road, chicken) | A Crossy Road-style chicken that has to get from the start on the left to the finish on the right. Every road has cars running up or down it. | Hop across 10 roads. Pressing the right half of the button hops right, the left half hops left. There's a grass rest spot every 3–4 roads, and once you leave the start or reach a rest spot you can't go back past it. Getting hit is a game over, and so is standing still for 6 seconds: an eagle swoops in, with its shadow growing as a warning. |
| django (aliases western, quickdraw, shooting) | A quick-draw shooting gallery in a western town. Each wave, 3–5 targets pop up at random spots, with a fuse burning along the bottom. | Click every target before the fuse runs out. You get exactly one round per target, so a single miss ends the run. Clear 10 waves. |
| balloon (aliases balloons, pop) | Balloons drift down from the top, a little faster each time. | Click a balloon 3–5 times to pop it (dots on it show the hits left); each hit also bumps it back up. A balloon touching the ground is a game over. Pop all 10. |
| memory (aliases match, cards, concentration) | 6 or 8 numbered cards, in pairs, are dealt face up for a moment, then turned face down (identical backs). | Click cards to turn them over; every two in a row must be a pair. A mismatch, or running out of time, ends the run. Turn every pair up to win a round; 8-card deals show up from round 4. Win 10 rounds. |
| dodge (aliases traffic, highway) | A five-lane highway: your car is on the left and traffic closes in from the right. | Press the top half of the button to move up a lane, the bottom half to move down. A sign marks every kilometre; reach the finish at 10 km. Every row of traffic blocks the lane you're in, so sitting still crashes, but always leaves a free lane at most one lane away. |
| breakout (aliases arkanoid, bricks) | Breakout: a ball, a paddle, and two rows of five bricks. | Press anywhere and the paddle slides toward that spot (it doesn't jump there). Where the ball hits the paddle sets its rebound angle. Break all 10 bricks; missing the ball is a game over. |
| invaders (aliases space-invaders, space) | A Space Invaders-style formation of 10 marches side to side, dropping a row at each edge and speeding up as it thins out. | Press anywhere and your cannon slides under that spot, then fires; one shot in the air at a time. Invaders drop bombs, most of them aimed at you. Shoot all 10; getting bombed, or letting them land, is a game over. |
| pong (aliases tennis) | Pong against a slower computer paddle. Every serve comes at you. | Press at a height and your paddle slides there. Where the ball meets your paddle sets its angle, so edge hits send it steep enough to beat the computer. Score 10 points; letting one past you is a game over. |
| mole (aliases whack-a-mole, whack) | Whack-a-mole over two staggered rows of holes. | Click a mole while it's up. Whack 10 in a row: a single mole getting away is a game over. Moles stay up for less time as you go, and from the 5th whack two can be up at once. |
| stack (aliases tower, stacker) | A block slides back and forth above a tower; press to drop it. It falls under gravity and keeps all its overhang — nothing is trimmed. | After every landing each joint is checked: if the blocks above it have their center of mass past the edge of what holds them up, that part of the tower tips over the edge and falls, which is a game over (so is missing the tower). Stack 10. Wait 4 seconds and the block slips off the far end of its track on its own. |
| heli (aliases helicopter, cave) | The helicopter-in-a-cave game: a winding cave that narrows as you go. | Hold the button to climb, let go to sink. Touching a wall is a game over. Fly through 10 checkpoint gates. |
| numbers (aliases number-order, sequence) | Numbered circles scattered across the button, 4 per round growing to 8. | Press them in order, 1 first, before the time bar runs out. A wrong number, or running out of time, ends the run. Win 10 rounds. |
| rhythm (aliases rhythm-hero, osu, beat) | An osu!-style hit-circle game: each screen brings 3–5 numbered circles on a beat, which quickens as you go. | Press each circle as the ring shrinking onto it closes. Too early, too late, or not at all is a miss and ends the run. Clear 10 screens. |
| survivor (aliases survivors, vampire) | A Vampire Survivors-style arena: your hero fires a ring of shots in every direction on its own while bats swarm in from every edge, more and faster over time. | Press anywhere and the hero walks there. One touch from a bat is a game over. Collect 10 gems, which appear one at a time. Stand still for 8 seconds and the Reaper appears: it can't be shot and chases you until you move again. |
| frog (aliases frogger, lilypad) | A Frogger-style river crossing in the same left-to-right layout as crossy: lily pads drift up or down every river, with a grassy bank every 3–4 rivers. | Right half of the button hops right, left half hops left; you ride whatever pad you land on, and on a bank the frog wanders back to the middle. Landing in the water, or riding a pad off the edge, is a game over, and you can't go back past the start or a bank. Cross 10 rivers; stand still for 12 seconds and the heron gets you. |
| crawler (aliases dungeon-crawl, monster-hunt) | A first-person dungeon crawl on the dungeon raycaster: the camera explores the maze on its own until a monster steps into the corridor ahead. | Click a monster's head to drop it in one hit, or its body three times (pips over its head count down), before it reaches you. 10 monsters; the last is a bigger, horned one standing in front of the exit portal. |
| shooter (aliases shmup, gradius) | A side-scrolling shoot-'em-up: your fighter sits on the left and fires twin shots on its own. | Press the top half of the button to climb a step, the bottom half to dive one. Drones can be shot down; meteors can't, and come at whatever height you're flying. Touching either is a game over. Reach the finish line (the pips track the distance). |
A row of 10 pips along the top of each game tracks progress. Difficulty is
defined relative to the button's height, so it doesn't change with the
canvas's pixel size, but a bigger button is easier to read. Consider
enlarging game buttons with ::part(button):
useless-button[variant="flappy"]::part(button) { min-width: 260px; min-height: 72px; }const el = document.querySelector('useless-button[variant="timing"]');
el.locked; // true until the game is beaten
el.addEventListener("game-clear", () => {}); // fires once, on the win
el.addEventListener("click", () => {}); // only fires once unlocked
el.resetGame(); // start over, locked againVisual effects
Visual variants are purely ambient: they animate on their own and don't react to hovering, pressing or clicking.
| variant | What it does | Behavior |
|---|---|---|
| swarm (default, and the fallback for unknown/misspelled values) | A dense flock of boids (separation/alignment/cohesion) computed on a spatial grid | Each boid wanders on its own slowly-turning heading, so the flock keeps splitting and regrouping instead of locking onto one direction. |
| sand | A falling-sand cellular automaton, chunky 5-device-px grains | Rains in continuously on its own. Once every cell is filled, the complete pile stays visible for half a second, then resets and starts filling again in the next color. |
| life | Conway's Game of Life, 2 device px per cell, toroidal, advancing at a fixed ~36 generations/sec regardless of render rate | Whenever cells die off, new clustered seeds sprout elsewhere on the board — population stays lively instead of dwindling to almost nothing. |
| fractal | A Mandelbrot explorer, computed at half resolution and upscaled 2×2, f64 coordinates, adaptive iteration count | Zoom only ever increases, deepening at a slow, steady rate. Continuous boundary-detail probing nudges the view away from flat/boring (solid interior or empty space) patches, and relocates to a new hand-picked coordinate if one is crossed anyway — the fractal never dead-ends on a blank screen. |
| bounce | Dozens of pixels bouncing around a fully elastic collision chamber, changing both direction and a genuinely random full-spectrum color on every wall or pixel-pixel hit | The background is never cleared or faded: only the particles' current positions get painted, so every pass any pixel has ever taken through the canvas stays visible as a permanent trail. |
| dungeon | A first-person raycaster (DDA, Wolfenstein/DOOM-style) through a 47×35 procedurally-carved maze. Walls are brick, in running bond, and colored by which way they run as seen from above — north-south walls warm/red, east-west ones cool/blue — with each straight run taking its own hue from that family, so a corridor holds one color along its length and every corner is a visible break. Floor and ceiling are cast per pixel into square tiles, the ceiling's twice the size of the floor's, both fading out with distance | Walks center-to-center on autopilot forever, turning in place at each cell before moving on, picking a new open direction at every junction and reversing only at dead ends. The maze is deliberately far larger than what's visible so it roams instead of pacing the same few corridors. |
| starry | Starry Night, more or less: at least eight multi-armed spirals, each drawn as a chain of tapering strokes from a hot white core out to cool blue tips. They stay put and breathe — turning at their own rate, half of them the other way round, pulsing between roughly 0.6x and 1.4x their size — rather than drifting, since a swirl wandering off its spot reads as one star vanishing and another appearing. The sky between them streams: short brush strokes ride a slowly-churning flow field, each one belonging to a particular star and reborn around it, so the current comes out of the stars. Placement is best-candidate sampling at each swirl's peak size, because uniform random clumps and a clump of spirals reads as one blob | Half the swirls turn clockwise and half anticlockwise, each at its own rate. |
| voronoi | A Voronoi diagram over drifting sites, each cell filled with its own random color. Every pixel is simply colored by its nearest site, so the cell boundaries fall out of the coloring rather than being built as geometry — no edge list to keep consistent while the sites move. Sampled at half resolution, since the cost is sites × pixels | Sites bounce off the edges rather than wrapping, so no cell ever jumps across the canvas. |
| hyperdrive | The jump to lightspeed, on a loop. Several hundred stars in a 3D field under perspective projection, with the camera falling away from them, so growing depth shrinks x / z and each star rushes inward and is swallowed by the vanishing point. Each is drawn as the streak between where it was and where it is, and the smear grows with the drive as well as with its speed — so at the punch the lines stretch until they fill the frame, rather than merely moving faster. Runs as a cycle (near-still field, spool-up, punch, white flash), because standing at full speed forever loses the acceleration the shot is built on | Loops forever: idle, spool-up, punch, flash. |
| tunnel | A neon dimension tunnel with glowing rings, twisting rails, rainbow depth shading and a dark vanishing point. Rendered at half resolution with bounded work per pixel. | Travels and twists at a steady pace. |
| blackhole | A fast elliptical camera orbit dives toward an accretion disk, rolls around it and pulls away. | Camera zoom and roll ride the orbit. |
| chrome | Liquid metal reflects moving cyan and magenta light. | Gentle camera zoom and roll. |
| plasma | Branching electric filaments converge on a glowing core. | Gentle camera zoom and roll. |
| stained-glass | Shared junctions drift inside a fixed frame, reshaping the colored triangular panes with no whole-sheet rotation or zoom. | 48 persistent triangular shards. |
| aurora | Layered green and violet curtains ripple across the sky. | Gentle camera zoom and roll. |
| ripple | Caustic light dances across dark water. | Gentle camera zoom and roll. |
| hologram | Rotating wireframe slices float above a scan grid. | Gentle camera zoom and roll. |
| supernova | A boiling star pulses and rotates on a synthetic 144 BPM beat, with sharp zoom kicks and a fiery corona. | No audio input or playback. |
| matrix | Continuous green bitmap code rain with bright leading glyphs and fading tails. | Each column falls at its own speed with its own tail length. |
| fire (alias flames) | The classic demoscene pixel fire: a heat grid fed from the bottom row, where every cell takes the heat of the one below, loses a random bit and drifts a random step sideways. Heat maps through black, red, orange and yellow to white. | Every few seconds the base flares and the flames leap higher. |
| rain (alias rainy-window) | Rain on a window at night: blurry city lights glow behind the glass while droplets bead all over it. | Now and then a big drop runs down, wobbling, swallowing the droplets in its path (growing as it does) and leaving a few new beads behind. |
| fireworks | A fireworks show over a city skyline. The sky is darkened a little each frame instead of cleared, so everything leaves a glowing trail. | Rockets climb on a trail of sparks and burst into a sphere, a ring or a drooping willow, whose embers fall under gravity and drag, twinkle and fade. |
| snow (alias snowfall) | Snow falling at night in three depths — the nearer, the bigger, faster and brighter — in front of a row of distant pines. | Flakes sway on their own and on a slowly gusting wind; the near ones pile into a drift that slumps smooth and settles at a steady depth. |
| reaction (aliases reaction-diffusion, turing) | Gray-Scott reaction-diffusion: chemical U feeds in everywhere, V eats U to make more of itself and slowly decays, and from a few seeds that alone grows coral, spots and worm-like stripes (Turing patterns). | The feed rate drifts slowly so the pattern keeps changing character, and fresh seeds keep landing so it never dies out. |
| spirograph (alias spiro) | A toy Spirograph: a gear rolls inside a fixed ring while a pen in it traces a hypotrochoid. Wide buttons get several side by side. | When a curve closes, a new one starts with a different gear, pen hole and ink; finished curves slowly fade from the paper. |
| kaleidoscope | A row of kaleidoscope rosettes: each pixel's angle is folded into one mirrored wedge showing drifting glass chips, with dark lead lines where chips meet. | Neighboring rosettes turn opposite ways from different angles, and the colors cycle. |
| pendulum (aliases double-pendulum, chaos) | Six double pendulums on one pivot, released from angles a thousandth of a radian apart, each dragging a colored trail. | They swing as one for a few seconds, then fly apart; every 20 seconds they're reset to a new angle. |
| lissajous (aliases oscilloscope, scope) | An oscilloscope in X-Y mode: a green phosphor Lissajous figure over a graticule. | The phase drifts so the figure seems to turn, and every few seconds the frequency ratio eases to the next (1:2, 3:2, 3:4, ...). |
| synthwave (aliases retrowave, outrun) | An 80s retro sunset: a striped sun sinking behind neon-rimmed mountains, twinkling stars in a violet sky, and a glowing perspective grid. | The grid rushes toward you and the sun's stripes drift down. |
| crt (aliases tv, no-signal) | A CRT television on the blink: color bars with scanlines, a slow rolling bar, flicker and darkened corners; red is read a little to the side so the colors bleed apart. | The picture jitters and tears sideways at random, and every few seconds the signal drops to snowy static. |
| pipes (alias 3d-pipes) | The old 3D Pipes screensaver: pipes shaded across their width like lit cylinders, with ball joints at every bend. | Pipes grow cell by cell and turn at random; a boxed-in pipe stops and a new one starts elsewhere in a new color; when the screen is mostly full it's wiped and starts over. |
| radar | A radar scope with range rings and a crosshair; on wide buttons, level meters and a scrolling signal trace flank it. | The sweep turns with a fading afterglow, and drifting contacts light up only as it passes over them. |
| metaball (aliases metaballs, blobs) | Glossy 3D metaballs. Unlike lava's flat wax, each pixel gets a surface normal (the field becomes a height that's exactly a hemisphere for a lone ball), lit with diffuse light, a specular highlight, a Fresnel rim and a sky reflection, over a floor with soft shadows. | Candy-colored balls drift in depth (nearer ones look bigger) and melt into each other where they meet. |
| constellation (aliases network, plexus) | The classic particle network: bright points drifting across a dark sky. | Any two points closer than a set distance are joined by a line that fades as they separate and vanishes when they drift too far apart, so constellations keep forming and breaking up. |
| ascii (aliases ascii-art, donut) | ASCII art in the spirit of donut.c: spinning 3D tori projected onto a grid of character cells, each shaded with one of .,-~:;=!*#$@ from a built-in bitmap font, in terminal green. | Wide buttons get several donuts, each spinning on its own axes and speed. |
| lava (alias lava-lamp) | A lava lamp: a metaball field of tall wax blobs, shaded from hot yellow at the core to magenta at the rim, with a soft glow. | Blobs warm at the bottom, float up, cool at the top and sink, merging and pinching apart as they pass. |
Cinematic effects
Background and text effects can be combined independently:
<useless-button variant="blackhole" text-fx="blackhole" style="--ub-accent: #f2faff">Beyond the horizon</useless-button>
<useless-button variant="chrome" text-fx="chrome">Liquid metal</useless-button>
<useless-button variant="plasma" text-fx="hologram">High voltage</useless-button>The eight material backgrounds target 60 fps, with faster shading animation and
camera zoom/roll on the shaded effects and moving junctions inside a fixed frame
for stained glass. Supernova uses a synthetic 144 BPM visual beat
(no audio input or playback). Procedural shading uses adaptive
pixel blocks; stained glass uses 48 persistent triangular shards. Text effects use the existing
shared scheduler and accessible-label handling, and stop with reduced motion.
Use light label colors on these predominantly dark backgrounds. Each demo card pairs a background with its own text effect and displays the
text-fx value below the button. The menu can override all effects or restore
the original pairings.
The cinematic material effects use dedicated palettes (warm accretion light,
chrome reflections, violet plasma, and cyan holograms) and dark backgrounds.
They do not use --ub-paper as their background color.
Colors
The original simulations paint their moving/living/procedural elements with
random full-spectrum colors (HSV hue drawn from Rng, not a
gradient between two fixed theme colors) — each boid, sand-pile color
band, live cell, fractal iteration band, bouncing pixel, and dungeon wall
gets its own hue. --ub-paper is still the background and --ub-ink is
still used for a couple of neutral/structural details (e.g. the
Mandelbrot's interior, the dungeon's floor), but --ub-accent is no
longer read by any simulation — it's applied purely as the button
label's own text color instead (see below).
Without the element
The simulations and the label animations are also available as plain functions,
for rendering offline (a video export, a screenshot, a test) where you own the
clock and there is no <useless-button> in the page. Neither one registers
observers, media-query listeners or animation-frame callbacks.
import { createBackground, TextFxSampler } from "useless-buttons";
// A visual variant's pixels at any time you choose. Games are rejected.
const bg = createBackground({
variant: "aurora", width: 1280, height: 720, seed: 30,
paper: 0xf5f3ec, ink: 0x1a1a1a, accent: 0xf2faff, // 0xRRGGBB, optional
});
for (let i = 0; i < 90; i++) bg.tick(1 / 30);
const png = bg.toDataURL(); // or bg.imageData() for a copy of the pixels
bg.free(); // release the native simulation
// A label animation at any time, painted onto objects that carry a `style`.
const sampler = new TextFxSampler();
const block = { style: { transform: "", textShadow: "" } };
const chars = [..."Hi"].map((ch, index) => ({
el: { style: { transform: "", textShadow: "", color: "", opacity: "", fontFamily: "" },
dataset: {}, textContent: ch },
index, stagger: index * 0.02,
}));
// `chars.length` is the word length that centered/mirrored modes use.
sampler.sample("zoom", 1.25, block, chars);TextFxSampler.sample(mode, seconds, block, chars) is a pure function of
seconds for every mode except spin, which integrates a randomized speed
curve; it is replayed in fixed 1/30 s steps, so its result still does not depend
on which times were asked before. For frame-by-frame playback use TextFxEngine
(reset, update(mode, dt, block, chars)), which is what the element runs.
Attributes / properties
| Attribute | Property | Default | Notes |
|---|---|---|---|
| variant | .variant | "swarm" | Case/whitespace-tolerant. Changing it at runtime tears down and recreates the simulation. |
| seed | .seed | 1 | Unsigned 32-bit PRNG seed. Same seed + same size ⇒ identical animation. Changing it recreates the simulation. |
| fps | .fps | the variant's preferred_fps (currently 60 for every variant) | Caps how often the simulation is stepped, independent of the shared render loop's own rate. |
| disabled | .disabled | absent | Reflects onto the real, inner <button disabled> — native disabled semantics apply (no clicks, no focus, no form submission). |
| text-fx | .textFx | "spin" | Decorative label animation — see below. Always on by default; pass text-fx="none" to opt out. |
All five are observed attributes; changing them at runtime through
setAttribute/the JS property takes effect immediately.
| Property / method / event | Notes |
|---|---|
| .locked (read-only) | true while a game variant hasn't been beaten yet. Always false for visual variants. |
| .resetGame() | Recreates the simulation, so a game starts over and is locked again. |
| game-clear event | Fired once (bubbles, composed) when a game is beaten and the button unlocks. |
text-fx
A decorative animation applied to the button's own label text (not the
canvas) — on by default (this is useless-buttons; a static label
was never really the point). Runs on the same shared render loop as
everything else, and — like the canvas — is disabled entirely under
prefers-reduced-motion: reduce, freezing on a neutral, fully-readable
pose rather than partway through a transform. All of them are tuned to
be genuinely excessive — the button no longer clips its own label
(overflow: visible; the canvas still clips itself to the rounded
corners), so skew and explode are free to actually leave the
button's box rather than stay politely contained inside it.
| Value | Effect |
|---|---|
| "spin" (default) | Rotates continuously around its own center, scaling up and down in sync. Both the angular speed and the scale trace the same sine wave — ranging from a slow, small, readable crawl up to several rotations per second at a visibly larger size — fast enough to blur into an unreadable smear at its peak, then ease back down. How long each cycle takes to reach peak speed, and how large the peak scale is, are both re-rolled every cycle, so consecutive spins vary instead of repeating an identical ramp forever. |
| "skew" | A large, fast skew() wobble (up to ~80°) built from layered sine waves at non-integer-ratio frequencies, riding along with a proportional offset so the label actually travels off-center — the ends visibly swing outside the button's own box at the extremes, not just shear in place inside it. |
| "explode" | Splits the label into individual characters that burst outward (fast ease-out), hold scattered for a beat — flung well past the button's edges, tumbling and briefly scaled up — then snap back together (fast ease-in) and rest assembled before the next burst. Each character's direction, distance, rotation and peak scale are re-rolled every cycle, so the same word doesn't burst the same way twice in a row. The original text is fully hidden for as long as text-fx="explode" is set (see Accessibility below) — what's on screen is only ever the animated characters, never both at once. |
| "snake" | Splits the label into characters that slither along a travelling sine wave. Each character samples the same curve at its own phase offset, so the crest moves along the word rather than every letter bobbing in unison, and each is rotated to the curve's local tangent — that tangent is what makes the row read as one body following a path instead of letters bouncing independently. A slower sweep carries the whole body left and right on top of that, so the snake travels well outside the button's own box on every side rather than wriggling on the spot in the middle of it. |
| "blackhole" | Letters orbit, rotate and pulse continuously. |
| "chrome" | Metallic letters melt and stretch under moving highlights. |
| "plasma" | Electric jitter and cyan-violet arcs outline each letter. |
| "stained-glass" | Colored letters shift hue and rock gently, like glass catching the light. |
| "aurora" | Light trails rise from letters floating along a luminous curtain. |
| "ripple" | Letters refract and leave watery double images. |
| "hologram" | Translucent letter slices shift with cyan-magenta color separation. |
| "supernova" | Letters pulse and rotate at 144 BPM. |
| "zoom" | A travelling magnification wave expands and rotates letters in sequence. |
| "ricochet" | Letters bounce, squash at impact and rotate with offset rhythms. |
| "corridor" | Letters move through perspective depth and turn like corridor panels. |
| "streak" | Letters stretch horizontally, trailing six cyan light echoes. |
| "matrix" | Readable letters cascade vertically with bright green heads and fading afterimages. |
| "fire" | Letters flicker and stretch upward in flame colors, with orange-to-red glow rising off them. |
| "rain" | Pale, wet-looking letters each grow a drip beneath them that falls and starts over. |
| "fireworks" | Letters pop one after another, scaling up in a burst of glow, in a fresh color every time. |
| "snow" | Icy letters wear a cap of snow, sway gently, and now and then shiver. |
| "lava" | Gooey letters squash and stretch in warm colors as they slowly rise and sink. |
| "reaction" | Letters swell and pinch like dividing cells, each on its own rhythm, shifting between teal and violet. |
| "spirograph" | Each letter rides an epicycle (a small circle carried on a bigger one) in cycling pen colors. |
| "kaleidoscope" | Mirror-symmetric: letters either side of the middle turn opposite ways as their colors cycle. |
| "pendulum" | Letters swing from their tops like weights, on two mixed frequencies. |
| "lissajous" | Flickering green phosphor letters, each tracing a tiny 3:2 Lissajous figure. |
| "synthwave" | Slanted neon lettering, pink to cyan across the word, with a pink glow and a cyan drop shadow that pulse to a beat. |
| "crt" | Colors split red and cyan; now and then a letter glitches sideways, and the whole label flickers. |
| "pipes" | Chunky letters extruded in 3D, each popping in turn. |
| "radar" | Letters light up green as a sweep passes over them, then fade. |
| "metaball" | Glossy candy letters with a highlight on top that swell and settle like blobs. |
| "constellation" | Letters twinkle like stars and drift slowly, each on its own path. |
| "ascii" | Letters scramble through random ASCII characters, decode left to right in a monospace font, hold, and scramble again. |
| "tunnel" | Eighteen colored depth echoes behind the real label, with a gently changing perspective tilt. Works independently of the background variant and respects reduced motion. |
| "slot" | Each character becomes a slot-machine reel spinning about its X axis. Reels decelerate (ease-out) through a whole number of turns, so they always come to rest face-on rather than stopped edge-on and invisible, and they stop left to right, hold the result for a beat, then spin up again. Turn count is re-rolled per character per cycle so the reels never fall into lockstep, and faces darken as they turn away, the way a physical drum would. |
| "none" | Opt out: static label, no animation. |
<useless-button variant="swarm">Spinning</useless-button> <!-- text-fx defaults to "spin" -->
<useless-button variant="sand" text-fx="skew">Wobbling</useless-button>
<useless-button variant="life" text-fx="explode">Boom!</useless-button>
<useless-button variant="starry" text-fx="snake">Slithering</useless-button>
<useless-button variant="voronoi" text-fx="slot">Jackpot</useless-button>
<useless-button variant="tunnel" text-fx="tunnel" style="--ub-accent: #f2faff">Enter the portal</useless-button>
<useless-button variant="fractal" text-fx="none">Perfectly still</useless-button>The per-character modes (explode, snake, slot, and the material/code effects
blackhole, chrome, plasma, stained-glass, aurora, ripple, hologram,
supernova, matrix, zoom, ricochet, corridor, and streak) hide the real
slotted text (visibility: hidden on its wrapper — not display: none, which would collapse the label's
layout box and break centering) for as long as one of them is active —
the animated characters are aria-hidden, since they're a visual stand-in, not a second copy of
the label — so the button's accessible name is pinned explicitly via
aria-label on the inner <button> instead, taken from the same text.
Switching to a non-per-character mode removes that override and restores the
slot, so the real text drives the accessible name again as normal.
CSS custom properties
Three colors, read from the host's computed style:
| Property | Meaning | Default (light) | Default (dark, via prefers-color-scheme) |
|---|---|---|---|
| --ub-paper | Simulation background | #f5f3ec | #1c1c1c |
| --ub-ink | Simulation's neutral/structural color (interior fractal points, the dungeon floor, ...) | #1a1a1a | #f2f0e9 |
| --ub-accent | The button label's own text color (color on the inner <button>) — not used by any simulation, which instead paint with random full-spectrum colors (see Colors above) | #e0503d | #ff7a5c |
Any valid CSS color is accepted — keywords, hex, hsl(), oklch(), a
chain of var() — it's resolved by assigning it to an offscreen
element's color and reading back the browser's own normalized
rgb(r, g, b), not parsed by hand.
useless-button {
--ub-paper: #0d1117;
--ub-ink: #c9d1d9;
--ub-accent: #58a6ff;
}Colors are re-read every render tick, so they animate/transition live — flip a theme toggle, drag a color picker, whatever.
part selectors
Shadow parts exposed for external styling:
| Part | Element |
|---|---|
| button | The inner <button> |
| canvas | The simulation canvas |
| label | The <span> wrapping the slotted label text |
useless-button::part(button) {
border-radius: 999px;
}
useless-button::part(label) {
text-transform: uppercase;
letter-spacing: 0.04em;
}React
import "useless-buttons";
function App() {
return (
<useless-button variant="swarm" onClick={() => console.log("clicked")}>
Flee the swarm
</useless-button>
);
}React ≥19 passes unrecognized lowercase tags through as custom elements with their attributes set directly, which is all this component needs. If you're on an older React or use TypeScript with the DOM types, you may want a small JSX intrinsic-element declaration:
declare global {
namespace JSX {
interface IntrinsicElements {
"useless-button": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
variant?: string;
seed?: number;
fps?: number;
"text-fx"?: "none" | "spin" | "skew" | "explode" | "snake" | "slot" | "tunnel" | "blackhole" | "chrome" | "plasma" | "stained-glass" | "aurora" | "ripple" | "hologram" | "supernova" | "matrix" | "zoom" | "ricochet" | "corridor" | "streak" | "fire" | "rain" | "fireworks" | "snow" | "lava" | "reaction" | "spirograph" | "kaleidoscope" | "pendulum" | "lissajous" | "synthwave" | "crt" | "pipes" | "radar" | "metaball" | "constellation" | "ascii";
},
HTMLElement
>;
}
}
}Vue
<script setup>
import "useless-buttons";
</script>
<template>
<useless-button variant="fractal" @click="onClick">Fractal zoom</useless-button>
</template>Tell Vue's compiler to treat it as a custom element rather than a
component (e.g. in vite.config.js):
export default {
vue: {
template: {
compilerOptions: {
isCustomElement: (tag) => tag === "useless-button",
},
},
},
};Accessibility
- The canvas is
aria-hidden="true"— it's decorative, and the button's accessible name normally comes entirely from its real, visible slotted text (text-fx="spin"/"skew"/"none"never touch it). - The interactive surface is a genuine
<button type="button">, so focus, keyboard activation, and screen reader button semantics all work without any ARIA patching. prefers-reduced-motion: reducedisables the animation loop entirely. A single static frame is rendered and nothing animates on its own. This also disablestext-fx— the label freezes on a neutral, fully-readable pose rather than mid-rotation/skew/scatter (andtext-fx="spin"'s default is exactly that: motion, so reduced-motion users always get a plain static label regardless of the default).text-fx="explode"is the one exception to "the label is always the real text": while it's active, the real text is hidden (display: noneon the<slot>) and only the animated,aria-hiddencharacter spans are shown, so the button's accessible name is pinned explicitly viaaria-label(taken from that same text) instead. Switching away from"explode"removes the override and restores the slot.- A locked game hides its label, so the button's accessible name
is pinned to
"<label> (locked: clear the game to unlock)"until it's beaten. Underprefers-reduced-motion: reducea game can't be played (nothing animates), so game variants start unlocked instead. - Games are played with a pointer only. A locked game button still takes keyboard focus, but Enter/Space do nothing until it's been beaten with a mouse, pen or finger — keyboard-only users can't unlock one, so don't put anything essential behind a game variant.
- Off-screen buttons (
IntersectionObserver) and buttons in a hidden tab (document.visibilitychange) are excluded from the shared render loop entirely, not just throttled.
Building from source
Requires Rust with the wasm32-unknown-unknown target, wasm-pack, and
Node 18+:
rustup target add wasm32-unknown-unknown
cargo install wasm-pack # or the official install scriptgit clone <this repo>
cd useless-buttons
npm install
npm run build # wasm-pack -> base64-inline -> esbuild -> tsc --declarationOther useful scripts:
npm test # cargo test — the simulation logic, verified natively
npm run preview # cargo run --release --example preview
# renders preview/{swarm,sand,life,fractal,bounce,
# dungeon,starry,
# voronoi,hyperdrive}.gif
# so you can eyeball a variant without a browserThen open demo/index.html behind any static file server (it imports
../dist/index.js, so it needs npm run build to have run first):
npx serve .
# or: python3 -m http.serverDeploy the demo
Build a standalone static site locally (requires the same Rust/wasm-pack toolchain as the package build):
npm run build:demo
python3 -m http.server 8080 --directory site
# Open http://localhost:8080The generated site/ directory contains the demo at / and the bundled
library at /dist/index.js, including its WASM. It does not depend on an
npm release. Re-run the build before each deployment to include changes.
Vercel: deploy the generated directory, using the included static-site configuration to skip cloud builds:
npx vercel login
npx vercel site --prodCloudflare Pages: create a Direct Upload project in Workers & Pages
and upload the site/ folder, or use the CLI:
npx wrangler login
npx wrangler pages project create useless-buttons-demo --production-branch main
npx wrangler pages deploy site --project-name useless-buttons-demo --branch mainProject creation is only needed once; choose another project name if needed. These commands deploy local build output.
Automatic Vercel previews for pull requests
Use Vercel's native GitHub integration. GitHub Actions and deployment secrets
are not required: Vercel checks out the source, installs Rust/wasm-pack, runs
the tests, builds the demo, and publishes site/.
One-time setup:
Commit and push
vercel.json,scripts/, and the rest of the source to GitHub.In Vercel, choose Add New → Project, connect GitHub, and import the repository. For an existing project, connect it under Settings → Git.
Keep Root Directory at the repository root (
.), choose Other as the framework preset, and use Node.js 22.x. Remove any old dashboard build overrides; the checked-invercel.jsonsupplies these settings:| Setting | Value | | --- | --- | | Install Command |
npm ci| | Build Command |bash scripts/vercel-build.sh| | Output Directory |site|Deploy the project, then push a feature branch and open a PR. Vercel adds the Preview deployment status and URL to the PR and rebuilds after new pushes.
The first build includes installation of Rust and wasm-pack, which can take
several minutes. The local site/ directory stays gitignored; Vercel generates
it from source, including the WASM bundle, on every build.
By default, Vercel also builds branch pushes before a PR is opened. Pushes to
the configured Production Branch (usually main), including PR merges, deploy
to production. External fork contributions may require approval in Vercel.
See Vercel's GitHub integration documentation.
Releasing
Releases are automatic: every merge to main runs
.github/workflows/release.yml, which
picks the version bump from the Conventional Commit
subjects since the last v* tag (for squash merges, that's the PR title):
| Commit subject | Bump |
|---|---|
| feat!: ..., fix!: ..., or a BREAKING CHANGE: footer | major |
| feat: ... | minor |
| fix: ..., perf: ..., refactor: ..., revert: ... | patch |
| anything else (docs:, chore:, ci:, test:, ...) | no release |
It then tests and builds, pushes a chore(release): vX.Y.Z commit and a
vX.Y.Z tag to main, publishes to npm, and creates a GitHub Release
with generated notes. Don't bump version in package.json by hand.
Re-running a failed run is safe: it publishes whatever version
package.json holds if npm doesn't have it yet.
Publishing uses npm Trusted Publishing
(OIDC, no token), which has to be set up once on npmjs.com: package
Settings → Trusted Publisher → GitHub Actions, with owner
Pyongpyong, repository useless-buttons and workflow release.yml.
If main is protected, the workflow's bot needs to be allowed to push to it.
scripts/release-bump.mjs can be run locally to see what the next
release would be: node scripts/release-bump.mjs.
Why cargo test and not wasm-bindgen-test
The #[wasm_bindgen] layer in src/lib.rs is deliberately not gated
behind #[cfg(target_arch = "wasm32")]. wasm-bindgen's macros compile
fine on the host target too, so cargo test alone exercises the exact
code that ends up in the browser — same pixel math, same PRNG, same
clamping. If it's correct natively, it's correct in wasm.
Project layout
Cargo.toml, src/ Rust: sims + software rasterizer (no Canvas2D)
src/sims/games/ Rust: the game variants (flappy, runner, timing, crossy, ...)
src-ts/ TypeScript: web component, scheduler, wasm glue
scripts/inline-wasm.mjs base64-inlines pkg/*.wasm into a TS constant
scripts/release-bump.mjs picks the next release's semver bump from commit subjects
.github/workflows/ release.yml: version, publish to npm, GitHub Release
examples/preview.rs renders preview/*.gif from the native lib
demo/index.html manual browser smoke test + theme controls
demo/example.html example page: a lazy product page gated behind a game buttonLicense
MIT
