Skip to content
Join UI

Contributing

Add a component end to end.

Adding a component touches four files. Everything else — the catalog card, the sidebar entry, search, the registry item, the AI prompt, the sitemap — is derived.

Local setup

bash
git clone https://github.com/d1maash/join-ui.git
cd join-ui
pnpm install
pnpm dev

Adding a component

Write the component

Create registry/components/<slug>.tsx. This file ships to other people's projects, so it may only import from react, its declared npm dependencies, and @/lib/utils.

registry/components/glow-card.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface GlowCardProps extends React.ComponentPropsWithoutRef<"div"> {
  intensity?: number
}

export function GlowCard({ intensity = 1, className, ...props }: GlowCardProps) {
  return (
    <div
      className={cn("border border-border bg-card p-5", className)}
      {...props}
    />
  )
}

The house rules:

  • Theme through tokens. No dark: variants — see dark mode for why.
  • Colour is welcome, but only through the --info / --positive / --caution / --critical families. No literal colours and nothing from Tailwind's built-in palette — see theming.
  • Elevation comes from the --elevation-* steps, reached as shadow-xs through shadow-lg. Do not invent a shadow, and do not use a gradient as one.
  • Round through rounded-soft-sm, rounded-soft and rounded-soft-lg rather than the plain scale, so a project that retunes its own radii does not resize your component.
  • Never let colour be the only carrier of state: pair it with a glyph and a label.
  • Accept and merge className through cn, always last.
  • Export the props interface.
  • Animate transform and opacity only, and handle prefers-reduced-motion.
  • Mark "use client" only if the component genuinely needs state, refs, effects or browser APIs.

Write a preview

Create components/previews/<slug>-preview.tsx with a default export. This one never leaves the site, so it may import anything.

components/previews/glow-card-preview.tsx
"use client"

import { GlowCard } from "@/registry/components/glow-card"

export default function GlowCardPreview() {
  return <GlowCard className="max-w-sm">Preview content</GlowCard>
}

Register it so it code-splits. The map holds render functions, not component types — previews is a Record<string, () => ReactNode>, and renderPreview(slug) calls the entry:

components/previews/registry.tsx
const GlowCardPreview = dynamic(() => import("./glow-card-preview"))

export const previews: Record<string, () => ReactNode> = {
  "glow-card": () => <GlowCardPreview />,
}

Storing a function rather than a component keeps the preview frame from instantiating a value it received at runtime, and leaves the entry free to pass props or wrap the demo in a stage. A slug with no entry renders an explicit empty state rather than failing.

Describe it

Add one entry to lib/registry/components.ts. This is the single source of truth: the component page, catalog card, search index, sidebar link, registry item and AI prompt are all projected from it.

lib/registry/components.ts
defineComponent({
  name: "GlowCard",
  slug: "glow-card",
  title: "Glow Card",
  description: "A card with a soft animated glow.",
  overview: "Two to four sentences. Reused on the page and in the AI prompt.",
  category: "Cards",
  tags: ["card", "surface"],
  status: "new",
  featured: false,
  dependencies: [],
  registryDependencies: ["utils"],
  files: [uiFile("glow-card")],
  accessibility: ["Decorative layers are aria-hidden and pointer-events-none."],
  keyboard: [],
  props: [
    {
      name: "GlowCard",
      props: [
        {
          name: "intensity",
          type: "number",
          defaultValue: "1",
          description: "Glow strength.",
        },
      ],
    },
  ],
  usage: `import { GlowCard } from "@/components/joinui/glow-card"

export function Example() {
  return <GlowCard>Content</GlowCard>
}`,
  customization: [],
  related: [],
  since: "2026-08-02",
})

installCommand is derived by defineComponent — never write it by hand. related takes slugs of other components and is validated, so leave it empty until the components it would point at exist.

Validate and build

bash
pnpm registry:validate
pnpm registry:build
pnpm check

Validation fails loudly if the file does not exist, the preview is not registered, related points at an unknown slug, the slug is not kebab-case, or the description is longer than 160 characters.

Adding a documentation page

Two files, again derived from one source.

Add the MDX

Create content/docs/<slug>.mdx. No frontmatter — the title and description live in the navigation.

Register it

lib/docs/nav.ts
{
  title: "Guides",
  items: [
    item("my-page", "My page", "One-line description for search and metadata."),
  ],
}

That entry drives the sidebar, breadcrumbs, previous/next links, the search index, the sitemap and generateStaticParams.

Available MDX components

ComponentUse
<Callout variant="note | tip | warning | danger">Highlighted aside
<Steps> / <Step title="…">Numbered walkthrough
<CardGroup columns={2 | 3}> / <Card title href>Linkable card grid; the blurb is the child, not a prop
<InstallTabs target="@joinui/name" />shadcn add, per package manager
<DependencyTabs packages="motion clsx" />npm install, per package manager
<PackageManagerTabs commands={…} />Any other per-manager command
<ComponentPreview slug title minHeight />Live preview
<CodeBlock code language title />Highlighted code outside a fence
<Kbd> / <Badge>Inline key and status chips

Callout signals severity three ways at once: an icon, a spelled-out label, and a tint from one of the four hue families. The tint is the accelerator and the other two are the message, so a new variant needs all three — a colour on its own is not a variant.

Fenced blocks support a title, highlighted lines and line numbers:

text
```tsx title="button.tsx" {2,5-7} showLineNumbers
```

Customising the AI prompt

The prompt is generated from metadata. To override it wholesale, set prompt on the component — see using components with AI.

Before you open a pull request

  • pnpm check passes — validation, types and lint.
  • pnpm build succeeds.
  • The component works with a keyboard alone.
  • It behaves correctly with Reduce Motion enabled at the OS level.
  • Both preview themes look right — check with the sun and moon buttons on the preview frame, not just by switching the site theme.
  • No dark: variants in registry/ or components/previews/.
  • No hard-coded colours, no gradients, no shadows. Hue comes from the four token families, and corners round through rounded-soft*.
  • No any, and no TODO left in place of an implementation.

Publishing the registry

prebuild regenerates public/r/ before every production build, so deploying the site publishes the registry:

bash
pnpm build

Consumers then install from your domain:

bash
pnpm dlx shadcn@latest add https://your-domain.com/r/glow-card.json

Self-hosting details are in registry setup.