Building Sequence's new homepage
We launched Sequence's new homepage in early October. It's a good example of how working with AI has changed the way I design and build.
The hero went through more than 30 versions across six experiment pages, and the one that shipped is the last one I made.
Before, that number would have been four or five. I'd spend hours in Figma working out the layout and the details of an idea or an animation, then move it to code to find out how the interaction felt as part of the page, and sometimes throw it away because it didn't feel good enough. If I had a dozen ideas, I had time to test four or five.
Now building is cheap. I built this page with agents, and every idea went on the real page, with the real copy above it and the next section below. I work from the feel back into the look: pick what feels right, and polish once the interaction and the layout are locked. What's left for me is deciding what's worth keeping, and that takes taste and engineering.
I also used Figma way less on this project. I tried designing in Figma first and bringing it to life through the MCP, but the functionality, the animation and the feel of an interaction were still faster to get right directly in code.
The page had a new job. The one before it launched with Sequence 2.0 and said what we wanted to be: pioneers of AI in finance. Since then we've shipped agents as a core part of the product, so this one had to show what we have.
This post is how we got there, in three phases I now keep apart on purpose: prototype every idea, polish with tools instead of chat, and engineer what ships. Each one holds the code to a different bar. A vibecoded idea can ship as it is, but it won't be crafted. The phases are how one becomes the other, and the technical parts show the decisions along the way.
Prototype every idea
During the creative exploration, every section we were still deciding on lived behind a switcher: a pill bar listing its variants. On the last experiment page six sections had one, with 31 variants between them. Fourteen were heroes:
const VARIANTS = [
{ id: 'a', label: 'A', Comp: HeroA },
{ id: 'b', label: 'B · Halo', Comp: HeroB },
{ id: 'c', label: 'C · Waterfall', Comp: HeroC },
// ...
{ id: 'l', label: 'L · Stream', Comp: HeroL },
{ id: 'm', label: 'M · Stream GL', Comp: HeroM },
{ id: 'n', label: 'N · Stream Arms', Comp: HeroN },
] as constWe started with a quick wireframe prototype to define the story of the page. By then I already had one or two ideas I wanted to try for each section. I prompted with those, with links or screenshots attached when an idea came from a reference or a quick wireframe, and left room for the agent to explore one or two more variations. A prompt looked something like: "duplicate this section and create four more variants: one with the layout inverted, one vertical instead of horizontal, one as scrollable cards, and come up with one more." This was early exploring, so neither its first versions nor mine were perfect. The final version of each section came from iterating on top of them.
A lot of that prompting happened on the page itself. With retune I point at an element and comment on it, instead of working out which file it lives in. I used it for new variants, for small things like copy, spacing and font sizes, and for bugs, right where I saw them: "This component is not properly applying the mix blend mode in Safari, let's move the text effect into the canvas itself." (More on that one later.)
The hero took three rounds. The first leaned hard on agents and AI. In the second we wanted it to feel enterprise, and tried a video among other things. We still felt something was missing. The third started from a different question: how do we illustrate what we do in one image? Sequence isn't one end-to-end process. It's a set of entry points, each with its own flow and tasks, like quoting, collecting, recognizing revenue or dunning, all under one idea: quote to cash.
I went into that round with two ideas: a swarm of dots forming a donut, and a graph that moved the dots through the stages from left to right. That took a lot of exploration, and at each step I also asked the agent for alternatives. The one that shipped is a swarm of tasks flowing left to right through the stages. The final tuning was mine, but the agent helped find the direction.
To get feedback, I shared links with a combination already picked, like /home?hero=v2&carousel=v5. Everyone reviewed the real page, not a mockup. The video hero came close. Hero N was the last element we polished before launch.
None of this code was meant to ship. Values were hardcoded, images were random stand-ins and the illustrations were fakes of the real content. I didn't spend time auditing accessibility at that point, and most of it broke on mobile. That's fine for me in this phase, the same way a design exploration doesn't need tidy layers. It gets fixed later, on purpose, in its own phase.
The one thing that does matter here is how a variant is wired and colocated, so I can freely move and edit one without unexpectedly changing another. I was redefining that setup for every new section, and again when cleaning it up, so I wrote it down as an agent skill, a set of instructions the agent loads when a task matches. It comes down to two rules: one component per variant file, and the page imports the switcher, not the variants.
Polish with tools, not chat
Most sections got a panel of controls while I was still prototyping, but there they were for broad strokes: rough timing, a layout idea. The real tuning waited until a variant was picked. Then every value that needed it got fine-tuned.
I used to fine-tune a transition by changing one parameter in code, testing, going back for the next one and trying again. Doing it through chat is the same loop with a wait added: "make it faster. Now a bit slower. Now a bit to the right." A slider is instant. It's like having a custom Figma panel for each animation, interaction or shader.
Adding custom panels for that now is one line after adding a section or an animation, something like "add a DialKit to manually adjust it" or "add a Tweakpane with every adjustable value". DialKit is Josh Puckett's library for tweaking values live. Tweakpane does the same. I usually reach for DialKit on regular animations and Tweakpane on heavier shader or canvas experiments, though on this page DialKit ended up covering almost everything, the hero included.
This is part of the hero's config. Each tuple is default, min, max, step:
const DIALS = {
stream: {
count: [25000, 500, MAX, 500],
speed: [0.013, 0.005, 0.3, 0.001],
// ...
},
// Plays once when the stream goes live, in seconds.
intro: {
replay: { type: 'action', label: 'Replay intro' },
duration: [4, 0, 6, 0.05],
// ...
},
light: {
dotSize: [5.5, 0.5, 24, 0.5],
colorLeft: { type: 'color', default: '#b13ffd' },
colorRight: { type: 'color', default: '#4dc3ff' },
// ...
},
// ...
} satisfies DialConfig
const dials = useDialKit('Hero N — Stream Arms', DIALS)The hero got the most: the number of dots (25,000 by default), how far they spread, their speed, size and colors, and when each label comes in and out. The intro has its own set, with a button to replay it after every change:
Almost every value got adjusted somehow. The other sections got the same treatment. On the outcomes cards it was the colors, where each card sits in the field, the speed, the noise, and how the field reacts to the mouse and to a click.
Feedback worked the same way as in the first phase. I moved the sliders until I had a version I liked, then shared it. What came back was general, like "it's too fast" or "the dots could be bigger", and I made the adjustments myself and sent it back.
Something I really like about this workflow is that what I'm tuning is the code. It isn't a prototype in Figma that might behave differently once it's rebuilt. When a value feels right it becomes the default in that config, and the config is what ships.
Engineer what ships
Everything up to here was allowed to be messy. This phase is where it stops being a prototype: clean it up, hold the code to my own conventions, make it work in every browser, and run the checks before it ships. After the checks there are usually a few last fixes, and then it goes out.
Clean up
The clean-up has its own skill too. Its first step is a script that reads every switcher, pins it to the current pick and walks the imports. This is its report on the experiment page before the first clean-up:
Switchers pinned (6):
_components/hero/switcher.tsx -> _components/hero/variant-n.tsx
_components/sections/agent-builder/switcher.tsx -> _components/sections/agent-builder/rail.tsx
_components/sections/customers/switcher.tsx -> _components/sections/customers/grid.tsx
...
KEEP (58 files, 11287 lines)
DROP — only reachable through losing variants (35)
ORPHANS — not reachable at all (8)From there the steps are mechanical, with a type-check and lint gate after each: copy what's kept, flatten the thin variant wrappers, prune the props and branches that only chose between variants, and compare screenshots of the old and new routes. The first run took the folder from 107 source files to 48, and from about 16,300 lines to about 9,900, with a pixel diff under 1%, all of it inside the WebGL hero and the animated counters. The agent did the run, and I made the calls along the way.
Code I'd write
Then the code gets held to the conventions I'd follow if I wrote it by hand:
- Components, data and helpers for a section live together.
- Components are built by composition, not by drilling props through them.
- A few core grid components keep the layout aligned across breakpoints, from mobile to desktop.
- Pure functions live outside components.
- CSS transitions over keyframe animations, and JavaScript animation only where it's strictly needed.
- Animations start when they come into view and stop when they're not needed.
- Click targets are big enough to hit.
Two of those are worth a closer look.
When building quickly, agents lean towards rebuilding primitive components from scratch: an accordion, a modal, a carousel, a button. They often do a decent job, but getting a primitive's accessibility, UX and DX right is reinventing the wheel. I'd rather not spend time polishing primitives. I start from a polished, unstyled library like Base UI, which takes care of them, and spend the time on what this page needs: the styling and the animation. The film modal, the menus and the FAQ accordion on the page are Base UI now.
Base UI also settled how I animate things that open and close. In the PlanetScale post I wrote data-animate={inView} and styled both states. Later I used tw-animate-css, which gives you an intro and an outro, but with CSS keyframe animations, and those aren't interruptible: close something halfway through opening and it can't reverse from where it is. So I started shifting towards CSS transitions, and it finally clicked when I looked at Base UI's approach, which keeps the intro and outro but uses transitions. The element gets data-starting-style for the frame it mounts and data-ending-style while it leaves, and the CSS only describes those two states:
<Dialog.Backdrop
className="fixed inset-0 bg-black/90 transition-opacity duration-300 ease-out
data-starting-style:opacity-0 data-ending-style:opacity-0"
/>For my own components, like the customer story sheet and the rail's captions, the page has a small useTransitionStatus hook based on Base UI's. It sets the same attributes and keeps the element mounted until its exit transition has finished, so every component on the page animates the same way:
export function useTransitionStatus(open: boolean, ref: RefObject<HTMLElement | null>) {
const [mounted, setMounted] = useState(open)
const [status, setStatus] = useState<'starting' | 'ending' | undefined>(undefined)
// Resolved during render, as Base UI does, so mount and the starting style land in one commit.
if (open && !mounted) {
setMounted(true)
setStatus('starting')
}
if (!open && mounted && status !== 'ending') {
setStatus('ending')
}
// The starting style comes off one frame after mount, which gives the transition its "from".
// Reopening mid-exit lands here too and clears the ending style, so the element eases back.
useLayoutEffect(() => {
if (!open) return
const frame = requestAnimationFrame(() => setStatus(undefined))
return () => cancelAnimationFrame(frame)
}, [open])
// Closing waits for the element's own finite animations to finish, then unmounts.
useLayoutEffect(() => {
if (open || status !== 'ending') return
let cancelled = false
const frame = requestAnimationFrame(() => {
const animations = (ref.current?.getAnimations() ?? []).filter(
(a) => a.effect?.getTiming().iterations !== Infinity,
)
Promise.allSettled(animations.map((a) => a.finished)).then(() => {
if (cancelled) return
setMounted(false)
setStatus(undefined)
})
})
return () => {
cancelled = true
cancelAnimationFrame(frame)
}
}, [open, status, ref])
const attrs = {
'data-starting-style': status === 'starting' ? '' : undefined,
'data-ending-style': status === 'ending' ? '' : undefined,
// ...
}
return { mounted, status, attrs }
}Using it looks the same as styling a Base UI component:
const t = useTransitionStatus(open, ref)
t.mounted && (
<div
ref={ref}
{...t.attrs}
className="transition-opacity data-starting-style:opacity-0 data-ending-style:opacity-0"
/>
)Works everywhere
Two Safari bugs are worth telling. The first was in the outcomes section: three stat cards. On hover a WebGL field fills the card, and the number and its label blend over it.
In Chrome the blend was one CSS class, mix-blend-difference, on the text. In Safari 18.6 the text came out unblended: Safari wouldn't blend DOM text with the WebGL canvas under it. I caught it testing by hand. The agent's own checks couldn't see it, because the screenshots it takes through safaridriver repaint the page in software, and in those the blend looked correct.
The fix was to draw the text inside the shader's canvas, an idea I took from Stripe: their new site keeps complex effects looking the same in every browser by putting them on a canvas. In my version the text stays in the HTML, and each glyph is traced into a 2D canvas at the box where the browser laid it out, so wrapping, kerning and tracking all come from the DOM:
for (let i = 0; i < text.data.length; i++) {
const char = text.data[i]
if (/\s/.test(char)) continue
range.setStart(text, i)
range.setEnd(text, i + 1)
const box = range.getBoundingClientRect()
const ascent = text2d.measureText(char).fontBoundingBoxAscent
text2d.fillText(char, box.left - base.left, box.top - base.top + ascent)
}That canvas becomes a texture, the fragment shader does the difference blend itself, and the DOM copy turns transparent. The result matches Chrome's CSS blend pixel for pixel.
The second was the agent builder rail: a row of panels where the focused one is a full card and the rest are strips that swell on hover.
It was smooth in Chrome and janky in Safari, and most of the reason was left over from the prototype. To explore colors, each panel drew its gradient with CSS filters, a hue rotation plus a grayscale copy for the strips, so I could try any hue from one image. Safari re-runs a filter on every frame its box changes size, and on every step of the rail every panel changes size.
The agent measured it. It drove real Safari through safaridriver, timed the frames around a click and removed one suspect per run:
base 195ms per frame
no filters 37ms
no dashboard 171ms
no border-radius 176ms
no will-change 141msAutomation Safari runs slower than normal Safari, so read these as a ratio. The filters were most of the cost. The dashboard and the rounded corners barely moved it, and will-change didn't help. Once the colors were locked there was no reason to keep the filters, so each hue became its own image, rendered from the same filter values so it looks identical. That took a step from 195ms to 32ms, about 6×. The blank panels in the before clip were a second Safari bug, found by the agent diffing the frames of my screen recording: real images with decoding="sync" instead of CSS backgrounds fixed them.
There was a bug in the widths too. Hovering a strip next to the focused card nudged the card, because two CSS transitions had to stay in step, the strip growing and the row re-centering, and they drifted apart when one reversed partway through. The rail now moves every width and the row's offset from one JavaScript tween on the same curve, so the focused card stays still. That's one of the few places where JavaScript animation was strictly needed.
Checks
Before a launch I used to spend a good while checking small things by hand: image sizes, accessibility, HTML semantics, that every shader has a fallback, that animations only run on screen. With generated code there's one more: that it still animates the way I'd write it.
Those checks are now seven skills: conventions, copy, motion, media, SEO, accessibility and performance. /preflight reads the diff, decides how deep to go, runs them in parallel and merges one report, sorted into blockers, should-fixes and nits. A one-line color change gets two static checks. The homepage got all seven, plus axe and Lighthouse against a production build.
Some of the findings it caught on this page:
- Animations that ignored reduced motion (MOTION-01), kept running off screen, or skipped the low-powered device check (MOTION-02).
- Fades still written as keyframe animations instead of transitions (MOTION-03).
- Images without alt text (MEDIA-02), and images at a higher resolution than where they're shown (MEDIA-03).
- The customer story sheet loading with the page. It now loads when you hover or focus a story, which takes it out of the initial JavaScript.
- FAQ answers that weren't in the HTML while closed. With Base UI's
hiddenUntilFoundthey're now in the server HTML, so search engines and find-in-page reach them.
/preflight is also wired into the repo's review step, and the conventions it checks are written into the repo's agent instructions, so they apply to anyone's changes to the site, not only mine.
Right thing, wrong time
Most of my mistakes on this page were the right work at the wrong time. Because the agent makes everything easy to start, I polished animations before a variant was locked, worked on multi-browser support while the page was still early, and cleaned up too early: for a couple of days every change was committed twice, once to the experiment and once to the clean copy, until I deleted the copy. Keeping the phases apart is what fixes that.
Building is cheap now. Deciding what's worth keeping is the job.
The page is live at sequencehq.com.
Credits
Thanks to Mo Rashed, Donal McKeon and the Sequence team.
Inspirations: the rail's mechanism comes from Jhey Tompkins' :has() speaker carousel, and the outcomes shader is adapted from a CodePen by masuwa.