Beyond AI Demos: Building Production-Ready Creative Websites
How to use software design principles to get the best result with AI for creative websites.
It’s Friday afternoon, you’ve just opened this message about a little issue to fix in this project from last year. Your brain is already too full to dive into a new codebase so you put Claude on this task. It analyzes the code first and in a minute comes back with a simple solution. You barely read it and accept the modifications, life is easy. Then you open your localhost to check before committing, and nothing has changed. The bug is still here. A long back-and-forth begins where you try to fix the problem without having to dive into the codebase. Modifications are now additions, 500 new lines of code across 6 files, and a full new level of complexity added. If you’re like me, you’ve encountered this situation at least a few times in your developer life, and you kept adding patches, blaming your old self.
But here’s the thing: the problem was neither Claude nor your old self.
Your worst enemy has a name and it’s complexity
So why did the codebase become so hard to maintain, even for an AI? It is hardly ever about a single decision, but a sum of tiny reasonable decisions, each of which introduces just a bit of complexity. A boolean added for one specific case, a setTimeout to handle an order problem, a function that is duplicated and not extracted… This is how complexity grows in your application, with pernicious little steps.
Complexity is what makes a system difficult to understand or modify. It’s not about the size of your code, or how sophisticated it is, but it has two main causes: dependencies (I cannot update X without modifying Y) and obscurity (I can’t tell what I need to know to use this module). Complexity is the first thing you need to care about at every stage of development.
// gallery.ts
const dragLimit = (viewportWidth - totalWidth) / 3 // 3 = parallax amplitude
// item.ts (300 lines of animation code later)
const AMPLITUDE = 3
card.x = dragX * AMPLITUDE
// intro.ts
card.x = dragX * 3 // value from item.ts
This deliberately simplified (and bad) code shows the two main issues that create complexity:
-
dependencies: ask AI to update amplitude to
4in item.ts and the drag limit in gallery.ts and the intro animation become wrong without any warning or visible error -
obscurity: we cannot easily identify that the three
3are linked. The only hints are comments, which nothing checks and which don’t tell you the other places to update.
// config.ts
export const AMPLITUDE = 3 // imported by gallery.ts, item.ts and intro.ts
What you should do here is extract the amplitude to a shared config file and make the usage obvious.
This is an exaggerated example but if you ship fast without paying full attention, little design mistakes like this can happen and pile up into real complexity.
The problem isn’t caused by AI, but AI amplifies it by generating code so fast. Imagine welcoming a new developer to your team. You want a clean design, solid conventions, code modules that are simple enough for them to use without needing to understand everything under the hood. AI is like the new dev, but it has no memory between sessions: everything must be written down or carried by the structure of the code, otherwise it will improvise.
A Philosophy of Software Design
This definition of complexity doesn’t come from me: it comes from a reference book in the software industry, A Philosophy of Software Design, written by John Ousterhout and published in 2018. The cool thing is that most of the book’s ideas aren’t confined to software programming, and can also be applied to creative web development. We’ll go through a few of its concepts here, but I encourage you to read it if you’re interested in the topic: it’s an easy read.
1. Complexity
Anything in a system’s structure that makes it difficult to understand or modify.
Three symptoms:
- Change amplification: one simple modification requires changes in multiple places.
- Cognitive load: you have to keep a lot of things in mind to update a piece of code.
- Unknown unknowns: you don’t know what needs to be updated, or what could break. It’s the worst of the three.
in the earlier example (the three 3s), there are 2 symptoms of complexity. Changing the amplitude from 3 to 4 is change amplification because the value lives in 3 different places, and unknown unknows because you’ll get no warning that it needs to be changed elsewhere.
Complexity is incremental: it comes from an accumulation of small patches, not from one big mistake. You need zero tolerance, otherwise it piles up. And complexity is judged from the reader’s point of view, not the author’s.
2. Deep modules
The author calls a module anything that has an interface and an implementation: a class, a function, a service. The interface is what a developer needs to know to use the module: its signature, behavior, constraints, side effects… The implementation is the code inside, where the complex functionality lives.
There are two kinds of modules: deep and shallow. Your modules should aim to be deep, meaning that a lot of functionality is hidden behind a simple interface. For example, a class that creates a complex 3D animation can contain many complex calculations and animations, yet expose only a playAnimation() function.
The opposite of a deep module is a shallow module: its interface is nearly as complex as what it does, so it hides very little. Here is an example with two interfaces of the same camera module.
// Shallow: each step is exposed, the caller orchestrates
cameraRig.setStartPosition(camera.position)
cameraRig.setEndPosition(targets.detail.position)
cameraRig.setLookAtTarget(targets.detail.lookAt)
cameraRig.setEasing('power3.inOut')
cameraRig.createTween()
cameraRig.startTween()
cameraRig.updateLookAt() // to call every frame, or the camera looks the wrong way
// Deep: one call, the rig handles the rest
cameraRig.goTo('detail') // tween, lookAt, interruption and resize are inside
In the shallow version, the developer using this module must know all the 7 calls, their order and if you forget to call LookAt on a request animation frame, you won’t get any warning. Having a simple implementation can save a developer brain or some AI tokens.
3. Information hiding and leakage
- Hiding: a module encapsulates its own design decisions (formats, algorithms, structure) in its implementation. It reduces the dependencies.
- Information leakage: a design decision reflected in several files is a major red flag.
- Temporal decomposition: decomposing your modules in chronological order of actions (read, parse then write) can lead to leakage: each phase must know all the features, so the same decision is spread across multiple file.
This last point can be very important in creative development, because an animation has a lifecycle (init -> layout -> animate -> dispose). You should group by shared knowledge and responsibilities, so your interfaces are simpler, with better information hiding and no leaking.
Here are examples of temporal and knowledge decomposition:
Temporal (to avoid)
src/
init/ creates everything: cards, gallery, message
layout/ positions everything
animations/ animates everything
cleanup/ disposes everything
By knowledge and responsibilities (recommended)
src/
app/
flow.machine.ts states, events, legal transitions (pure logic)
choreography.ts per state: which animations play, in which order
features/
card/ knowledge: what a card is
card.ts public API: createCard(), toImage(), dispose()
card.layout.ts private: where things sit on a card (pure maths)
card.render.ts private: drawing and exporting the card
card.animation.ts private: how a card moves (enter, flip, leave)
card.config.ts private: sizes, durations, eases
gallery/ knowledge: how a set of cards is browsed
gallery.ts public API
gallery.layout.ts
gallery.animation.ts
shared/ only what 2+ features really share
motion.ts durations and eases used across features
viewport.ts one source for size and breakpoints
The second example is the one which limits the complexity and cognitive load. One directory equals one knowledge. The flow.machine.ts knows which transitions are legal, but doesn’t know the DOM or gsap. choreography.ts answers ‘what plays, in which order, and when’. Each feature animates with its .animation.ts file, and the choreography decides when. There is no watcher inside a component.
Inside a feature, it is ok to split layout and animation even if it looks like phases: each hides something different (maths, GSAP) behind a small interface.
4. Strategic mindset
Ousterhout describes the mindset to adopt when writing code: the strategic mindset, as opposed to the tactical one.
A tactical developer codes and ships features very fast, but each shortcut adds a bit of complexity. That leads to complexity growing until the project becomes too complex to manage. AI without structure is like a super efficient tactical developer.
For a strategic developer, the first goal is a good design, working code is a by-product. Ousterhout says that about 10 to 20% of the time should be dedicated to design upgrades during the process, not with a big refactoring at the end of it. A bad design has a cost increasing at each modification, so even if it takes more time at the start, the gains will come quickly. This approach doesn’t mean over-planning and over-engineering before coding; it’s more about small, regular investments.
The author also supports a “design it twice” approach, meaning that when you design a module, you should consider two different approaches for its interface, even if you’re certain about the first one. He says that it will train your judgement and almost always improve the design. I personally never tried this approach, but it can be a good thing to try with an AI workflow.
Those are only some of the core concepts, but there are many more in the book, like pulling complexity downwards, designing somewhat general-purpose modules, or defining errors out of existence. I won’t go into more details here but don’t hesitate to check the book!
Design principles for creative websites
Here are some other design principles that I draw from my experience in creative coding:
State machines
More than once I found myself adding variables and booleans to describe the states of my applications, combining things like currentView = gallery with booleans like isLoading, isAnimating, isOpen … This way to describe the state of an app creates many possible combinations, most of them having no sense. You may also find yourself having these state variables scattered into multiple files.
A system has a finite number of explicit states, events, authorized transitions, optionally guards (conditions) and effects (actions). Illegal states should be impossible by design: instead of the boolean combinations, one clear state 'idle' | 'loading' | 'open' | 'error'.
To work with AI, it’s a good thing to design a transition table, so there aren’t edge cases to guess and testing the code gets easier. It’s basically a list of what can happen and what it does.
The state machine doesn’t play animations, it just tells where we are.
Here is an example without state machine, inspired by an old vue.js project, before refactoring:
let view: 'intro' | 'gallery' | 'item' | 'details' // 4 values
let isTransitioning: boolean // × 2
let interactionsEnabled: boolean // × 2 = 16 combinations on paper
// And in every component that needs to react:
watch(() => view, (newView, oldView) => {
if (newView === 'item' && oldView === 'gallery') { /* … */ }
else if (newView === 'details') { /* … */ }
else if (newView === 'gallery' && oldView === 'intro') { /* … */ }
})
This approach increases complexity on several levels: responsibilities are shared among a lot of files and so is the source of truth, the watch() can get very messy with many if / else if, and some exotic and illegal combinations can exist, for example an ‘item’ view with isTransitioning set to true while interactions are enabled.
| State | What it accepts → next state |
|---|---|
intro |
INTRO_DONE → gallery |
gallery |
OPEN → opening |
opening |
OPENED → item |
item |
NEXT / PREV → switching (if there is one), SHOW_DETAILS → details, CLOSE → closing |
switching |
SWITCHED → item |
details |
HIDE_DETAILS → item, CLOSE → closing |
closing |
CLOSED → gallery |
This is an example of a transition table that we would create for the design of this same website. Every event not on the table is ignored. You can use it a a design document before writing code.
type View = 'intro' | 'gallery' | 'opening' | 'item' | 'switching' | 'details' | 'closing'
type Context = { view: View; selected: number; count: number }
function transition(ctx: Context, event: Event): Context {
switch (ctx.view) {
case 'intro':
return event.type === 'INTRO_DONE' ? { ...ctx, view: 'gallery' } : ctx
case 'gallery':
return event.type === 'OPEN'
? { ...ctx, view: 'opening', selected: event.index }
: ctx
case 'opening':
return event.type === 'OPENED' ? { ...ctx, view: 'item' } : ctx
case 'item':
switch (event.type) {
case 'NEXT':
// No next item: nothing happens, and nothing is left locked
return ctx.selected < ctx.count - 1
? { ...ctx, view: 'switching', selected: ctx.selected + 1 }
: ctx
case 'SHOW_DETAILS':
return { ...ctx, view: 'details' }
case 'CLOSE':
return { ...ctx, view: 'closing' }
default:
return ctx
}
// … switching, details, closing follow the same shape
default: {
const unreachable: never = ctx.view // compile error if a view is not handled
return unreachable
}
}
}
// Derived, never stored: replaces isTransitioning and interactionsEnabled
const canInteract = (ctx: Context) =>
ctx.view === 'gallery' || ctx.view === 'item' || ctx.view === 'details'
Animation updates the state when completed or interrupted:
// When the view becomes 'opening', the animation layer plays and reports back:
gsap.to(item, { x: 0, duration: 0.9, onComplete: done, onInterrupt: done })
// where done = () => send({ type: 'OPENED' })
Here are the pros of this approach with the transition table and state machine:
- Only 7 named states, no more 16 possible combinations
- No need for booleans to lock events. If you click during opening, nothing happens.
NEXTon last item doesn’t do anything either. - canInteract is computed, not stored and set in multiple files.
Other useful design rules
Other design rules I follow when working with AI agents, no specific order:
- One source of truth for each piece of data, the rest is derived.
- Unidirectional flow: state -> render, no cross-mutations.
- Responsibilities are separated: inside each feature, files are split by what they hide (logic, motion, data), not by execution step.
- Composition over inheritance
- Explicit lifecycle: everything that is created also has a cleanup/dispose function.
An efficient workflow with AI
With all these design principles in mind, you should be able to produce simpler, cleaner code, which will result in a higer AI efficency. To give the AI even more structure, you could write the design principles that matter for your project in your CLAUDE.MD, or write your own software design skill based on the book’s rules and your own specific additions.
In addition to structuring the AI’s work with architecture and design principles, you could use strict verification rules with linting, type-checking and hooks after AI modifications. It’s more reliable to create an automatic rule to verify the code than asking AI to check.
These rules were already relevant in team projects before AI, but they are now even more important.
The workflow to limit the complexity of your project could be like this:
- Plan (state machine for the global transitions, and write down the interfaces with comments for the specific modules)
- Design it twice (Ousterhout’s recommendation, which I haven’t tried yet)
- Refactor before adding a feature
- Work in the smallest possible steps
- Verification process with AI and strict rules (type check, browser access, resizing while animating)
Final note
The goal of all these concepts is to improve our architecture and design skills as developers by keeping complexity under control. Shipping features as fast as possible without taking steps back and improving our designs has always led to hard to maintain spaghetti code. This problem just became more visible in the AI era, because code has never been produced this fast and the technical debt and complexity can grow incredibly fast too. With the right process and mindset, I believe that we can produce really good production-ready code with AI, not just huge visual 3D experiments or slop. The good thing about this approach is that you can use AI in every step, an AI to help you plan, an AI to execute, and you still get to use your brain. So your next Friday afternoon won’t hit as hard. Your codebase will still be clean and Claude will fix the bug in seconds. You’ll have time to go out and see the sunlight. Wonderful, isn’t it?