Will You Be My Valentine
- Web products
- Web Toy
- 2026
- Open source
Astro, React, TypeScript, Tailwind CSS and 2 more
Will You Be My Valentine is a one-page meme. A cartoon bear asks the question, and the No button refuses to stay a button. Every refusal moves it, shrinks it, relabels it or swaps it for another control. After 26 refusals the No button disappears.
I built it in Next.js 16 in February and moved it to Astro in May, keeping the whole interaction as one React island. The joke is simple. Keeping a teleporting, draggable button inside the viewport and away from Yes took more care.
# one component, one counter, six No modescd src/componentsgrep -c "= useState" MorphButton.tsx58grep -o "setNoMode('[a-z]*')" MorphButton.tsx | grep -v buttonsetNoMode('split')setNoMode('fake')setNoMode('loading')setNoMode('slider')setNoMode('checkbox')setNoMode('draggable')
3 commands7 lines of output
- Chapter
- Web products
- Category
- Web Toy
- Year
- Availability
- Open source
- Last commit
- Astro
- React
- TypeScript
- Tailwind CSS
- Docker
- nginx
One counter drives it
src/pages/index.astro mounts <MorphButton client:load />, and everything else lives in src/components/MorphButton.tsx. One number, attempts, is the input. getStage maps it to five stages: intro below 2, playful below 6, persuasion below 10, chaos below 15, then boss. getMood picks one of five bear faces. The 27 entries of NOPE_COPY supply a subtitle per count. handleNoClick holds the transitions as plain newAttempts === N checks, so it is closer to a lookup table than a state machine. No splits at 7, fakes a disabled state at 10 and shows a loading bar at 11. It becomes a slider at 16, a checkbox at 19 and a draggable at 22. Yes grows 6 percent per refusal, up to 2.5 times its size.
Controls that misbehave
Every move goes through teleportNo. It samples a random spot, favouring edges in the late stages. It retries up to 30 times to stay out of a 150 px zone around Yes, then clamps 30 px inside the viewport. Hover dodging predicts the cursor from its velocity and moves away along that angle. The slider is a native <input type="range">. Past 70 it snaps back 10 steps every 50 ms, then counts the attempt. The checkbox unchecks itself after 100 ms. A released draggable gets inertia that decays by 0.95 per frame and bounces off the walls.
Decisions and costs
- Astro with one island instead of Next.js. The output is static, so the
Dockerfilebuildsdist/and serves it from nginx. The shell is only a document head and a mount point, so the gain is dropping the Next.js runtime, not my own JavaScript. - Honour
prefers-reduced-motionin two places. AmatchMedialistener turns off dodging, clones, the gravity flip, zoom and spotlight.globals.csscuts every animation to one 0.01 ms run. Teleporting on click stays, since without it there is no joke. - Keep every gag in one component. Jokes were cheap to add, at the cost of 58
useStatehooks and transitions spread across timeouts and effects. I would revisit this one first: a reducer keyed on the count could be tested.
Limits and status
There are no tests. Vitest and Testing Library are installed, but pnpm test runs with --passWithNoTests. Accessibility is deliberately poor for No, which teleports on keyboard focus after the second refusal. The viewport meta also blocks zoom, and the bear SVG has no accessible name. Yes stays a real button with a visible focus ring. It runs on Astro 7, and scripts/deploy.sh publishes the static build to Cloudflare Pages. Locally it is pnpm dev. It is MIT licensed.