Your agent wrote four buttons. Here is how to find them.
PrimaryButton, SecondaryButton, DangerButton, GhostButton. Nobody decided to have four. Each one was reasonable on the day it was written.
Ask an agent for a destructive action and you get a red button. Ask another day for a quieter one and you get a grey button. Neither request was wrong. Six weeks later there are four button components, they drift apart the moment either is touched, and none of them can share a test.
This is the most common thing an AI-assisted codebase accumulates, and it is much harder to notice than a hardcoded colour. A stray hex value is visible in one line. Four near-identical components are only visible if you happen to open all four at once.
The obvious approach does not work
The first idea everyone has — including us — is to match on names. PrimaryButton, SecondaryButton, DangerButton share a suffix and differ by a modifier. Strip the modifier, group what remains.
We tried this. It failed, and then it kept failing in new ways. Six times, on one codebase:
- Prefix matching grouped
ButtonwithButtonGroup— a container, not a variant. - Modifier detection flagged
NewHarnessForm, whereNewis the role, not a modifier. - Filename rules dropped
components/loading.tsx, mistaking a component for a framework reserved file. - Import co-occurrence — "things used together are related" — collapsed on real data and was abandoned.
- "Has a value, therefore it is a primitive token" broke on the first three-tier scale it met.
WaitlistSectionlooks like a member of a*Sectionfamily by name. It shares no structure with them at all.
Six counter-examples in one repository is not bad luck. It is the method telling you it does not work.
The reason is simple in hindsight. A name is a claim about intent, and intent is exactly what has gone wrong in a codebase full of near-duplicates. You cannot use the broken signal to detect the breakage.
Match on structure instead
What two look-alike components genuinely share is the shape of what they render. Three signals, all readable without running anything:
- The root element. A
<button>and a<div>are not variants of each other, whatever they are called. - The multiset of elements rendered.
div > spananddiv > span > spanare different components. - The vocabulary of styling properties — with the values thrown away. This is the counter-intuitive one, and it is the key.
Throwing away values is what makes it work. bg-[#3b82f6] and bg-[#ef4444] must be treated as the same — the whole thing you are hunting is "differs only in appearance". If the colours count as a difference, you can never find it.
bg-[#3b82f6] → bg
px-[13px] → px
hover:bg-red-500 → hover:bg
p-4 → pKeep the modifier (hover:) — whether a component has hover states is a real structural difference. Drop everything after the first segment.
Then group: same root, same element multiset, and styling vocabularies that overlap past a threshold. We use 80%.
Then be conservative, and measure
A duplicate detector that cries wolf gets switched off in a week, and after that you have no detector at all. So the bar is not "find everything" — it is "never be wrong in a way that costs someone an afternoon".
We ran the structural version against our own codebase — 71 live components — and got three groups. Two of them were wrong, and the reason was instructive: every false positive was a set of components that live in the same file.
Of course they matched. The fingerprint was computed per file, so co-located components produce identical fingerprints — not because they look alike, but because they are literally the same text. Skip same-file pairs and the result was one group, zero false positives.
The one that survived was real: two badge components, in different directories, differing only in colour and wording. One of them even says "see the other one" in its own documentation.
Run it against a codebase you know well before you trust it on one you do not. The false positives tell you more than the true ones.
What to do with a group once you have one
Not every group should be merged. The useful question is not "are these the same?" but:
- Same role, same structure, only appearance differs → one component with a variant prop.
- Different role, or different structure → leave them apart, and write down why, so the next reader does not re-open the question.
A practical test: if adding one prop expresses the difference, it is a variant. If you need three, they are probably different components wearing similar clothes.
And the reason to prefer the variant is not tidiness. PrimaryButton and DangerButton cannot share a spec, a test, or a token set, so they drift apart the moment either is touched. One component with two variants cannot.
Why nobody catches this by hand
You will not notice four buttons by reading pull requests. Each one arrived alone, in a diff where it was the reasonable thing to add. The duplication is a property of the set, and nobody ever looks at the set.
That is the part Harnd automates: it reads the components out of the code you already have, groups the look-alikes by structure, and shows what they have in common so you can make the call. On a deliberately messy test app it finds two groups — four buttons and three cards — in a codebase of twelve components. Early access is open.
If you build your own version, take the one lesson that cost us the most: do not ask what the components are called. Ask what they render.