
How I Built the Stitched Heart Like Button
A behind-the-scenes look at the blog like button on this site, inspired by Josh W. Comeau's playful interactions and rebuilt as a stitched heart powered by Supabase.
By Suryansh Kushwaha /
On this page
The tiny like button beside each blog post started with a very simple thought:
I wanted the page to feel a little more alive.
Not in a noisy way. Just one small detail that made reading the post feel less static.
The original spark came from Josh W. Comeau's website. His site has the kind of playful interaction design that makes you notice the craft without making the page harder to use. The details are useful, polished, and a little delightful.
I liked that feeling, but I did not want to copy the exact pattern. I wanted something that belonged to this site.
So I made the like button a broken heart that gets stitched back together. Try it out below!
The Interaction Idea
Most like buttons are binary:
- empty heart
- filled heart
That works. It is familiar. But it also feels very done.
I wanted the button to have a little progress inside it. So instead of one click flipping the state forever, each reader can add up to ten stitches to a post.
The heart starts cracked. Every tap adds one stitch. On the tenth tap, the heart becomes complete.
That gave the interaction a tiny story:
- The post has a broken heart.
- You add a stitch.
- The heart slowly heals.
- The tenth stitch fills it completely.
It is still just a like button. But now the like button has a memory of the taps that got it there.
If you want this kind of detail on your own website, something small but custom enough to make the whole experience feel more alive, consider reaching out to my Studio. This is the kind of product polish I like building.
The Supabase Schema
I store likes in Supabase with two tables:
blog_like_visitorsblog_post_likes
The visitor table exists so a browser can be recognized with a first-party anonymous cookie. No login or profile. Just a UUID stored in an HTTP-only cookie.
create table if not exists public.blog_like_visitors (
id uuid primary key default gen_random_uuid(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);The likes table stores the post slug, the visitor id, and the number of stitches that visitor has added to that post.
create table if not exists public.blog_post_likes (
id uuid primary key default gen_random_uuid(),
post_slug text not null check (char_length(btrim(post_slug)) > 0),
visitor_id uuid not null references public.blog_like_visitors(id) on delete cascade,
stitches smallint not null default 0 check (stitches >= 0 and stitches <= 10),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (post_slug, visitor_id)
);That unique (post_slug, visitor_id) constraint is the important bit.
It means each browser gets one row per post. The row can be updated from one stitch to two, three, four, and so on, but it cannot create unlimited duplicate rows for the same visitor and post.
The stitches check also keeps the database honest:
check (stitches >= 0 and stitches <= 10)Even if the client does something weird, the database refuses anything outside the actual interaction rules.
Recording A Stitch
The API route does not write raw table queries from the client. It calls a
Supabase RPC function named record_blog_post_like.
The function does three jobs:
- Create or refresh the anonymous visitor row.
- Insert a like row for the post if it does not exist yet.
- Increment the stitch count, capped at ten.
The core update is this:
on conflict (post_slug, visitor_id) do update
set
stitches = least(public.blog_post_likes.stitches + 1, 10),
updated_at = now()That least(..., 10) is the guardrail. The button can keep getting clicked,
but the saved value stops at ten.
The function also returns the current visitor's stitch count and the total likes for the post, so the UI can update immediately after the save.
The API Layer
The Next.js API route exposes two methods:
GET /api/blog/likes?slug=...POST /api/blog/likes
GET loads the current state for the visitor:
const { data, error } = await supabase
.rpc('get_blog_post_like_state', {
p_post_slug: slug,
p_visitor_id: visitor.id,
})
.single();POST records the next stitch:
const { data, error } = await supabase
.rpc('record_blog_post_like', {
p_post_slug: slug,
p_visitor_id: visitor.id,
})
.single();Before either call runs, the route checks that the slug belongs to a real blog post. That prevents random strings from becoming fake like records.
The visitor ID is stored in a cookie named blog_like_visitor_id. It is:
httpOnlysameSite: 'lax'- secure in production
- valid for one year
That is enough for this feature. It is not trying to identify a person. It is only trying to remember one browser's progress on one post.
The React Component
The component lives in components/blog/blog-like-button.tsx.
It keeps a small state object:
type BlogLikeState = {
maxStitches: number;
stitches: number;
totalLikes: number;
};maxStitches is currently ten. stitches is how many this visitor has added to
the current post. totalLikes is the sum of all saved stitches for that post.
When the button mounts, it fetches the saved state:
const response = await fetch(
`/api/blog/likes?slug=${encodeURIComponent(slug)}`,
{
cache: 'no-store',
credentials: 'same-origin',
signal: controller.signal,
},
);When the reader clicks, the component updates optimistically first. That makes the button feel instant instead of waiting for the network.
const nextOptimisticState = {
...optimisticState,
stitches: optimisticState.stitches + 1,
totalLikes: optimisticState.totalLikes + 1,
};Then it posts the real update to the API. When the server responds, the component merges the saved result back into the local state.
That merge uses Math.max(...) so a slightly delayed response does not make the
UI jump backwards after a fast click.
The SVG Heart
The heart is an inline SVG, not an image file.
That makes it easy to control:
- the outline
- the filled heart
- the crack
- the stitch marks
- the animations
The fill uses a clip path. As the stitch count increases, the active fill grows from the bottom of the heart:
const activeClipHeight =
state.stitches > 0 && state.maxStitches > 0
? Math.max(8, Math.round((state.stitches / state.maxStitches) * 56))
: 0;The visible stitches are capped to the number of stitch marks drawn in the SVG:
const visibleStitches = Math.min(state.stitches, stitchMarks.length);So the data model can say "ten stitches", while the SVG can show the visual marks that fit the design cleanly.
When the heart reaches ten, the crack and stitches disappear and the full heart gets a small completion animation.
Accessibility Details
The button is decorative visually, but not decorative semantically.
The real button has an aria-label that includes the current progress and total
like count:
aria-label={`Like this post, ${state.stitches} of ${state.maxStitches} stitches added, ${formattedLikes} total likes`}There is also an aria-live region so screen reader users get updates when a
stitch is added or the heart is complete.
Motion is disabled for people who prefer reduced motion:
@media (prefers-reduced-motion: reduce) {
.blog-like-heart-button svg,
.blog-like-heart-button[data-complete='true'] svg,
.blog-like-stitch[data-new='true'],
.blog-like-plus {
animation: none;
transition: none;
}
}That part matters. A playful detail is only good if it does not make the interface worse for someone else.
Final Takeaway
The stitched heart is a tiny feature, but it is exactly the kind of thing I like building.
It has interaction design, SVG, optimistic UI, server validation, database constraints, accessibility, and a little bit of personality.
The inspiration came from seeing how much care Josh W. Comeau puts into small web interactions. The final version became something different: a like button where the tenth tap does not just toggle a state, it finishes the heart.
And if you want thoughtful little interactions like this for your own website, you can reach out through my Studio. I would love to help make your site feel less generic and more yours.