Migrating a hand-made control
Seven steps to move a hand-made button, tag or table onto the package.
Why
A hand-made control is one an app draws itself (a raw <button> with its own classes, a home-made search box or tabs, the browser's own confirm box) instead of taking it from @brdgapp/ui. Each copy drifts: its own size, color and hover, and often no focus ring or keyboard support.
On 2 November 2026, Bridge OS's main branch had 128 raw <button> elements in 71 files; its open merge request onto the package brings that to 98 in 53 files. Not every one is a mistake (a whole clickable row can stay a button), but most are hand-made versions of a package control. The same mistakes recur, so these steps apply to every control, not only buttons.
What replaces what
Find the hand-made thing on the left; use the component on the right. The Bridge OS examples are real, from the survey of 2 November 2026.
| Hand-made | Use | In Bridge OS |
|---|---|---|
A pill-shaped <button> with its own color, padding and text size | Buttonvariant, size | Activity feed items' Send and Skip (activity-feed-item.tsx); empty-state pills on the organization and contact pages |
| An outlined pill link ("View more") | Buttonvariant="secondary" with render={<a/>} | Organization page's View more (org-network-content.tsx) |
| A round button with only an icon | IconButtonaria-label | Close and more buttons written inline |
| A colored label in a pill or box | Tagcolor | Status labels drawn with hex backgrounds |
A rounded <input> with a search icon | SearchInput | Sharing's search (sharing-table.tsx); Network overview's search (connectors-tab.tsx) |
| Buttons with an underline under the active one | Tabs | Settings' tabs (settings-content.tsx); the admin embed's code tabs |
| Chips used to switch what a card shows | SegmentedControl | Invites' Received / Sent chips (connectors-tab.tsx) |
| A dropdown of plain buttons, or Radix's DropdownMenu | ActionMenu | Contact actions menu (contact-actions-menu.tsx); the app launcher and user menu |
The browser's own confirm() box | ConfirmDialog | Sharing's Stop sharing; Network settings' Stop all; Settings' Remove key |
A native title tooltip | InfoHint, HoverTip and TipTip, HoverTip, InfoHint | Tags with title text |
A grid or <table> with capitals headers and its own rows | DataTable | Sharing's list (sharing-table.tsx); the admin apps table |
| A "Load more" link under a list | DataTableDataTablePagination | Sharing (sharing-table.tsx) |
if (loading) return <Skeleton/>, or a hand-drawn empty or error box | DataState | Empty and error states drawn per page (about six different ways) |
How to find them
Search the app's code for these. Each hit is a candidate, not a verdict.
| Search for | What it usually is |
|---|---|
<button | A hand-made button or click area. Most become Button or IconButton; a whole clickable row can stay. |
rounded-full bg-[ | A hand-made pill: almost always a Button or a Tag. |
confirm( | The browser's confirm box: use ConfirmDialog. |
#0038FF #0EA02E #E13535 | Hex colors: use role tokens, and usually a component that already has them. |
title=" | A native tooltip: use Tip, HoverTip or InfoHint. |
@radix-ui | Radix parts the package replaced with Base UI (menus, dialogs, tooltips). |
components/ui/ | A local copy of a package component: import it from @brdgapp/ui instead. |
The MCP's check_code finds most of these in a file at once (see For agents).
Seven steps
- Delete every look class from the old markup. Don't carry over
text-[13px], padding, height, radius, color,font-semibold,hover:ortransition-*. The component's options own all of that. - className is for layout only.
shrink-0,mt-4,w-full,self-start. If the look needs a className, the chosen option is wrong: pick another, or fix the component. - Snap to the nearest size. 13px becomes
sm(14px); a 10px control becomesxs(12px). A 1–2px change is the expected cost of joining the system; keeping the old pixel is the mistake. - Drop one-off flourishes. Animated arrows, custom hovers, a one-off accent color: remove them so the control matches its siblings.
- The bar for "no equivalent" is high. "It's small", "it's a link", "it's a one-off" all map to an option. A true exception isn't a text or icon control at all (a photo used as a trigger, a chart bar). When in doubt, migrate.
- If the system can't do it, fix the system, then migrate. Add the option to the component and its handbook page in the same change. Never work around it at the call site.
- Check in the browser, not by eye. Search the file for leftover
text-[,px-,bg-,rounded-,hover:on migrated lines, and check one element's computed size and color.
Before and after
Left, copied from Bridge OS; right, the package. Press Tab through them and switch to dark.
A search box
Sharing's search against SearchInput.
Hand-made
Package
Type in both: only the package one has a clear button, and its placeholder passes contrast.
In code:
// Before: hand-made
<button className="rounded-full bg-[#0038FF] px-4 py-1.5 text-[13px] font-semibold text-white hover:bg-[#0036D7]">
Make intro
</button>
// After: the system decides the look
<Button size="sm">Make intro</Button>
// Moving from Bridge OS: asChild becomes render
<Button asChild><Link href="/settings">Settings</Link></Button> // before
<Button render={<Link href="/settings" />}>Settings</Button> // afterSources
- Bridge OS
components.md→ Button migration contract (7 rules), generalised to every component. - Plan
03-architecture.md→ Radix → Base UI (asChild → render). - Counts:
git grep '<button'on Bridge OSorigin/main(5d9b382) andorigin/design-system/adopt-ui(b2adf7d), 2026-11-02. - Examples: the Bridge OS consistency survey, 2026-11-02 (all 26 screens).