Quinton Nistico

Back to portfolio
Live Product

verve

A browser motion-design tool for people who ship interfaces. Paste a real component, animate it by dragging rather than by guessing numbers, and leave with production Motion, GSAP or CSS that you own outright.

Role
Sole designer — product, interface and shipping, end to end
Launched
September 2026 — live, Stripe billing in production
Scope
29 pages · 6 API routes · a ~20,000-line animation engine · 1,859 unit tests
Stack
Next.js 16 · React 19 · TypeScript · Tailwind v4 · Zustand · Supabase · Stripe
Solving motion — pure TypeScript, no framework, no dependencies
Springs
Closed-form solver — samples at any t, so the scrubber can run a spring backwards
Moving targets
A second, integrated spring for pointer-follow, tested against the closed form
Easing
Cubic bezier and the named presets
Compile
A document compiles once into per-element time → values functions; playback samples them per frame
Motion check
One rule catalogue shared by the editor panel, the docs, and the CLI
Emitting code — three dialects off one model
Motion
Variants and transitions, idiomatic to the library
GSAP
Timelines, with ScrollTrigger when the animation is scroll-linked
CSS
Keyframes, with the spring sampled into steps because CSS has no spring
Modes
Time, scroll-linked, and view transitions — each crossed with every dialect
Formatting
Prettier standalone, so an export reads like a person wrote it
Running someone else's component — the sandbox
Transpile
Sucrase, JSX and TS, against a small hyperscript runtime — React never ships into the frame
Isolation
sandbox="allow-scripts" with no allow-same-origin: an opaque origin, no cookies, no storage, no network
Protocol
Versioned postMessage both ways; the parent validates shape and source and trusts nothing
Identity
A DOM path plus a content fingerprint, so an animated node survives the user editing their source
Images
Data URIs in IndexedDB, never the server — a database constraint rejects any that try to get in

What this page is

Design engineers ship motion by guessing numbers and reloading — edit stiffness: 300, save, wait, squint, repeat, fifteen times, for something whose only real test is whether it feels right. They're typing numbers to describe a feeling.

verve is a tool for doing that by hand instead. But the interesting part was never the canvas — it was deciding what you walk away holding, and letting that one answer set the pricing, the architecture and half the interface.

Eight decisions below, in three groups. Half of them are interface calls, and three of those started as something going visibly wrong in front of me. Each one is a place where the obvious answer was available and I didn't take it.

Eight decisions

Each one in the same shape: the fork, what I turned down, why, and what it cost.

What the product is

Two decisions that set everything downstream — one about what verve refuses to build, one about what it refuses to charge for.

01

The exit door decided the product

The fork

Every motion tool is judged on its canvas. I picked the thing on the other side instead — what you walk away holding — and let that set everything upstream.

What I rejected
  • A Lottie exporter. The most requested format in this space, and a second exit door worse than the one I sell.
  • A hosted player, or a video render. Same problem: both quietly say the promise is negotiable.
  • Being a better canvas. That race is already crowded and the winner still hands you a file you can't ship.
Why

Jitter hands you a video, which can't be a working UI. Rive hands you a proprietary format plus their runtime in your app forever. Lottie hands you a JSON blob and a player library. A Figma prototype is a demo somebody rebuilds by hand in code. Four tools, four dead ends.

So the rule is that anything authored in verve has to be expressible in exported code. That single test kills whole categories of feature before they're costed — if the canvas can do something the export can't, the canvas is lying.

It also fixed the pricing, the architecture and the marketing in one move. Lottie and Rive export free too; what they take is a dependency. Ours is zero, so “it’s yours outright” is a claim nobody else in the category can make.

The cost I accepted

No video export, which people will ask for and which would be genuinely useful to them. I turned it down because a second exit door is how a product stops meaning anything — but I'm turning down real demand, not imaginary demand, and I'd rather say that plainly than pretend nobody wants it.

02

Charge for saving, not for leaving

The fork

The obvious paywall in a code-export tool is the export. It's the moment of value, it's trivially gateable, and every competitor gates something there.

What I rejected
  • Gating export. It's the promise — charging for it makes the pitch a bait.
  • Gating the good dialects. Motion free, GSAP paid, that sort of thing. Same bait, more granular.
  • Requiring an account to try it. The tool works with no sign-in at all, and asking first is how you lose the person who wanted to test one component.
