Explain installation, styles, exports, and validation. Record the component wrapper design and implementation plan.
19 KiB
Base UI Shadcn Wrapper Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a source-only internal components/ui library that wraps all Base UI React components with Tailwind styles and supports both Base UI-style default namespace imports and shadcn-style named imports.
Architecture: Each UI component is a folder. Base UI parts are split into focused files such as root.tsx, trigger.tsx, and popup.tsx; index.ts assembles the default namespace export and named shadcn-style exports. Shared class merging lives in lib/utils.ts, and styles are static Tailwind class strings adapted from the Base UI documentation examples.
Tech Stack: React, TypeScript, @base-ui/react, Tailwind CSS, clsx, tailwind-merge, class-variance-authority.
File Map
- Create
package.json: local validation scripts and dependencies only. - Create
tsconfig.json: framework-neutral React TypeScript settings. - Create
components/ui/**: source templates, one folder per Base UI component. - Create
lib/utils.ts:cn()helper used by every styled part. - Create
examples/smoke.tsx: compile-only examples for default namespace and named shadcn-style APIs. - Create
scripts/check-ui-exports.mjs: verifies every planned component folder exists and exports a default. - Create
README.md: usage, dependency, import, and customization guidance.
Component Folders
Create these component folders under components/ui:
accordion
alert-dialog
autocomplete
avatar
button
checkbox
checkbox-group
collapsible
combobox
context-menu
dialog
drawer
field
fieldset
form
input
menu
menubar
meter
navigation-menu
number-field
otp-field
popover
preview-card
progress
radio
scroll-area
select
separator
slider
switch
tabs
toast
toggle
toggle-group
toolbar
tooltip
Task 1: Project Skeleton
Files:
-
Create:
package.json -
Create:
tsconfig.json -
Create:
lib/utils.ts -
Create:
components/ui/.gitkeep -
Step 1: Create
package.json
{
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"check:exports": "node scripts/check-ui-exports.mjs",
"check": "npm run typecheck && npm run check:exports"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.8.0"
}
}
- Step 2: Create
tsconfig.json
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"declaration": false,
"esModuleInterop": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"paths": {
"@/*": ["./*"]
},
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
},
"include": ["components", "examples", "lib", "scripts"]
}
- Step 3: Create
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
- Step 4: Install dependencies
Run: npm install
Expected: package-lock.json and node_modules are created.
- Step 5: Run typecheck
Run: npm run typecheck
Expected: TypeScript exits successfully, or fails only because component files are not present yet.
Task 2: Export Checker
Files:
-
Create:
scripts/check-ui-exports.mjs -
Step 1: Create
scripts/check-ui-exports.mjs
import { access, readFile } from "node:fs/promises"
import path from "node:path"
const components = [
"accordion",
"alert-dialog",
"autocomplete",
"avatar",
"button",
"checkbox",
"checkbox-group",
"collapsible",
"combobox",
"context-menu",
"dialog",
"drawer",
"field",
"fieldset",
"form",
"input",
"menu",
"menubar",
"meter",
"navigation-menu",
"number-field",
"otp-field",
"popover",
"preview-card",
"progress",
"radio",
"scroll-area",
"select",
"separator",
"slider",
"switch",
"tabs",
"toast",
"toggle",
"toggle-group",
"toolbar",
"tooltip",
]
const failures = []
for (const component of components) {
const indexPath = path.join("components", "ui", component, "index.ts")
try {
await access(indexPath)
const source = await readFile(indexPath, "utf8")
if (!source.includes("export default")) {
failures.push(`${indexPath}: missing default export`)
}
} catch {
failures.push(`${indexPath}: missing file`)
}
}
if (failures.length > 0) {
console.error(failures.join("\n"))
process.exit(1)
}
console.log(`Checked ${components.length} component exports.`)
- Step 2: Run checker and verify it fails before components exist
Run: npm run check:exports
Expected: FAIL with missing components/ui/*/index.ts files.
Task 3: Implement Foundation Components
Files:
-
Create:
components/ui/button/root.tsx -
Create:
components/ui/button/index.ts -
Create:
components/ui/input/root.tsx -
Create:
components/ui/input/index.ts -
Create:
components/ui/separator/root.tsx -
Create:
components/ui/separator/index.ts -
Step 1: Implement Button
components/ui/button/root.tsx:
"use client"
import * as React from "react"
import { Button as BaseButton } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex h-10 items-center justify-center rounded-md border border-neutral-950 bg-neutral-950 px-4 text-sm font-medium text-white select-none hover:bg-neutral-800 active:bg-neutral-700 data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:border-white dark:bg-white dark:text-neutral-950 dark:hover:bg-neutral-200 dark:active:bg-neutral-300 dark:focus-visible:outline-white",
{
variants: {
variant: {
default: "",
outline:
"border-neutral-300 bg-transparent text-neutral-950 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-900 dark:active:bg-neutral-800",
ghost:
"border-transparent bg-transparent text-neutral-950 hover:bg-neutral-100 active:bg-neutral-200 dark:text-white dark:hover:bg-neutral-900 dark:active:bg-neutral-800",
destructive:
"border-red-600 bg-red-600 text-white hover:bg-red-700 active:bg-red-800 focus-visible:outline-red-600 dark:border-red-500 dark:bg-red-500 dark:hover:bg-red-600 dark:active:bg-red-700 dark:focus-visible:outline-red-500",
},
size: {
sm: "h-8 px-3 text-sm",
default: "h-10 px-4",
lg: "h-11 px-5",
icon: "size-10 px-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonProps = React.ComponentPropsWithoutRef<typeof BaseButton> &
VariantProps<typeof buttonVariants>
export const Button = React.forwardRef<
React.ElementRef<typeof BaseButton>,
ButtonProps
>(({ className, variant, size, ...props }, ref) => (
<BaseButton
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
))
Button.displayName = "Button"
components/ui/button/index.ts:
export { Button, type ButtonProps } from "./root"
export { Button as default } from "./root"
- Step 2: Implement Input
components/ui/input/root.tsx:
"use client"
import * as React from "react"
import { Input as BaseInput } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
export type InputProps = React.ComponentPropsWithoutRef<typeof BaseInput>
export const Input = React.forwardRef<
React.ElementRef<typeof BaseInput>,
InputProps
>(({ className, ...props }, ref) => (
<BaseInput
ref={ref}
className={cn(
"h-10 w-full rounded-md border border-neutral-300 bg-transparent px-3 text-base text-neutral-950 outline-none placeholder:text-neutral-500 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:text-white dark:placeholder:text-neutral-400 dark:focus-visible:outline-white",
className,
)}
{...props}
/>
))
Input.displayName = "Input"
components/ui/input/index.ts:
export { Input, type InputProps } from "./root"
export { Input as default } from "./root"
- Step 3: Implement Separator
components/ui/separator/root.tsx:
"use client"
import * as React from "react"
import { Separator as BaseSeparator } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
export type SeparatorProps = React.ComponentPropsWithoutRef<
typeof BaseSeparator
>
export const Separator = React.forwardRef<
React.ElementRef<typeof BaseSeparator>,
SeparatorProps
>(({ className, orientation = "horizontal", ...props }, ref) => (
<BaseSeparator
ref={ref}
orientation={orientation}
className={cn(
"shrink-0 bg-neutral-200 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px dark:bg-neutral-800",
className,
)}
{...props}
/>
))
Separator.displayName = "Separator"
components/ui/separator/index.ts:
export { Separator, type SeparatorProps } from "./root"
export { Separator as default } from "./root"
- Step 4: Run typecheck
Run: npm run typecheck
Expected: PASS for the files added in this task.
Task 4: Implement Dialog Pattern
Files:
-
Create:
components/ui/dialog/root.tsx -
Create:
components/ui/dialog/trigger.tsx -
Create:
components/ui/dialog/portal.tsx -
Create:
components/ui/dialog/backdrop.tsx -
Create:
components/ui/dialog/viewport.tsx -
Create:
components/ui/dialog/popup.tsx -
Create:
components/ui/dialog/title.tsx -
Create:
components/ui/dialog/description.tsx -
Create:
components/ui/dialog/close.tsx -
Create:
components/ui/dialog/content.tsx -
Create:
components/ui/dialog/index.ts -
Step 1: Create styled primitive files
Use @base-ui/react/dialog parts. Keep the class model from the Base UI dialog examples: neutral trigger button, fixed translucent backdrop, centered popup, compact title and muted description. content.tsx composes Portal, Backdrop, and Popup.
- Step 2: Create namespace and named exports
index.ts must default export:
const DialogPrimitive = {
Root: DialogRoot,
Trigger: DialogTrigger,
Portal: DialogPortal,
Backdrop: DialogBackdrop,
Viewport: DialogViewport,
Popup: DialogPopup,
Title: DialogTitle,
Description: DialogDescription,
Close: DialogClose,
}
It must also named export Dialog, DialogTrigger, DialogPortal, DialogBackdrop, DialogViewport, DialogPopup, DialogContent, DialogTitle, DialogDescription, and DialogClose.
- Step 3: Run typecheck
Run: npm run typecheck
Expected: PASS.
Task 5: Implement Dialog-Like Overlay Components
Files:
-
Create folders and part files for
alert-dialog,drawer,popover,preview-card, andtooltip. -
Step 1: Implement Alert Dialog
Mirror the dialog folder pattern with @base-ui/react/alert-dialog. Include styled parts for Root, Trigger, Portal, Backdrop, Viewport if present, Popup, Title, Description, and Close. Include shadcn-style AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle, AlertDialogDescription, and AlertDialogClose.
- Step 2: Implement Drawer
Mirror Base UI Drawer anatomy. Split each part into a file. Preserve snap/gesture props by extending Base UI part props. Use Tailwind classes from Base UI drawer examples for backdrop, popup, handle, title, and description.
- Step 3: Implement Popover and Preview Card
Split Root, Trigger, Portal, Positioner, Popup, Arrow, and close/action parts where documented. Include shadcn-style PopoverContent and PreviewCardContent convenience components.
- Step 4: Implement Tooltip
Split Provider if documented, Root, Trigger, Portal, Positioner, Popup, and Arrow. Include TooltipContent as the shadcn-style convenience component.
- Step 5: Run typecheck
Run: npm run typecheck
Expected: PASS.
Task 6: Implement Selection and Menu Components
Files:
-
Create folders and part files for
select,combobox,autocomplete,menu,menubar,context-menu, andnavigation-menu. -
Step 1: Implement Select
Split every documented Base UI Select part into files. Include styled trigger, value, icon, portal, positioner, popup, item, item indicator, item text, group, label, separator, and scroll buttons where documented. Add shadcn-style named exports such as Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel, and SelectSeparator.
- Step 2: Implement Combobox and Autocomplete
Split root, input, trigger, portal, positioner, popup, list, item, item text, item indicator, status, empty, and group/label parts where documented. Use Base UI example classes for popup, list, and highlighted item states.
- Step 3: Implement Menu, Menubar, and Context Menu
Use the same menu part style vocabulary across the three components: trigger buttons, popups, items, checkbox/radio items, separators, labels, groups, submenus, arrows, and positioners. Preserve all Base UI part props and shadcn-style convenience exports.
- Step 4: Implement Navigation Menu
Split root, list, item, trigger, content, viewport, positioner, link, indicator, and portal parts where documented. Keep layout classes static and allow className overrides on every part.
- Step 5: Run typecheck
Run: npm run typecheck
Expected: PASS.
Task 7: Implement Form and Field Components
Files:
-
Create folders and part files for
form,field,fieldset,checkbox,checkbox-group,radio,switch,number-field,otp-field, andslider. -
Step 1: Implement Field, Fieldset, and Form
Split root/control/label/description/error/validity parts according to Base UI docs. Use compact label, muted description, and red error text classes from the examples.
- Step 2: Implement Checkbox and Checkbox Group
Split root, indicator, group, label, description, and error parts where documented. Include shadcn-style Checkbox with indicator composed inside when appropriate, plus primitive namespace exports.
- Step 3: Implement Radio and Switch
Split radio root/indicator/group parts and switch root/thumb parts. Use Base UI data attribute styling for checked, unchecked, and disabled states.
- Step 4: Implement Number Field, OTP Field, and Slider
Split increment/decrement buttons, input, scrubbing area, group, track, thumb, indicator, and item slots according to each anatomy page. Preserve CSS variable driven styles for slider and number-field controls.
- Step 5: Run typecheck
Run: npm run typecheck
Expected: PASS.
Task 8: Implement Disclosure, Navigation, and Feedback Components
Files:
-
Create folders and part files for
accordion,collapsible,tabs,toolbar,toggle,toggle-group,toast,progress,meter,avatar, andscroll-area. -
Step 1: Implement Accordion and Collapsible
Split root, item, header, trigger, panel/content parts. Use Base UI example classes for border grouping, trigger focus, and panel transition states.
- Step 2: Implement Tabs and Toolbar
Split list, tab, panel, indicator, root, group, button, link, and separator parts where documented. Use data selected/orientation selectors for visual states.
- Step 3: Implement Toggle and Toggle Group
Use class-variance-authority for variant and size on single toggle controls, preserving the Base UI example as default.
- Step 4: Implement Toast
Split provider, viewport, root, title, description, close, action, and portal parts where documented. Preserve swipe/animation data attributes and static Tailwind classes.
- Step 5: Implement Progress, Meter, Avatar, and Scroll Area
Split root, indicator, image, fallback, viewport, scrollbar, thumb, and corner parts where documented. Use Base UI CSS variables for progress/meter values and scroll area sizing.
- Step 6: Run typecheck
Run: npm run typecheck
Expected: PASS.
Task 9: Smoke Examples and Export Verification
Files:
-
Create:
examples/smoke.tsx -
Modify:
scripts/check-ui-exports.mjsif any official component folder name changed during implementation. -
Step 1: Create
examples/smoke.tsx
import Dialog, {
Dialog as ShadcnDialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import Button, { Button as UIButton } from "@/components/ui/button"
export function Smoke() {
return (
<div>
<Button focusableWhenDisabled>Primitive-style button</Button>
<UIButton variant="outline" size="sm">
Named button
</UIButton>
<Dialog.Root>
<Dialog.Trigger>Primitive dialog</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>Primitive dialog</Dialog.Title>
<Dialog.Description>Default namespace API.</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
<ShadcnDialog>
<DialogTrigger>Named dialog</DialogTrigger>
<DialogContent>
<DialogTitle>Named dialog</DialogTitle>
</DialogContent>
</ShadcnDialog>
</div>
)
}
- Step 2: Run export checker
Run: npm run check:exports
Expected: Checked 37 component exports.
- Step 3: Run full check
Run: npm run check
Expected: TypeScript and export checks pass.
Task 10: README
Files:
-
Create:
README.md -
Step 1: Create README
Include:
# Base UI Tailwind Components
Source-owned Base UI React wrappers styled with Tailwind CSS.
## Install Dependencies
\`\`\`bash
npm install @base-ui/react clsx tailwind-merge class-variance-authority
\`\`\`
## Import Styles
Default imports expose styled Base UI part namespaces:
\`\`\`tsx
import Dialog from "@/components/ui/dialog"
\`\`\`
Named imports expose shadcn-style components:
\`\`\`tsx
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
\`\`\`
Every styled part accepts \`className\`, and custom classes are merged after defaults.
- Step 2: Run full check
Run: npm run check
Expected: PASS.
Self-Review Checklist
- Every component in the spec has a folder in
components/ui. - Every folder has
index.ts. - Every
index.tshas a default export. - Composite components split Base UI parts into separate files.
- Named exports support shadcn-style usage.
examples/smoke.tsxcompiles.- README explains default and named imports.