In this step-by-step guide, we’ll tackle the classic "Circle Drawer" challenge from the 7 GUIs benchmark. We’ll focus on drawing circles and implementing undo/redo—but skip the diameter editing to keep things simple and clean.
Here’s what we’ll build:
Our app will let users:
- Click to draw circles
- Undo and redo every action
Let’s break it down and build it up.
Getting Started
I’ve set up a StackBlitz starter project so you can start coding right away. Just sign in with GitHub and you're good to go.
Alternatively you can set up a local project on your machine.
I'm using Tailwind CSS for styles, but you can use anything you’re comfortable with.
Step 1: Defining Our Circle Type
Before we touch any UI, we need to define what kind of data our app is dealing with. Since we’re using TypeScript, we'll define a type for our circles.
Each circle will have:
- A unique
id
- An
x
andy
coordinate - A fixed
r
(radius) — we’ll set it to 25 by default
File: src/lib/types.ts
export type Circle = {
id: number;
x: number;
y: number;
r: number;
};
Step 2: Storing Circles in State
Now we need somewhere to keep track of our drawn circles. We'll the $state
rune which will give us a reactive local state which we will use to store our circles and render it to the UI.
File: src/routes/+page.svelte
<script lang="ts">
import type { Circle } from '$lib/types';
let circles = $state<Circle[]>([]);
</script>
We’ll update this later to plug in a history mechanism, but this is a good starting point.
Step 3: Drawing on an SVG Canvas
We'll use <svg>
to render our circles. SVG is perfect for this challenge because it treats every shape as a DOM node, which makes it super easy to update or delete.
<svg width="600" height="200">
{#each circles as circle (circle.id)}
<circle
cx={circle.x}
cy={circle.y}
r={circle.r}
stroke="blue"
stroke-width="2"
fill="transparent"
/>
{/each}
</svg>
Svelte’s {#each}
block will update the DOM automatically whenever the circles
array changes.
Step 4: Drawing Circles on Click
Right now, our app shows an empty canvas. Let’s make it respond to clicks by drawing a circle where the user clicks.
But here's the catch: click events give us clientX
and clientY
, which are relative to the window, not the SVG. So we’ll grab the bounding box of the <svg>
to offset the click coordinates properly.
We also use bind:this
to reference the DOM node directly.
<script lang="ts">
import type { Circle } from '$lib/types';
let svgElement: SVGSVGElement;
let circles = $state<Circle[]>([]);
function handleClick(event: MouseEvent) {
const svgRect = svgElement.getBoundingClientRect();
const x = event.clientX - svgRect.left;
const y = event.clientY - svgRect.top;
const newCircle: Circle = {
id: Date.now(),
x,
y,
r: 25
};
circles.push(newCircle);
}
</script>
<svg bind:this={svgElement} on:click={handleClick} width="600" height="200">
{#each circles as circle (circle.id)}
<circle cx={circle.x} cy={circle.y} r={circle.r} stroke="blue" stroke-width="2" fill="transparent" />
{/each}
</svg>
Now every time you click the canvas, a circle should appear right under your cursor.
Step 5: Undo/Redo
Let’s add full undo/redo support. We’ll build a tiny state history engine that stores snapshots of the circles
array. Each time the user draws a circle, it creates a new version. The user can then go back and forth in time like to access the circles.
File: src/lib/history.svelte.ts
export function createHistory<T>(initialValue: T) {
const history = $state<T[]>([initialValue]);
let index = $state(0);
const current = $derived(history[index]);
const canUndo = $derived(index > 0);
const canRedo = $derived(index < history.length - 1);
function update(newValue: T) {
// Cut off any "redo" history when updating
history.length = index + 1;
history.push(newValue);
index = history.length - 1;
}
function undo() {
if (canUndo) index--;
}
function redo() {
if (canRedo) index++;
}
return {
get current() { return current; },
get canUndo() { return canUndo; },
get canRedo() { return canRedo; },
update,
undo,
redo
};
}
This generic helper works with any type of state, not just circles.
Step 6: Wiring It All Together
Now we’ll plug our history engine into the page. Instead of directly mutating circles
, we’ll call circleHistory.update()
every time we want to make a change. This gives us full control over the timeline.
<script lang="ts">
import type { Circle } from '$lib/types';
import { createHistory } from '$lib/history.svelte.ts';
const circleHistory = createHistory<Circle[]>([]);
let svgElement: SVGSVGElement;
function handleClick(event: MouseEvent) {
const svgRect = svgElement.getBoundingClientRect();
const x = event.clientX - svgRect.left;
const y = event.clientY - svgRect.top;
const newCircle: Circle = {
id: Date.now(),
x,
y,
r: 25
};
const newCircles = [...circleHistory.current, newCircle];
circleHistory.update(newCircles);
}
</script>
We will also wire up our buttons and the {#each}
block to our new state.
<button on:click={circleHistory.undo} disabled={!circleHistory.canUndo}>Undo</button>
<button on:click={circleHistory.redo} disabled={!circleHistory.canRedo}>Redo</button>
<svg bind:this={svgElement} on:click={handleClick} width="600" height="200">
{#each circleHistory.current as circle (circle.id)}
<circle cx={circle.x} cy={circle.y} r={circle.r} stroke="blue" stroke-width="2" fill="transparent" />
{/each}
</svg>
And there we have it! A fully functional, reactive Circle Drawer with a robust undo/redo system. You can click a bunch of times, undo all the way back to zero, and redo them one by one.
Wrap-up
What we built:
- Draw circles with a click
- View all circles with SVG
- Undo and redo any action
- Fully reactive state with Svelte 5 runes
This is a great example of how declarative + reactive UI can be powerful and minimal. If you want to go further, you could:
- Add diameter editing as per the challenge
- Add keyboard shortcuts for undo/redo
Thanks for following along! Happy coding!.
Top comments (0)