Why

Export is free, unlimited, and needs no account. You can arrive, paste a component, animate it, take the code and leave, having given me nothing — and that's the intended path, not a loophole.

The cap is on saved animations: five on free, unlimited on Pro at $12/month. That charges for the thing that actually costs me something to keep, and for the person who has made verve part of how they work.

The hard rule sits under all of it: free or paid, once you have it, it's yours outright. No runtime, no attribution, no phone-home, nothing that stops working if you stop paying.

The cost I accepted

It leaves money on the table from exactly the people getting the most immediate value — someone can take a hundred exports and never pay. I took that because the alternative contradicts the sentence the whole product is built on, and a promise with an asterisk isn't one.

How the interface works

Four decisions from building and testing the editor. Three of them started as something going wrong in front of me.

03

If it's invisible it shouldn't be clickable

The fork

Open a saved animation and the canvas was empty. Almost every entrance starts at opacity 0 and the playhead opens at zero, so the editor loaded showing nothing — a stage full of elements that were invisible and still fully selectable and draggable.

What I rejected
  • Playing the document on open. Built it, killed it within the hour. An editor is not a player — Figma doesn't run your prototype when you open the file, and opening a file means “I want to work on this”.
  • Splitting every element into a box plus a paint layer, so an invisible element still drew its selection ring. It worked, and it was the wrong shape: it restructured every element on the stage to fix a first impression. Reverted whole.
Why

The rule that came out of it is the whole fix: if it's invisible it shouldn't be clickable, and if it's clickable it shouldn't be invisible. Everything else was an attempt to have it both ways.

So there's a third state. The canvas shows the component; the timeline shows the animation; the two meet when you ask. The first thing anyone sees on opening a document is their actual component, rendering the way it does in their codebase.

Touching the timeline is what asks — playing, seeking, or any edit that changes a track. Deliberately not every edit: dragging an element around the stage changes its position, and posing the document mid-drag would make the card you're holding vanish into its own opacity-0 keyframe.

The cost I accepted

Scrub to a moment where an element is invisible and it's invisible and clickable again. That one I left, knowingly — at that point it's the animation rather than a broken editor, because you put the playhead there.

04

Nobody could find how to add a keyframe

The fork

The editor had three ways to create a keyframe: the diamond button at the playhead, double-clicking a track row, and pose-then-key. Watching an expert user work, all three were invisible. Not awkward — invisible. This is the central action of the entire product.

What I rejected
  • Adding a fourth path. Three had already failed; a fourth would have been a guess wearing the clothes of a fix.
  • Documenting it. A tooltip explaining where a control lives is an admission that the control isn't discoverable — you've written down the failure rather than removed it.
Why

The standard I set afterwards is that creation should feel inevitable, not documented. If the way in has to be taught, the way in is wrong.

The same session found two more of the same shape, which is what made it a pattern rather than one miss. A new keyframe copies the current value — correct, because it stops the element jumping — but that means a second keyframe produces a flat segment and nothing appears to animate until you change a value. And a track with one keyframe is a constant, which is standard DAW semantics and still reads as “my animation is broken”.

All three are the same failure: the system was right and said nothing. Correct behaviour that reads as breakage is a design bug, not a user error.

The cost I accepted

What shipped first were stopgaps, and I logged them as stopgaps rather than calling it fixed — a ghost diamond on row hover, a row tooltip, an add-at-playhead button inside the inspector, hint copy. They work. Hint copy in a UI is still a debt, and it's written down as one.

05

Signing in threw away the work you signed in to save

The fork

The whole editor works signed out, and that's the product promise rather than a fallback. So persistence had to arrive without becoming a wall — and the first version of it was a trap: animate something, click Sign in to save it, and come back to an empty demo. Sign-in ends in a full page load, and the document only lived in memory.

What I rejected
  • Gating the editor behind an account. Solves persistence by destroying the promise.
  • Asking on return whether you'd like your work back. A dialog with one sensible answer isn't a choice, it's a toll.
  • An icon rail down the side for navigation. The library was exactly one destination, and one destination is a link, not a rail. It becomes a rail when there's a third place.
Why

Persistence lives entirely in the chrome — one row above the layers list, reading “Sign in to save” when signed out and blocking nothing. The save control has no lasting on-state: it says Save while there's work to store, ticks, and goes quiet.

