# Rapport, a contacts app on shadcn/ui and Base UI: six traps from the shadcn CLI to phone numbers

> What we learned building Rapport, a contacts app and light CRM on shadcn/ui with Base UI: six traps in the shadcn CLI, Base UI, Intl.Collator, libphonenumber and avatar loading.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Rapport — Contacts App & CRM for React, shadcn/ui & Tailwind (Address Book UI)](https://upfork.dev/products/shadcn-contacts-app-crm-react) (Component, comes with the Pro plan). Published 2026-10-26. 5 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Rapport is a contacts manager and light CRM for React on shadcn/ui, built with the Base UI primitives: folders and labels, search with highlights, filters, list, grid and table views, an A–Z rail, contact details with activity and notes, validated forms with photo cropping, bulk actions with Undo, CSV and vCard import and export, keyboard shortcuts, phone and tablet layouts, light and dark.

[Live preview of Rapport — Contacts App & CRM for React, shadcn/ui & Tailwind (Address Book UI)](https://upfork.dev/products/shadcn-contacts-app-crm-react)

This article is for anyone, person or AI, building a data-heavy app on shadcn/ui, especially with Base UI underneath: six traps we hit, from the shadcn CLI that installs the component to a phone number that turned a Boston contact Canadian, each with its fix.

## Installing: names the shadcn CLI resolves for you

Before a release goes out, an install check installs the component with the shadcn CLI into a fresh Next.js project, the way a buyer's project gets it.

> **Lesson: The shadcn CLI resolves @/lib/contacts by file name and prefers .tsx: it picked components/contacts/contacts.tsx** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **I assumed:** an import of `@/lib/contacts` would always reach the file `lib/contacts.ts`, as it does in the project it was written in.
> **What happened:** the shadcn CLI matches the files it installs by name, and ranks `.tsx` before `.ts`. In a buyer's project, `@/lib/contacts` pointed at the app's own `components/contacts/contacts.tsx`, and the install check failed seven times before the cause was clear.
> **The fix:** the shared people file got a name of its own, `lib/people.ts`, that no component file shares. The real install check (shadcn CLI 4.21, a fresh Next.js project) then passed on all 48 files, and UpFork now refuses such an import when a listing is saved.
> **Evidence:** commit 92f82a9.

## Base UI under shadcn/ui

Renaming a label happens in a popover that opens from the label's menu, not from a button of its own. The popover still needs an anchor, so its trigger is a 1 px invisible span at the row's edge:

`components/contacts/sidebar.tsx`, lines 189–190:

```tsx
			<Popover open={renaming} onOpenChange={setRenaming}>
				<PopoverTrigger nativeButton={false} render={<span aria-hidden tabIndex={-1} className="absolute inset-y-0 right-0 w-px" />} />
```

> **Lesson: A Base UI trigger rendered as anything but a button needs nativeButton set to false** (Not in the docs; learned by the AI (Claude Opus 5.5); @base-ui/react@1.9)
> **I assumed:** `PopoverTrigger` with `render={<span … />}` would simply render the span, as Radix's `asChild` would.
> **What happened:** Base UI triggers expect the element they render to be a native `<button>` unless told otherwise, and handle it as one.
> **The fix:** `nativeButton={false}` on any trigger whose `render` isn't a `<button>`, so Base UI knows the element isn't a native button.
> **Evidence:** sidebar.tsx line 190.

Bulk selection uses "select all" checkboxes that are checked, unchecked, or partly checked when only some rows are picked.

> **Lesson: shadcn's Base UI Checkbox has no indeterminate icon: a partly checked box showed a full tick** (Not in the docs; learned by the AI (Claude Opus 5.5); @base-ui/react@1.9)
> **I assumed:** passing `indeterminate` to the shadcn Checkbox was enough to show a partly checked box.
> **What happened:** the Base UI primitive tracks the state and marks it with `data-indeterminate`, but the shadcn component's indicator only holds a check icon, so a box with some rows picked looked the same as one with all of them.
> **The fix:** the indicator holds both icons and shows one by state, with Tailwind's `in-data-indeterminate:` variant: the check hides and a minus appears.
> **Evidence:** ui/checkbox.tsx lines 19–25; the `indeterminate` props in contact-table.tsx and toolbar.tsx.

`components/contacts/ui/checkbox.tsx`, lines 19–25:

```tsx
			<CheckboxPrimitive.Indicator
				data-slot="checkbox-indicator"
				className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
			>
				<CheckIcon className="in-data-indeterminate:hidden" />
				<MinusIcon className="hidden in-data-indeterminate:block" />
			</CheckboxPrimitive.Indicator>
```

## Sorting and phone numbers

> **Lesson: Sorting empty values last with a ~ placeholder fails with Intl.Collator: punctuation sorts before letters** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** giving contacts without a city a placeholder of `"~"`, which comes after the letters in ASCII, would sort them to the end.
> **What happened:** `Intl.Collator` compares by language rules, not by character codes, and it puts punctuation before letters, so contacts without a city came first.
> **The fix:** compare emptiness first, explicitly: a contact without a city goes after one with a city whichever way the list runs, and only two cities are compared with the collator.
> **Evidence:** logic.ts lines 140–141.

`components/contacts/logic.ts`, lines 131–141:

```ts
const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true })

export function sortContacts(list: Contact[], sort: Sort) {
	const sign = sort.dir === "asc" ? 1 : -1
	const byName = (a: Contact, b: Contact) => collator.compare(fullName(a), fullName(b))
	const compare: Record<Sort["field"], (a: Contact, b: Contact) => number> = {
		name: (a, b) => sign * byName(a, b),
		// "Ascending" for dates reads as newest first, like every inbox.
		added: (a, b) => sign * (b.addedAt - a.addedAt) || byName(a, b),
		// Contacts without a city go last, whichever way the list runs.
		city: (a, b) => (!a.city !== !b.city ? (a.city ? -1 : 1) : sign * collator.compare(a.city, b.city)) || byName(a, b),
```

The sample contacts' phone numbers are invented, and most use the 555-01xx numbers reserved for fiction. Two didn't.

> **Lesson: A made-up +1 number can land on a real Canadian area code: libphonenumber then says Canada for a Boston contact** (My own mistake; learned by the AI (Claude Opus 5.5); libphonenumber-js)
> **I assumed:** any +1 number would come out as a US number, so the form could take its country from the parsed number.
> **What happened:** +1 is shared by the US and Canada, and libphonenumber picks between them by area code. A sample number with area code 753, a Canadian one, made the form show Canada for a contact who lives in Boston.
> **The fix:** for +1 numbers the form chooses Canada only when the parsed number says Canada and the contact's city is a Canadian one; otherwise it shows the US.
> **Evidence:** contact-form.tsx lines 27 and 56–57; the sample contact in data.ts.

`components/contacts/contact-form.tsx`, lines 53–57:

```tsx
function toValues(c?: Contact, defaults?: Partial<ContactDraft>): Values {
	const src = { ...c, ...defaults }
	const parsed = src.phone ? parsePhoneNumberFromString(src.phone) : undefined
	// +1 is shared by the US and Canada: Canada only when the city says so (753 is a Canadian area code, Boston isn't).
	const country = parsed?.countryCallingCode === "1" ? (parsed.country === "CA" && CANADIAN.test(src.city ?? "") ? "CA" : "US") : ((parsed?.country as string) ?? "US")
```

## Photos without a flash of initials

Every contact shows a photo, or their initials when there isn't one.

> **Lesson: Avatars that load as rows mount show initials first: decode the photos once, before the list appears** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** an avatar that shows initials until its image arrives was a fine way to show photos in a list.
> **What happened:** the rows mount again when you switch folders, and each new avatar showed its initials for a moment before the photo, so switching folders flickered.
> **The fix:** the photos are fetched and decoded once, all together, and the app waits for them, for at most 2.5 seconds, before it shows the list. After that the avatars are plain `<img>` elements whose photos are already decoded, so a row paints its photo at once.
> **Evidence:** hooks.ts lines 47–69.

`components/contacts/hooks.ts`, lines 47–69:

```ts
/** Fetches and decodes the photos once, so switching folders never flashes initials. Gives up after `timeout`. */
export function usePreloadedPhotos(urls: string[], timeout = 2500) {
	const [done, setDone] = React.useState(false)
	const key = urls.join("|")
	React.useEffect(() => {
		let alive = true
		const list = key ? key.split("|").filter((u) => u && !u.startsWith("data:")) : []
		const loads = list.map(
			(src) =>
				new Promise<void>((resolve) => {
					const img = new Image()
					img.decoding = "async"
					img.src = src
					img.decode().then(resolve, resolve)
				})
		)
		Promise.race([Promise.all(loads), new Promise((r) => setTimeout(r, timeout))]).then(() => alive && setDone(true))
		return () => {
			alive = false
		}
	}, [key, timeout])
	return done
}
```

## Checked

15 unit tests; 82 click-throughs on the production build, all passing; axe finds no violations on 11 screens in light and dark; 120 screenshots (12 views, two themes, five widths). The real install check, with the shadcn CLI in a fresh Next.js project, passes on all 48 files.

## Make it yours

`<Contacts />` takes your `contacts` and `labels`, a `defaultView` and a `defaultContactId` to open on, `syncUrl` to keep the view and the open contact in the address, and `onContactsChange`, called after every change so you can save them. By default Email, Message and Schedule open `mailto:`, `sms:` and an `.ics` file; the `links` prop sends them to your own mail, chat and calendar instead.

```bash
npx shadcn@latest add @upfork/shadcn-contacts-app-crm-react
```

---

On UpFork: https://upfork.dev/articles/rapport-a-contacts-app-on-shadcn-ui-and-base-ui-six-traps-from-the-shadcn-cl
