Contribution
Contribute to shuip to make it better.
Setup
Clone the repository
git clone https://github.com/plvo/shuip.git
cd shuipInstall dependencies
bun installCreate a branch for your changes
git checkout -b feat/my-componentStart the development server
bun devRepository layout
shuip is a Bun + turborepo monorepo. Items are published from packages/registry/items/ through a generator script.
apps/docs/ # Next.js docs site (registry endpoints, MDX content)
packages/ui/ # shadcn primitives + base.css
packages/registry/ # SOURCE OF TRUTH for published items
items/<category>/<name>/ # One folder per item — see "Add an item" below
scripts/generate.ts # Scans items/, emits all downstream artifacts
packages/config/ # Shared tsconfig presetsThe generator emits registry.json, registry.base.json, __index__.ts, the stubs, the MDX symlinks, and the Base UI copies of the component, block, form and query items under packages/registry/generated/base/ (lib items such as lib/time have no Base UI copy). bun registry:build then writes apps/docs/public/r/<variant>/*.json, one folder per base: radix/ and base/. registry.json and registry.base.json are committed, the rest is git-ignored. None of them is ever edited by hand; they are regenerated from items/, and CI fails when the committed registry files differ from a fresh bun registry:generate.
Add an item
Categories
Each item lives at packages/registry/items/<category>/<name>/. Pick the category that matches what you're building:
| Category | Prefix | Used for |
|---|---|---|
components | (none) | Standalone UI components |
blocks | (none, registry:block) | Larger composition components |
react-hook-form | rhf- | Fields integrated with react-hook-form |
tanstack-form | tsf- | Fields integrated with @tanstack/react-form |
tanstack-query | tsq- | Components integrated with @tanstack/react-query |
The folder name is unprefixed — the generator adds the prefix from the CATEGORIES map in packages/registry/scripts/generate.ts. Naming a folder rhf-my-field publishes as rhf-rhf-my-field.
File structure
packages/registry/items/<category>/<name>/
├── component.radix.tsx # REQUIRED, exact filename. The canonical (Radix) published source.
├── component.base.tsx # Optional Base UI override of component.radix.tsx — see "Base UI and Radix"
├── default.example.radix.tsx # Primary preview (recommended)
├── <variant>.example.radix.tsx # Additional previews (recommended: at least one)
├── <variant>.example.base.tsx # Optional Base UI override of <variant>.example.radix.tsx
├── index.mdx # Doc page (skip for blocks — see below)
├── extras/ # Optional. Files copied alongside on install:
│ ├── <file>.action.ts # → installs at ./actions/shuip/<file>.ts
│ └── <file>.<ext> # → installs at ./components/ui/shuip/<file>.<ext>
└── meta.shuip.json # Optional. { "dependsOn": ["<other-shuip-item>"], "title": "…", "description": "…" }Detailed steps
Create the folder packages/registry/items/<category>/<name>/ (unprefixed name).
Write component.radix.tsx. Import shadcn primitives via @/components/ui/<name> (these become registryDependencies automatically). Don't import @/components/ui/shuip/* unless your item composes another shuip item — in that case, also declare it in meta.shuip.json with dependsOn.
meta.shuip.json also takes the registry fields a scan cannot infer: title, description, author, categories, docs, meta, cssVars, css and envVars. They are merged into the item of both registries and published with it. The fields the generator derives (name, type, dependencies, registryDependencies, files, target) cannot be set there: the generator fails on them.
Add 'use client' at the top only when the component uses client-only React features (state, refs, effects, browser APIs).
Add default.example.radix.tsx. Import your component via the stub alias, not via relative ./component.radix:
import { MyComponent } from '@/components/ui/shuip/<category-subdir>/<name>';The <category-subdir> segment is:
components/items → no subdir:@/components/ui/shuip/<name>react-hook-form/items →@/components/ui/shuip/react-hook-form/<name>tanstack-form/items →@/components/ui/shuip/tanstack-form/<name>tanstack-query/items →@/components/ui/shuip/tanstack-query/<name>blocks/items →@/components/block/shuip/<name>(note:block, notui)
Add at least one variant example (<variant>.example.radix.tsx) showing an alternate use-case. Doc pages with a single preview look thin.
Write index.mdx — for components, react-hook-form, tanstack-form, tanstack-query items.
---
title: My Component
description: One-line description for SEO and the sidebar.
registryName: <prefixed-name>
---
Prose explaining what this item does and when to use it.
import { TypeTable } from 'fumadocs-ui/components/type-table';
## Examples
<ItemExamples registryName={'<prefixed-name>'} />
## Props
<TypeTable
type={{
label: {
description: 'The label of the component',
type: 'string?',
},
}}
/><ItemExamples> and <ItemHeader> are registered globally (no import needed). <TypeTable> is from fumadocs-ui and must be imported inline as shown.
Blocks are different — do not put index.mdx in items/blocks/<name>/. Write the doc as a real MDX file at apps/docs/content/blocks/<name>.mdx instead. Blocks live in a separate fumadocs collection (apps/docs/source.config.ts).
Regenerate the registry:
bun registry:generateConfirm the output shows [generate] N items processed with N incremented by one. If you see [generate] skipping <cat>/<name>: no component.radix.tsx, the filename is wrong (must be exactly component.radix.tsx).
Check the Base UI variant. The generator derives it from your Radix source, so read its warnings and follow Base UI and Radix below.
End-to-end check:
bun build:docsThis chains registry:generate → registry:build → next build. If it passes, the item is installable via npx shadcn@latest add @shuip/<prefixed-name> (with the @shuip namespace configured, see Installation).
Base UI and Radix
Every component, block, form and query item is published for two bases. A consumer gets the variant that matches the style in their components.json (see Installation). You write the item once, for Radix, and the generator derives the Base UI variant.
In short, the model has three parts: the canonical Radix file is the source, the codemod produces the Base UI copy of it, and an override replaces that copy for one file. The copies under packages/registry/generated/base/ are build output, so a change to a Base UI variant is always made in the canonical file or in its override.
Canonical naming
component.radix.tsx and <variant>.example.radix.tsx are the canonical sources, written for Radix. The .radix suffix is part of the contract: a file named component.tsx, or an example without a recognised base suffix, makes the generator fail.
For each canonical file the generator produces the Base UI copy with a codemod. It turns <X asChild><Child /></X> into <X render={<Child />}>…</X> and adds nativeButton={false} when the rendered element is not a button. It also rebuilds a popover anchored to an element other than its trigger (PopoverAnchor asChild around an element with a ref, then PopoverContent) from the Base UI Portal, Positioner and Popup. Run bun registry:generate and read its warnings: it reports an asChild it cannot convert (for example one that wraps more than a single element), a link rendered with nativeButton={false}, and an anchored popover it leaves as it is (its Popover does not hold exactly one PopoverAnchor and one PopoverContent, the anchor is not an asChild around a single element, the anchored element has no ref={name} or ref={object.name}, the PopoverContent has a Radix-only prop such as onEscapeKeyDown, or it has a focus handler other than (e) => e.preventDefault()).
When to write a .base.tsx override
Write an override only where the two APIs genuinely diverge and the codemod cannot bridge them. A behaviour that differs between the two bases, such as the event and reason that onOpenChange of a Base UI popover receives, is the typical case. If the codemod output is correct, leave it alone: every override is a second file to keep in step with the canonical one.
To write one:
Copy component.radix.tsx to component.base.tsx in the same folder (or <variant>.example.radix.tsx to <variant>.example.base.tsx) and change only what diverges.
Write it exactly as consumers receive it, with the standard @/components/ui/* aliases. For the override, those aliases resolve to the Base UI primitives.
Run bun typecheck:base. It regenerates, then type-checks every override and every generated Base UI copy against the Base UI primitives.
An override wins over the codemod and is published verbatim. On every bun registry:generate, the generator warns about each override whose canonical file has newer commits (<override> predates the latest commits of <canonical>). The warning does not fail the build: update the override to follow the canonical change, or commit it unchanged once you have checked that it still holds.
Derive props from the primitives
Type a prop from the primitive it is forwarded to instead of re-declaring it:
export interface MyButtonProps extends React.ComponentProps<typeof Button> {
loading?: boolean;
}React.ComponentProps<typeof X> follows whichever primitive @/components/ui/x resolves to, so the same source type-checks against the Radix primitive and against the Base UI one. A hand-written copy of the primitive's props, such as an asChild?: boolean you re-declare, only matches one of them. Declare only the props your item adds.
Type-check both bases
CI type-checks the registry once per base, so run both before you push:
bun typecheck:radix
bun typecheck:basebun typecheck:radix regenerates, then type-checks every component.radix.tsx and *.example.radix.tsx against the Radix primitives. It is the only check that reaches a canonical file that has a Base UI override, since the Base UI copy of that file is the override.
Check for unused code and undeclared imports
CI runs knip once the tests have passed, and fails a PR that adds an unused file, an unused export or dependency, or an import that the workspace's package.json does not declare. Run it before you push, after bun registry:generate, which produces the stubs/ and generated/ files it reads:
bun check:unusedIt checks apps/docs and the code of packages/ui and packages/registry that is not published: packages/ui/src/lib, packages/registry/src and packages/registry/scripts. It does not check the exports of the registry items (packages/registry/items/**), which are the API a consumer imports, nor those of the vendored primitives (packages/ui/src/components/ui/** and ui-base/**), which are synced and never hand-edited. An unused file inside those folders is not reported either.
Declare each package in the package.json of the workspace that imports it. An undeclared import can resolve locally because packages are hoisted into the root node_modules, and knip is what catches it.
Run the smoke test for both bases
The smoke test installs items into a fresh shadcn project and type-checks the result, so it checks what a consumer receives:
bun smoke radix-vega --items <prefixed-name>
bun smoke base-vega --items <prefixed-name><prefixed-name> is the published name of the item: the folder name with its category prefix (rhf-, tsf- or tsq-; components and blocks have none), for example rhf-input-field. A base-* style is served the Base UI variant, and the test then fails on any Radix package. Run it for the items you touched on both bases, and without --items before a larger change.
Base-specific prose
A doc page exists once per base, but both render the same MDX. Wrap prose or snippets that only hold for one base in a BaseOnly block, with a blank line after the opening tag and before the closing one:
<BaseOnly base="radix">
```tsx
<SideDialogTrigger asChild>
<Button>Open</Button>
</SideDialogTrigger>
```
</BaseOnly>
<BaseOnly base="base">
```tsx
<SideDialogTrigger render={<Button />}>Open</SideDialogTrigger>
```
</BaseOnly>A test fails when a Radix-only API such as asChild appears outside a Radix block, and the markdown served for models keeps only the block of the page's base.
Coding conventions
- Biome is the only linter/formatter. Single quotes, 2-space indent, 120-col, trailing commas.
bun checkruns Biome with auto-fix. Pre-commit hook runsbiome check --write --unsafevia lint-staged. - TypeScript 6. Use
import typefor type-only imports. - Shared external deps are versioned via Bun catalogs (
workspaces.catalogandworkspaces.catalogs.{fumadocs,radix,forms}in rootpackage.json). Reference as"<pkg>": "catalog:"or"catalog:<name>"— never pin a literal version in a workspace'spackage.json. - Conventional commits. Format:
<type>(<scope>): <subject>. Examples:feat(registry): add rhf-phone-field,fix(theme-button): add 'use client',chore(dx): bump deps.
Submit your contribution
Run bun registry:generate, then stage the item folder, for a block its page at apps/docs/content/blocks/<name>.mdx, and the regenerated packages/registry/registry.json and registry.base.json. The other generated files are git-ignored.
git add packages/registry/items/<category>/<name> packages/registry/registry.json packages/registry/registry.base.json
git commit -m "feat(registry): add my-component"
git push origin feat/my-componentThen open a Pull Request on github.com/plvo/shuip.
Thank you for your contribution to shuip! 🚀