Migrating custom icon buttons to TapButtons. Use when replacing a raw <button>, IconButton, or className-styled icon; migrating header, toolbar, or footer icons; lazy-splitting a button's highlight decoration; or on 'tap targets', 'efficiency update', 'lazy split', or making a small component production-ready.
Installs into .claude/skills of the current project.
Are you the author of Efficient Tap Button Migration?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/armanisadeghi-efficient-tap-button-migration)
---
name: efficient-tap-button-migration
description: "Migrating custom icon buttons to TapButtons. Use when replacing a raw <button>, IconButton, or className-styled icon; migrating header, toolbar, or footer icons; lazy-splitting a button's highlight decoration; or on 'tap targets', 'efficiency update', 'lazy split', or making a small component production-ready."
---
# Efficient TapButton Migration Pattern
The single source of truth for migrating any small icon button to the project's tap-target system — and what an "efficiency update", "efficiency refactor", or "lazy split" of a small UI component means here: the production-ready pattern below. Reference implementation: `features/feedback/FeedbackButton.tsx` + `features/feedback/FeedbackHighlight.tsx`. Read both before applying this skill to a new component.
The pattern has four pillars. All four must hold for the migration to be considered complete.
---
## Pillar 1 — Tap-target hygiene
### Rules
- **No surrounding spacing.** Never wrap a TapButton in `p-*`, `m-*`, or `gap-*`. The invisible 44×44 outer ring already reserves space; adding more produces double-spacing.
- **No `className` for visuals.** Variation goes through documented props: `variant`, `tooltip`, `ariaLabel`, `bgColor`, `iconColor`, `hoverBgColor`, `activeBgColor`. A `className` passthrough on a TapButton consumer is a code smell.
- **Pre-composed > primitive.** Use `BugTapButton`, `PlusTapButton`, `SearchTapButton`, etc. from `@ai-matrx/tap-target/buttons`. Don't reach for the raw `TapTargetButton` + manual icon.
- **Tooltip auto-derives from `ariaLabel`.** Set `ariaLabel="Submit Feedback"` and the tooltip mirrors. Pass `tooltip="..."` only to override. Pass `tooltip={false}` to opt out.
- **Anchor decorations to the visible 32×32 inner pill.** Badges/dots/pings use `top-1.5 right-1.5` (offset 6px from the 44×44 outer), not `top-0 right-0`.
### THE PLACEMENT RULES (owner, 2026-10-02) — the guard enforces them
- **Glass only floats.** Glass is see-through. It belongs only on a bar floating over moving content (sticky or fixed with the page scrolling behind it, like iOS Messages' header) or on a `data-matrx-glass-plane`. Everywhere else (a page, card, toast, dialog or table), pass `variant="transparent"` (or `outline`) and use `TapTargetButtonGroup surface="solid"`. Never take the glass default without choosing it.
- **All glass or none.** A glass button never sits beside a non-glass element in the same row.
- **The 3px half-gap.** The box adds 3px of unseen space per side. Whatever sits beside the button (breadcrumb text, a field, a container's edge) adds its own 3px, so the visible gap is always 6px.
### Mapping consumer `className` → `variant`
| Old className intent | New variant |
|---|---|
| `shell-glass …` background, on a bar floating over scrolling content | `variant="glass"` (only there — see the placement rules) |
| `hover:bg-accent` / hover-only background | `variant="transparent"` |
| Solid filled button (e.g. primary CTA) | `variant="solid"` + `bgColor="bg-…"` |
| Inside a `TapTargetButtonGroup` | `variant="group"` |
### Migration before/after
```tsx
// ❌ Before — custom <button>, className overrides, p-2 padding
<button
className="p-2 rounded-full hover:bg-accent transition-colors"
aria-label="Submit Feedback"
onClick={handleClick}
>
<Bug className="w-4 h-4" />
</button>
// ✅ After — pre-composed TapButton, no className, props only
<BugTapButton
variant="transparent"
ariaLabel="Submit Feedback"
onClick={handleClick}
/>
```
### Suspense fallbacks must match the 44×44 outer
A TapButton's outer ring is 44×44 (`h-11 w-11`). If a parent lazy-loads the button via `Suspense`, its fallback must reserve the same dimensions or the row will shift on hydration.
```tsx
// ❌ Before — sized to the old custom button (~32×32)
<Suspense fallback={<button className="p-2 opacity-30" disabled><Bug className="w-4 h-4" /></button>}>
<FeedbackButton />
</Suspense>
// ✅ After — sized to the 44×44 tap target
<Suspense
fallback={
<span className="flex h-11 w-11 items-center justify-center opacity-30" aria-hidden="true">
<Bug className="w-4 h-4" />
</span>
}
>
<FeedbackButton />
</Suspense>
```
---
## Pillar 2 — Lazy-bundle discipline
The default for a small button used across many routes: **only the icon and click-dispatch ship in main.** Heavy decoration (extra icons, animation classes, dismiss UI, persistence logic, redux actions used only by the decoration) goes in a sibling component loaded via `next/dynamic`.
### Rules
- **Split when the lazy chunk is meaningfully heavier than the main chunk.** A 2-line decoration with no extra deps shouldn't be split — the network round-trip and chunk bookkeeping cost more than they save. Split when the lazy code pulls extra `lucide-react` icons, animation logic, persistence calls, or substantial JSX.
- **Use `next/dynamic({ ssr: false, loading: () => null })`.** No SSR (interactive-only), no fallback flicker.
- **Always include `"use client"` on the lazy file.** Required even though it's only imported via `next/dynamic` from a client file.
- **Gate the dynamic render with cheap selectors.** Wrap `<Lazy />` in a redux/state boolean check so the chunk isn't fetched for users who'll never see it. The gate uses cheap selectors that already live in main; only the decoration's *side effects* (timers, dispatches) move to the lazy file.
- **Coordinate parent ↔ lazy child via a one-shot `tick: number` prop.** When the parent click should trigger something in the lazy child (e.g. dismiss the highlight), pass `dismissTick: number` and increment on click; the lazy child watches it via `useEffect`. No callback refs, no event bus, no imperative handles, no context.
- **Co-locate redux actions with the chunk that dispatches them.** Actions used by the always-rendered icon (e.g. the typed opener) stay in main. Actions used only by the decoration (e.g. `setModulePreferences`) live in the lazy file.
### File layout
```
features/<feature>/
├── <Feature>Button.tsx # main chunk: icon + click + gate + dynamic ref
└── <Feature>Highlight.tsx # lazy chunk: decoration + persistence + extra icons
```
### Reference: main file
```tsx
// features/feedback/FeedbackButton.tsx
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import {
BugTapButton,
type TapButtonProps,
} from "@ai-matrx/tap-target/buttons";
import { useAppSelector } from "@/lib/redux/hooks";
import { useOpenFeedbackWindow } from "@/features/overlays/openers/feedbackDialog";
const FeedbackHighlight = dynamic(() => import("./FeedbackHighlight"), {
ssr: false,
loading: () => null,
});
type FeedbackButtonProps = Pick<TapButtonProps, "variant" | "tooltip">;
export default function FeedbackButton({
variant = "glass",
tooltip,
}: FeedbackButtonProps) {
const openFeedback = useOpenFeedbackWindow();
const userId = useAppSelector((s) => s.userAuth.id);
const viewCount = useAppSelector(
(s) => s.userPreferences.system.feedbackFeatureViewCount,
);
const prefsLoaded = useAppSelector(
(s) => s.userPreferences._meta.loadedPreferences !== null,
);
const [dismissTick, setDismissTick] = useState(0);
const shouldShowHighlight = !!userId && prefsLoaded && viewCount < 5;
const handleClick = useCallback(() => {
if (shouldShowHighlight) setDismissTick((n) => n + 1);
openFeedback();
}, [openFeedback, shouldShowHighlight]);
return (
<div className="relative">
<BugTapButton
variant={variant}
ariaLabel="Submit Feedback"
tooltip={tooltip}
onClick={handleClick}
/>
{shouldShowHighlight && <FeedbackHighlight dismissTick={dismissTick} />}
</div>
);
}
```
The lazy file (`FeedbackHighlight.tsx`) is a regular client component — see the reference file for the full structure.
---
## Pillar 3 — Type ownership
A type is defined **once**, by its OWNER, and imported everywhere else. This is non-negotiable.
### Rules
- **Never duplicate a type that already exists.** Even if it's a 4-member union you "happen to know."
- **If the owner doesn't export the type, export it from the owner.** Don't fork a private copy. Add the `export` keyword to the owner's file in the same change.
- **Use `Pick<OwnerType, ...>` to derive narrow subsets** when a wrapper forwards a few of many props.
- **Use `OwnerType["fieldName"]` to extract a single field's type** when you don't need a separate alias.
### Forbidden
```tsx
// ❌ BAD — local fake type duplicating the owner's union
type TapVariant = "glass" | "transparent" | "solid" | "group";
interface MyButtonProps {
variant?: TapVariant;
tooltip?: string | false;
}
```
### Correct
```tsx
// ✅ GOOD — derived from the canonical source
import type { TapButtonProps } from "@ai-matrx/tap-target/buttons";
type MyButtonProps = Pick<TapButtonProps, "variant" | "tooltip">;
```
If the owner doesn't yet export the type, add the `export` to the owner file in the same change. Example:
```tsx
// @ai-matrx/tap-target/buttons — owner
export interface TapButtonProps {
variant?: Variant;
// ...
}
```
---
## Pillar 4 — Opening a dialog from the button
**Use the overlay's typed opener** — `useOpenX()` from
`features/overlays/openers/<overlayId>.tsx` (~210 openers, ~520 call sites).
Never `dispatch(openOverlay(...))` in new or migrated code; the ~115 raw
dispatch sites left are legacy and get migrated when you touch them. The one
owner of this rule is the `overlay-system` skill — read it for callbacks,
declarative controllers, and adding a new overlay.
```tsx
// ❌ Raw dispatch — untyped data, callers must know the id string
dispatch(openOverlay({ overlayId: "feedbackDialog" }));
// ✅ Typed opener — options are type-checked, close() handle returned
import { useOpenFeedbackWindow } from "@/features/overlays/openers/feedbackDialog";
const openFeedback = useOpenFeedbackWindow();
openFeedback();
```
---
## Migration checklist
Copy this checklist when applying the pattern to a new button. Tick each item before declaring the migration complete:
```
- [ ] 1. Identify the canonical TapButton owner type (TapButtonProps from @ai-matrx/tap-target/buttons)
- [ ] 2. Replace the legacy <button> / <IconButton> / className-styled element with a pre-composed TapButton (BugTapButton, PlusTapButton, etc.)
- [ ] 3. Drop ALL className props on the TapButton itself. Use variant= instead.
- [ ] 4. Set ariaLabel; let tooltip auto-derive (or pass explicitly).
- [ ] 5. Resize any matching Suspense fallbacks to flex h-11 w-11.
- [ ] 6. Anchor any badges/dots/pings to top-1.5 right-1.5 (visible inner pill).
- [ ] 7. Identify heavy decoration (extra lucide icons, animations, persistence logic, dedicated useEffects) and move it to a sibling file.
- [ ] 8. Wrap the lazy file in next/dynamic({ ssr: false, loading: () => null }).
- [ ] 9. Add "use client" to the lazy file.
- [ ] 10. Gate the lazy render with cheap redux/state selectors so the chunk doesn't fetch for users who won't see it.
- [ ] 11. Coordinate parent → lazy child via a tick: number prop, never callbacks/refs/context.
- [ ] 12. Move redux actions used only by the decoration into the lazy file.
- [ ] 13. Replace any local type aliases with Pick<OwnerType, ...> or OwnerType["field"] imports. If the owner doesn't export the type, export it from the owner in the same change.
- [ ] 14. Open any dialog through its typed opener (`useOpenX()` — see `overlay-system`); replace any `dispatch(openOverlay(...))` or legacy `openSomethingDialog()` wrapper you touch.
- [ ] 15. Audit ALL consumers (grep for the component name) and update each call site in the same change. Update Suspense fallbacks in those consumers too.
```
---
## Reference implementation
Read these before applying the pattern to a new component:
- `features/feedback/FeedbackButton.tsx` — main chunk: icon + typed opener + gate + dynamic ref + `Pick<>`-derived type.
- `features/feedback/FeedbackHighlight.tsx` — lazy chunk: `PartyPopper` + `X` icons, dismiss button, view-count auto-increment timer, `setModulePreferences` dispatch.
- `@ai-matrx/tap-target/buttons` (package) — owner of `TapButtonProps`. Pre-composed buttons (`BugTapButton`, `PlusTapButton`, etc.) and the `Wrap` variant resolver.
- `@ai-matrx/tap-target` (package) — primitive (`TapTargetButton`, `TapTargetButtonTransparent`, `TapTargetButtonSolid`, `TapTargetButtonForGroup`, `TapTargetButtonGroup`). Don't import directly unless a pre-composed version doesn't exist; add a new pre-composed export instead.
- `components/icons/README.md` — definitive doc on the spacing rule and the `Wrap` helper for adding new pre-composed buttons.
- `app/(dev)/demos/button-demo/page.dev.tsx` — live demo of every variant, group, and AI brand button.
- `features/overlays/openers/` — the typed openers (owner: `overlay-system`).
The migration of `FeedbackButton`'s consumers (`components/layout/new-layout/DesktopLayout.tsx`, `features/public-chat/components/ChatMobileHeader.tsx`, `components/matrx/PublicHeaderFeedback.tsx`) is the canonical example of consumer-side rules in action — read those diffs to see how `className=...` becomes `variant=...` and how Suspense fallbacks are resized.
---
## Anti-patterns
- ❌ Wrapping a TapButton in a `<div className="p-2">` to add space.
- ❌ Passing `className="hover:bg-accent rounded-full transition-colors"` to a TapButton "for theming."
- ❌ Importing `TapTargetButton` directly when a `BugTapButton` / `PlusTapButton` already exists.
- ❌ Re-declaring `type TapVariant = "glass" | "transparent" | ...` instead of importing `TapButtonProps` from the owner.
- ❌ Splitting a 2-line decoration into a lazy chunk just because lazy is "good practice."
- ❌ Forgetting `"use client"` on the lazy file.
- ❌ Using a callback ref, custom event, or context for parent ↔ lazy-child coordination.
- ❌ Opening a dialog with `dispatch(openOverlay(...))` or a legacy `openXDialog()` wrapper in new code — use the typed opener (`useOpenX()`).
- ❌ Inventing a new typed wrapper for an overlay that takes zero or trivial params.
- ❌ Leaving Suspense fallbacks at the old button's dimensions after the underlying button grows to 44×44.
- ❌ Updating only the focused file's call site and forgetting the other consumers.
- ❌ Forking a private type from the owner instead of `export`-ing it from the owner.