Files
netto/docs/superpowers/plans/2026-07-05-base-ui-shadcn-wrapper.md
T
Maofeng 08e293d5f7 docs: document architecture and usage
Explain installation, styles, exports, and validation.

Record the component wrapper design and implementation plan.
2026-07-25 06:04:19 +08:00

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, and tooltip.

  • 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, and navigation-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, and slider.

  • 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, and scroll-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.mjs if 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.ts has a default export.
  • Composite components split Base UI parts into separate files.
  • Named exports support shadcn-style usage.
  • examples/smoke.tsx compiles.
  • README explains default and named imports.