Unsaved work is kept in the browser as a draft and comes back with a note on the stage. It restores without asking, and Discard is one click away on the note and in the command palette.

Three rules stop the draft becoming its own problem. What the address asks for wins — clicking a template opens that template, so a draft never ambushes a deliberate choice. A draft never overwrites a newer save, so work from another tab or device isn't clobbered. And a draft made while signed in only comes back for that account, because one slot for unsaved work would otherwise hand someone's pasted source to the next person on a shared machine.

The cost I accepted

Everything above is one slot, and account-scoping means recovery isn't universal — a signed-in draft won't return for a different account on the same machine. Correct, and still a case where the safe answer is the less helpful one. Found on the way: AI assist was opening its result as already-saved, so Save was disabled on work that existed nowhere and a reload lost a request the user had paid for.

06

A mode you can only enter once is not a mode

The fork

Picking an element inside the pasted component put you in pick mode, and picking one ended it. Staggering three cards meant arming the mode, picking, re-arming, picking, re-arming, picking.

What I rejected
  • Leaving it. Nothing was broken, which is exactly why it survived as long as it did.
  • A shortcut to re-arm faster. That optimises the wrong thing — it makes a bad model quicker to operate.
Why

If you have to re-enter a mode for every single use, it isn't a mode. It's a one-shot command wearing a mode's clothing, and the interface is lying about what it is.

Pick mode is sticky now, and Escape, the banner, or the rail toggle all leave it. Modes should be cheap to stay in and obvious to leave.

Double-clicking a component on the stage drills in as well — Figma's group-drill idiom — so the feature has an entry people already know rather than only a small icon they have to find.

The cost I accepted

Making it sticky created a trap that only existed because of how the sandbox works. Clicking an element moves focus into the iframe, key events don't cross an opaque origin, and the editor's Escape handler never fired — so the mode you could now stay in had no keyboard way out. The frame forwards Escape as a cancel-pick message, and the general rule went in the notes: any state reachable by clicking inside the sandbox needs its keyboard exit forwarded from the frame.

What had to be built

Two engineering bets. Both are expensive, and both follow directly from the exit door.

07

The engine had to be written, not installed

The fork

Motion and GSAP are excellent and free. Building a spring solver instead is the kind of decision that's hard to justify and easy to regret.

What I rejected
  • Driving the preview with Motion. The fastest route to a working editor by a wide margin.
  • Driving it with GSAP. Same shape, same problem.
Why

Playback libraries can't scrub a spring backwards or sample it at an arbitrary t. A timeline scrubber and draggable spring handles need both — the core interaction was the thing the libraries couldn't do.

And a tool that exports three dialects can't be married to one of them. The dialects have to be codegen targets on top of an internal model, or the product's output is shaped by whichever library happens to be driving the preview.

So the engine is plain TypeScript that imports nothing — no React, no Next, no packages — which is also what makes it testable as pure functions: time in, values out.

The cost I accepted

About twenty thousand lines that a library would have given away, and every solver bug is now mine. The mitigation was decided up front rather than after: if the bet failed, swap a library into the preview and keep the model and the codegen. It hasn't been needed.

08

Proving the export matches the preview

The fork

The entire promise is that what plays in the editor is what the exported code does. That's either true or the product is worthless, and it is not the kind of claim you can check by looking.

What I rejected
  • Trusting it. The solver and three separate codegen dialects would have to stay in agreement by hand, forever.
  • Snapshot-testing the generated code. That proves the output didn't change. It proves nothing about whether it's right.
Why

So exports get replayed. 111 scenes are generated, run in a real browser as Motion, as GSAP and as CSS, and compared against the engine's own trace of the same animation.

It runs the real libraries rather than a model of them, because the failure being guarded against is precisely the gap between what I think Motion does and what Motion does. Motion is sampled live, since it has no scrub API to ask.

It catches the class of bug that has no symptom in the editor: the preview stays perfect and the exported file drifts, which is the worst possible failure for this product because the user only finds it after they've shipped.

The cost I accepted

A slow gate and a real maintenance burden — every new feature that reaches the export needs scenes, and a change to the solver can turn the suite red in three dialects at once. That's the gate working, and it's still the most annoying thing in the repo.

The verve editor — elements panel, the component on the stage, spring curves on the timeline and the exported code

Designed, built, and shipped solo. Live at verveapp.dev.

Try it