瀏覽代碼

shadcn (#115)

* shadcn

* fix build error

* fix readme

* fix CI error

* improvements

* fixes

* stronger types

* fix interleave

* fixes

* on arg

* minor fixes

* fix lock
Chris Tate 4 月之前
父節點
當前提交
320c935bfb
共有 57 個文件被更改,包括 5066 次插入446 次删除
  1. 0 2
      .github/workflows/ci.yml
  2. 5 1
      AGENTS.md
  3. 36 0
      README.md
  4. 3 1
      apps/web/next.config.js
  5. 14 102
      examples/chat/lib/render/catalog.ts
  6. 14 112
      examples/chat/lib/render/registry.tsx
  7. 1 0
      examples/chat/package.json
  8. 1 1
      package.json
  9. 3 0
      packages/core/src/actions.ts
  10. 1 0
      packages/core/src/index.ts
  11. 39 3
      packages/core/src/schema.ts
  12. 98 0
      packages/core/src/types.test.ts
  13. 56 38
      packages/core/src/types.ts
  14. 58 10
      packages/react/src/catalog-types.ts
  15. 2 0
      packages/react/src/index.ts
  16. 43 13
      packages/react/src/renderer.tsx
  17. 17 0
      packages/react/src/schema.ts
  18. 198 0
      packages/shadcn/README.md
  19. 21 0
      packages/shadcn/components.json
  20. 80 0
      packages/shadcn/package.json
  21. 502 0
      packages/shadcn/src/catalog.ts
  22. 1199 0
      packages/shadcn/src/components.tsx
  23. 9 0
      packages/shadcn/src/index.ts
  24. 6 0
      packages/shadcn/src/lib/utils.ts
  25. 64 0
      packages/shadcn/src/ui/accordion.tsx
  26. 66 0
      packages/shadcn/src/ui/alert.tsx
  27. 53 0
      packages/shadcn/src/ui/avatar.tsx
  28. 46 0
      packages/shadcn/src/ui/badge.tsx
  29. 64 0
      packages/shadcn/src/ui/button.tsx
  30. 78 0
      packages/shadcn/src/ui/card.tsx
  31. 241 0
      packages/shadcn/src/ui/carousel.tsx
  32. 32 0
      packages/shadcn/src/ui/checkbox.tsx
  33. 32 0
      packages/shadcn/src/ui/collapsible.tsx
  34. 158 0
      packages/shadcn/src/ui/dialog.tsx
  35. 133 0
      packages/shadcn/src/ui/drawer.tsx
  36. 257 0
      packages/shadcn/src/ui/dropdown-menu.tsx
  37. 21 0
      packages/shadcn/src/ui/input.tsx
  38. 22 0
      packages/shadcn/src/ui/label.tsx
  39. 127 0
      packages/shadcn/src/ui/pagination.tsx
  40. 89 0
      packages/shadcn/src/ui/popover.tsx
  41. 29 0
      packages/shadcn/src/ui/progress.tsx
  42. 45 0
      packages/shadcn/src/ui/radio-group.tsx
  43. 188 0
      packages/shadcn/src/ui/select.tsx
  44. 28 0
      packages/shadcn/src/ui/separator.tsx
  45. 13 0
      packages/shadcn/src/ui/skeleton.tsx
  46. 63 0
      packages/shadcn/src/ui/slider.tsx
  47. 35 0
      packages/shadcn/src/ui/switch.tsx
  48. 114 0
      packages/shadcn/src/ui/table.tsx
  49. 91 0
      packages/shadcn/src/ui/tabs.tsx
  50. 18 0
      packages/shadcn/src/ui/textarea.tsx
  51. 83 0
      packages/shadcn/src/ui/toggle-group.tsx
  52. 45 0
      packages/shadcn/src/ui/toggle.tsx
  53. 57 0
      packages/shadcn/src/ui/tooltip.tsx
  54. 9 0
      packages/shadcn/tsconfig.json
  55. 16 0
      packages/shadcn/tsup.config.ts
  56. 192 163
      pnpm-lock.yaml
  57. 151 0
      skills/json-render-shadcn/SKILL.md

+ 0 - 2
.github/workflows/ci.yml

@@ -23,8 +23,6 @@ jobs:
 
       - name: Install pnpm
         uses: pnpm/action-setup@v4
-        with:
-          version: 9.0.0
 
       - name: Setup Node.js
         uses: actions/setup-node@v4

+ 5 - 1
AGENTS.md

@@ -25,12 +25,16 @@ This ensures we don't install outdated versions that may have incompatible types
 ## Code Style
 
 - Do not use emojis in code or UI
-- Do not use barrel files (index.ts that re-exports from other files)
 - Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
 
 ## Workflow
 
 - Run `pnpm type-check` after each turn to ensure type safety
+- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
+  - Package `README.md` files in `packages/*/README.md`
+  - Root `README.md` (if packages table, install commands, or examples are affected)
+  - Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
+  - `AGENTS.md` (if workflow or conventions change)
 
 <!-- opensrc:start -->
 

+ 36 - 0
README.md

@@ -6,6 +6,8 @@ Generate dynamic, personalized UIs from prompts without sacrificing reliability.
 
 ```bash
 npm install @json-render/core @json-render/react
+# pre-built shadcn/ui components
+npm install @json-render/shadcn
 # or for mobile
 npm install @json-render/core @json-render/react-native
 # or for video
@@ -20,6 +22,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
 - **Predictable** - JSON output matches your schema, every time
 - **Fast** - Stream and render progressively as the model responds
 - **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
+- **Batteries Included** - 30+ pre-built shadcn/ui components ready to use
 
 ## Quick Start
 
@@ -105,6 +108,7 @@ function Dashboard({ spec }) {
 |---------|-------------|
 | `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
 | `@json-render/react` | React renderer, contexts, hooks |
+| `@json-render/shadcn` | 30+ pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
 | `@json-render/react-native` | React Native renderer with standard mobile components |
 | `@json-render/remotion` | Remotion video renderer, timeline schema |
 
@@ -138,6 +142,38 @@ const { registry } = defineRegistry(catalog, { components });
 <Renderer spec={spec} registry={registry} />
 ```
 
+### shadcn/ui (Web)
+
+```tsx
+import { defineCatalog } from "@json-render/core";
+import { schema, defineRegistry, Renderer } from "@json-render/react";
+import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
+import { shadcnComponents } from "@json-render/shadcn";
+
+// Pick components from the 30+ standard definitions
+const catalog = defineCatalog(schema, {
+  components: {
+    Card: shadcnComponentDefinitions.Card,
+    Stack: shadcnComponentDefinitions.Stack,
+    Heading: shadcnComponentDefinitions.Heading,
+    Button: shadcnComponentDefinitions.Button,
+  },
+  actions: {},
+});
+
+// Use matching implementations
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: shadcnComponents.Card,
+    Stack: shadcnComponents.Stack,
+    Heading: shadcnComponents.Heading,
+    Button: shadcnComponents.Button,
+  },
+});
+
+<Renderer spec={spec} registry={registry} />
+```
+
 ### React Native (Mobile)
 
 ```tsx

+ 3 - 1
apps/web/next.config.js

@@ -22,4 +22,6 @@ const nextConfig = {
 
 const withMDX = createMDX({});
 
-export default withMDX(nextConfig);
+/** @type {import('next').NextConfig} */
+const config = withMDX(nextConfig);
+export default config;

+ 14 - 102
examples/chat/lib/render/catalog.ts

@@ -1,5 +1,6 @@
 import { defineCatalog } from "@json-render/core";
 import { schema } from "@json-render/react/schema";
+import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
 import { z } from "zod";
 
 // =============================================================================
@@ -45,48 +46,19 @@ const mesh3DProps = {
  */
 export const explorerCatalog = defineCatalog(schema, {
   components: {
-    // Layout
-    Stack: {
-      props: z.object({
-        direction: z.enum(["horizontal", "vertical"]).nullable(),
-        gap: z.enum(["sm", "md", "lg"]).nullable(),
-        wrap: z.boolean().nullable(),
-      }),
-      slots: ["default"],
-      description: "Flex layout container",
-      example: { direction: "vertical", gap: "md", wrap: null },
-    },
-
-    Card: {
-      props: z.object({
-        title: z.string().nullable(),
-        description: z.string().nullable(),
-      }),
-      slots: ["default"],
-      description: "Card container with optional title and description",
-      example: { title: "Weather", description: "Current conditions" },
-    },
-
-    Grid: {
-      props: z.object({
-        columns: z.enum(["1", "2", "3", "4"]).nullable(),
-        gap: z.enum(["sm", "md", "lg"]).nullable(),
-      }),
-      slots: ["default"],
-      description: "Responsive grid layout container",
-      example: { columns: "3", gap: "md" },
-    },
-
-    // Typography
-    Heading: {
-      props: z.object({
-        text: z.string(),
-        level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
-      }),
-      description: "Section heading",
-      example: { text: "Data Explorer", level: "h1" },
-    },
-
+    // From @json-render/shadcn (used as-is)
+    Stack: shadcnComponentDefinitions.Stack,
+    Card: shadcnComponentDefinitions.Card,
+    Grid: shadcnComponentDefinitions.Grid,
+    Heading: shadcnComponentDefinitions.Heading,
+    Separator: shadcnComponentDefinitions.Separator,
+    Accordion: shadcnComponentDefinitions.Accordion,
+    Progress: shadcnComponentDefinitions.Progress,
+    Skeleton: shadcnComponentDefinitions.Skeleton,
+    Badge: shadcnComponentDefinitions.Badge,
+    Alert: shadcnComponentDefinitions.Alert,
+
+    // Chat-specific components (different schemas or fully custom)
     Text: {
       props: z.object({
         content: z.string(),
@@ -96,32 +68,6 @@ export const explorerCatalog = defineCatalog(schema, {
       example: { content: "Here is your data overview." },
     },
 
-    // Data display
-    Badge: {
-      props: z.object({
-        text: z.string(),
-        variant: z
-          .enum(["default", "secondary", "destructive", "outline"])
-          .nullable(),
-      }),
-      description: "Status badge",
-      example: { text: "Live", variant: "default" },
-    },
-
-    Alert: {
-      props: z.object({
-        variant: z.enum(["default", "destructive"]).nullable(),
-        title: z.string(),
-        description: z.string().nullable(),
-      }),
-      description: "Alert or info message",
-    },
-
-    Separator: {
-      props: z.object({}),
-      description: "Visual divider",
-    },
-
     Metric: {
       props: z.object({
         label: z.string(),
@@ -222,22 +168,6 @@ export const explorerCatalog = defineCatalog(schema, {
       description: "Content for a specific tab",
     },
 
-    Progress: {
-      props: z.object({
-        value: z.number(),
-        max: z.number().nullable(),
-      }),
-      description: "Progress bar",
-    },
-
-    Skeleton: {
-      props: z.object({
-        width: z.string().nullable(),
-        height: z.string().nullable(),
-      }),
-      description: "Loading placeholder",
-    },
-
     // Educational / Rich content
     Callout: {
       props: z.object({
@@ -254,24 +184,6 @@ export const explorerCatalog = defineCatalog(schema, {
       },
     },
 
-    Accordion: {
-      props: z.object({
-        items: z.array(
-          z.object({
-            title: z.string(),
-            content: z.string(),
-          }),
-        ),
-        type: z.enum(["single", "multiple"]).nullable(),
-      }),
-      description:
-        "Collapsible accordion sections for organizing detailed content",
-      example: {
-        items: [{ title: "Overview", content: "A brief introduction." }],
-        type: "multiple",
-      },
-    },
-
     Timeline: {
       props: z.object({
         items: z.array(

+ 14 - 112
examples/chat/lib/render/registry.tsx

@@ -2,6 +2,7 @@
 
 import { useState, useRef, type ReactNode } from "react";
 import { useBoundProp, defineRegistry } from "@json-render/react";
+import { shadcnComponents } from "@json-render/shadcn";
 import {
   Bar,
   BarChart as RechartsBarChart,
@@ -20,16 +21,6 @@ import {
   type ChartConfig,
 } from "@/components/ui/chart";
 
-import {
-  Card,
-  CardHeader,
-  CardTitle,
-  CardDescription,
-  CardContent,
-} from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
-import { Separator } from "@/components/ui/separator";
 import {
   Table,
   TableHeader,
@@ -39,14 +30,6 @@ import {
   TableCell,
 } from "@/components/ui/table";
 import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
-import {
-  Accordion as AccordionRoot,
-  AccordionItem,
-  AccordionTrigger,
-  AccordionContent,
-} from "@/components/ui/accordion";
-import { Progress } from "@/components/ui/progress";
-import { Skeleton } from "@/components/ui/skeleton";
 import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
 import {
   Select,
@@ -218,77 +201,25 @@ function AnimatedGroup({
 
 export const { registry, handlers } = defineRegistry(explorerCatalog, {
   components: {
-    Stack: ({ props, children }) => {
-      const gapClass =
-        { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
-      return (
-        <div
-          className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${props.wrap ? "flex-wrap" : ""} ${gapClass}`}
-        >
-          {children}
-        </div>
-      );
-    },
-
-    Card: ({ props, children }) => (
-      <Card>
-        {(props.title || props.description) && (
-          <CardHeader>
-            {props.title && <CardTitle>{props.title}</CardTitle>}
-            {props.description && (
-              <CardDescription>{props.description}</CardDescription>
-            )}
-          </CardHeader>
-        )}
-        <CardContent className="flex flex-col gap-4">{children}</CardContent>
-      </Card>
-    ),
-
-    Grid: ({ props, children }) => {
-      const colsClass =
-        {
-          "1": "grid-cols-1",
-          "2": "grid-cols-1 md:grid-cols-2",
-          "3": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
-          "4": "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
-        }[props.columns ?? "3"] ?? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
-      const gapClass =
-        { sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
-      return <div className={`grid ${colsClass} ${gapClass}`}>{children}</div>;
-    },
-
-    Heading: ({ props }) => {
-      const Tag = (props.level ?? "h2") as "h1" | "h2" | "h3" | "h4";
-      const sizeClass = {
-        h1: "text-3xl font-bold tracking-tight",
-        h2: "text-2xl font-semibold tracking-tight",
-        h3: "text-xl font-semibold",
-        h4: "text-lg font-medium",
-      }[props.level ?? "h2"];
-      return <Tag className={sizeClass}>{props.text}</Tag>;
-    },
-
+    // From @json-render/shadcn (used as-is)
+    Stack: shadcnComponents.Stack,
+    Card: shadcnComponents.Card,
+    Grid: shadcnComponents.Grid,
+    Heading: shadcnComponents.Heading,
+    Separator: shadcnComponents.Separator,
+    Accordion: shadcnComponents.Accordion,
+    Progress: shadcnComponents.Progress,
+    Skeleton: shadcnComponents.Skeleton,
+    Badge: shadcnComponents.Badge,
+    Alert: shadcnComponents.Alert,
+
+    // Chat-specific components
     Text: ({ props }) => (
       <p className={props.muted ? "text-muted-foreground" : ""}>
         {props.content}
       </p>
     ),
 
-    Badge: ({ props }) => (
-      <Badge variant={props.variant ?? "default"}>{props.text}</Badge>
-    ),
-
-    Alert: ({ props }) => (
-      <Alert variant={props.variant ?? "default"}>
-        <AlertTitle>{props.title}</AlertTitle>
-        {props.description ? (
-          <AlertDescription>{props.description}</AlertDescription>
-        ) : null}
-      </Alert>
-    ),
-
-    Separator: () => <Separator />,
-
     Metric: ({ props }) => {
       const TrendIcon =
         props.trend === "up"
@@ -566,16 +497,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
       <TabsContent value={props.value}>{children}</TabsContent>
     ),
 
-    Progress: ({ props }) => (
-      <Progress value={props.value} max={props.max ?? 100} />
-    ),
-
-    Skeleton: ({ props }) => (
-      <Skeleton
-        className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
-      />
-    ),
-
     Callout: ({ props }) => {
       const config = {
         info: {
@@ -626,23 +547,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
       );
     },
 
-    Accordion: ({ props }) => (
-      <AccordionRoot
-        type={props.type === "single" ? "single" : "multiple"}
-        collapsible={props.type === "single" ? true : undefined}
-        className="w-full"
-      >
-        {(props.items ?? []).map((item, i) => (
-          <AccordionItem key={i} value={`item-${i}`}>
-            <AccordionTrigger>{item.title}</AccordionTrigger>
-            <AccordionContent>
-              <p className="text-muted-foreground">{item.content}</p>
-            </AccordionContent>
-          </AccordionItem>
-        ))}
-      </AccordionRoot>
-    ),
-
     Timeline: ({ props }) => (
       <div className="relative pl-8">
         {/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
@@ -987,8 +891,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
       </DreiText>
     ),
   },
-
-  actions: {},
 });
 
 // =============================================================================

+ 1 - 0
examples/chat/package.json

@@ -15,6 +15,7 @@
     "@ai-sdk/react": "^3.0.84",
     "@json-render/core": "workspace:*",
     "@json-render/react": "workspace:*",
+    "@json-render/shadcn": "workspace:*",
     "@react-three/drei": "^10.7.7",
     "@react-three/fiber": "^9.5.0",
     "@streamdown/code": "^1.0.2",

+ 1 - 1
package.json

@@ -40,7 +40,7 @@
     "typescript": "5.9.2",
     "vitest": "^4.0.17"
   },
-  "packageManager": "pnpm@9.0.0",
+  "packageManager": "pnpm@10.29.3",
   "engines": {
     "node": ">=18"
   },

+ 3 - 0
packages/core/src/actions.ts

@@ -47,6 +47,8 @@ export interface ActionBinding {
   onSuccess?: ActionOnSuccess;
   /** Handler after failed execution */
   onError?: ActionOnError;
+  /** Whether to prevent default browser behavior (e.g. navigation on links) */
+  preventDefault?: boolean;
 }
 
 /**
@@ -91,6 +93,7 @@ export const ActionBindingSchema = z.object({
   confirm: ActionConfirmSchema.optional(),
   onSuccess: ActionOnSuccessSchema.optional(),
   onError: ActionOnErrorSchema.optional(),
+  preventDefault: z.boolean().optional(),
 });
 
 /**

+ 1 - 0
packages/core/src/index.ts

@@ -143,6 +143,7 @@ export type {
   Schema,
   PromptTemplate,
   SchemaOptions,
+  BuiltInAction,
 } from "./schema";
 
 export { defineSchema } from "./schema";

+ 39 - 3
packages/core/src/schema.ts

@@ -66,6 +66,8 @@ export interface Schema<TDef extends SchemaDefinition = SchemaDefinition> {
   readonly promptTemplate?: PromptTemplate;
   /** Default rules baked into the schema (injected before customRules) */
   readonly defaultRules?: string[];
+  /** Built-in actions always available at runtime (injected into prompts automatically) */
+  readonly builtInActions?: BuiltInAction[];
   /** Create a catalog from this schema */
   createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
     catalog: TCatalog,
@@ -140,6 +142,18 @@ export type PromptTemplate<TCatalog = unknown> = (
   context: PromptContext<TCatalog>,
 ) => string;
 
+/**
+ * A built-in action that is always available regardless of catalog configuration.
+ * These are handled by the runtime (e.g. ActionProvider) and injected into prompts
+ * automatically so the LLM knows about them.
+ */
+export interface BuiltInAction {
+  /** Action name (e.g. "setState") */
+  name: string;
+  /** Human-readable description for the LLM */
+  description: string;
+}
+
 /**
  * Schema options
  */
@@ -148,6 +162,13 @@ export interface SchemaOptions<TCatalog = unknown> {
   promptTemplate?: PromptTemplate<TCatalog>;
   /** Default rules baked into the schema (injected before customRules in prompts) */
   defaultRules?: string[];
+  /**
+   * Built-in actions that are always available regardless of catalog configuration.
+   * These are injected into prompts automatically so the LLM knows about them,
+   * but they don't require handlers in defineRegistry because the runtime
+   * (e.g. ActionProvider) handles them directly.
+   */
+  builtInActions?: BuiltInAction[];
 }
 
 /**
@@ -347,6 +368,7 @@ export function defineSchema<TDef extends SchemaDefinition>(
     definition,
     promptTemplate: options?.promptTemplate,
     defaultRules: options?.defaultRules,
+    builtInActions: options?.builtInActions,
     createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
       catalog: TCatalog,
     ): Catalog<TDef, TCatalog> {
@@ -749,12 +771,26 @@ Note: state patches appear right after the elements that use them, so the UI fil
     | Record<string, { params?: z.ZodType; description?: string }>
     | undefined;
 
-  if (actions && catalog.actionNames.length > 0) {
+  const builtInActions = catalog.schema.builtInActions ?? [];
+  const hasCustomActions = actions && catalog.actionNames.length > 0;
+  const hasBuiltInActions = builtInActions.length > 0;
+
+  if (hasCustomActions || hasBuiltInActions) {
     lines.push("AVAILABLE ACTIONS:");
     lines.push("");
-    for (const [name, def] of Object.entries(actions)) {
-      lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
+
+    // Built-in actions (handled by runtime, always available)
+    for (const action of builtInActions) {
+      lines.push(`- ${action.name}: ${action.description} [built-in]`);
+    }
+
+    // Custom actions (declared in catalog, require handlers)
+    if (hasCustomActions) {
+      for (const [name, def] of Object.entries(actions)) {
+        lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
+      }
     }
+
     lines.push("");
   }
 

+ 98 - 0
packages/core/src/types.test.ts

@@ -1022,6 +1022,104 @@ describe("createJsonRenderTransform", () => {
     const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
     expect(specChunks.length).toBe(1);
   });
+
+  // ===========================================================================
+  // Text block splitting around spec data
+  // ===========================================================================
+
+  it("splits text blocks around spec data (text-start/text-end pairs)", async () => {
+    const input = [
+      "Some text\n",
+      '{"op":"add","path":"/root","value":"r"}\n',
+      "More text\n",
+    ].join("");
+
+    const chunks = await transformText(input);
+
+    const textStarts = chunks.filter((c) => c.type === "text-start");
+    const textEnds = chunks.filter((c) => c.type === "text-end");
+
+    // There should be two text blocks: one before the patch and one after
+    expect(textStarts.length).toBe(2);
+    expect(textEnds.length).toBe(2);
+
+    // Spec data should appear between the two text blocks
+    const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
+    expect(specChunks.length).toBe(1);
+
+    // Find the indices of the first text-end and the spec chunk
+    const firstTextEndIdx = chunks.findIndex((c) => c.type === "text-end");
+    const specIdx = chunks.findIndex((c) => c.type === SPEC_DATA_PART_TYPE);
+    const secondTextStartIdx = chunks.findIndex(
+      (c, i) => i > specIdx && c.type === "text-start",
+    );
+    expect(firstTextEndIdx).toBeLessThan(specIdx);
+    expect(specIdx).toBeLessThan(secondTextStartIdx);
+  });
+
+  it("flush closes an open text block when stream ends without text-end", async () => {
+    const transform = createJsonRenderTransform();
+    const writer = transform.writable.getWriter();
+    const reader = transform.readable.getReader();
+
+    const chunks: StreamChunk[] = [];
+    const readAll = (async () => {
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+        chunks.push(value);
+      }
+    })();
+
+    // Write text-start + text-delta, then close WITHOUT text-end
+    await writer.write({ type: "text-start", id: "t1" });
+    await writer.write({
+      type: "text-delta",
+      id: "t1",
+      delta: "Hello world\n",
+    });
+    await writer.close();
+
+    await readAll;
+
+    // The transform's flush should have emitted a text-end to close the block
+    const textEnds = chunks.filter((c) => c.type === "text-end");
+    expect(textEnds.length).toBe(1);
+
+    // Text content should still be present
+    const textChunks = chunks.filter((c) => c.type === "text-delta");
+    const text = textChunks.map((c) => (c as { delta: string }).delta).join("");
+    expect(text).toContain("Hello world");
+  });
+
+  it("consecutive patches do not produce empty text blocks", async () => {
+    const input = [
+      '{"op":"add","path":"/root","value":"r"}\n',
+      '{"op":"add","path":"/elements/r","value":{"type":"Card","props":{},"children":[]}}\n',
+    ].join("");
+
+    const chunks = await transformText(input);
+
+    const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
+    expect(specChunks.length).toBe(2);
+
+    // There should be no text-start/text-end pairs between the two spec chunks
+    // (the initial text-start from the upstream is forwarded, but no new empty ones)
+    const textDeltas = chunks.filter((c) => c.type === "text-delta");
+    const textContent = textDeltas
+      .map((c) => (c as { delta: string }).delta)
+      .join("")
+      .trim();
+    // No meaningful text content between the patches
+    expect(textContent).toBe("");
+
+    // Count text blocks: there should be at most 1 (the initial upstream one),
+    // not extra empty ones inserted between patches
+    const textStarts = chunks.filter((c) => c.type === "text-start");
+    const textEnds = chunks.filter((c) => c.type === "text-end");
+    expect(textStarts.length).toBeLessThanOrEqual(1);
+    expect(textEnds.length).toBeLessThanOrEqual(1);
+  });
 });
 
 // =============================================================================

+ 56 - 38
packages/core/src/types.ts

@@ -1010,11 +1010,48 @@ export function createJsonRenderTransform(): TransformStream<
   let buffering = false;
   // Whether we are inside a ```spec fence
   let inSpecFence = false;
+  // Whether we are currently inside a text block (between text-start/text-end).
+  // Used to split text blocks around spec data so the AI SDK creates separate
+  // text parts, preserving interleaving of prose and UI in message.parts.
+  let inTextBlock = false;
+  let textIdCounter = 0;
+
+  /** Close the current text block if one is open. */
+  function closeTextBlock(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    if (inTextBlock) {
+      controller.enqueue({ type: "text-end", id: currentTextId });
+      inTextBlock = false;
+    }
+  }
+
+  /** Ensure a text block is open, starting a new one if needed. */
+  function ensureTextBlock(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    if (!inTextBlock) {
+      textIdCounter++;
+      currentTextId = String(textIdCounter);
+      controller.enqueue({ type: "text-start", id: currentTextId });
+      inTextBlock = true;
+    }
+  }
+
+  /** Emit a text-delta, opening a text block first if necessary. */
+  function emitTextDelta(
+    delta: string,
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    ensureTextBlock(controller);
+    controller.enqueue({ type: "text-delta", id: currentTextId, delta });
+  }
 
   function emitPatch(
     patch: SpecStreamLine,
     controller: TransformStreamDefaultController<StreamChunk>,
   ) {
+    closeTextBlock(controller);
     controller.enqueue({
       type: SPEC_DATA_PART_TYPE,
       data: { type: "patch", patch },
@@ -1046,19 +1083,11 @@ export function createJsonRenderTransform(): TransformStream<
         emitPatch(patch, controller);
       } else {
         // Was buffered but isn't JSONL — flush as text
-        controller.enqueue({
-          type: "text-delta",
-          id: currentTextId,
-          delta: lineBuffer,
-        });
+        emitTextDelta(lineBuffer, controller);
       }
     } else {
       // Whitespace-only buffer — forward as-is (preserves blank lines)
-      controller.enqueue({
-        type: "text-delta",
-        id: currentTextId,
-        delta: lineBuffer,
-      });
+      emitTextDelta(lineBuffer, controller);
     }
     lineBuffer = "";
     buffering = false;
@@ -1092,11 +1121,7 @@ export function createJsonRenderTransform(): TransformStream<
     // --- Outside fence: heuristic mode ---
     if (!trimmed) {
       // Empty line — forward for markdown paragraph breaks
-      controller.enqueue({
-        type: "text-delta",
-        id: currentTextId,
-        delta: "\n",
-      });
+      emitTextDelta("\n", controller);
       return;
     }
 
@@ -1104,11 +1129,7 @@ export function createJsonRenderTransform(): TransformStream<
     if (patch) {
       emitPatch(patch, controller);
     } else {
-      controller.enqueue({
-        type: "text-delta",
-        id: currentTextId,
-        delta: line + "\n",
-      });
+      emitTextDelta(line + "\n", controller);
     }
   }
 
@@ -1116,14 +1137,19 @@ export function createJsonRenderTransform(): TransformStream<
     transform(chunk, controller) {
       switch (chunk.type) {
         case "text-start": {
-          currentTextId = (chunk as { id: string }).id;
+          const id = (chunk as { id: string }).id;
+          const idNum = parseInt(id, 10);
+          if (!isNaN(idNum) && idNum >= textIdCounter) {
+            textIdCounter = idNum;
+          }
+          currentTextId = id;
+          inTextBlock = true;
           controller.enqueue(chunk);
           break;
         }
 
         case "text-delta": {
           const delta = chunk as { id: string; delta: string };
-          currentTextId = delta.id;
           const text = delta.delta;
 
           for (let i = 0; i < text.length; i++) {
@@ -1138,11 +1164,7 @@ export function createJsonRenderTransform(): TransformStream<
               } else {
                 // Outside fence, emit newline; inside fence, swallow it
                 if (!inSpecFence) {
-                  controller.enqueue({
-                    type: "text-delta",
-                    id: currentTextId,
-                    delta: "\n",
-                  });
+                  emitTextDelta("\n", controller);
                 }
               }
             } else if (lineBuffer.length === 0 && !buffering) {
@@ -1152,20 +1174,12 @@ export function createJsonRenderTransform(): TransformStream<
                 buffering = true;
                 lineBuffer += ch;
               } else {
-                controller.enqueue({
-                  type: "text-delta",
-                  id: currentTextId,
-                  delta: ch,
-                });
+                emitTextDelta(ch, controller);
               }
             } else if (buffering) {
               lineBuffer += ch;
             } else {
-              controller.enqueue({
-                type: "text-delta",
-                id: currentTextId,
-                delta: ch,
-              });
+              emitTextDelta(ch, controller);
             }
           }
           break;
@@ -1173,7 +1187,10 @@ export function createJsonRenderTransform(): TransformStream<
 
         case "text-end": {
           flushBuffer(controller);
-          controller.enqueue(chunk);
+          if (inTextBlock) {
+            controller.enqueue({ type: "text-end", id: currentTextId });
+            inTextBlock = false;
+          }
           break;
         }
 
@@ -1186,6 +1203,7 @@ export function createJsonRenderTransform(): TransformStream<
 
     flush(controller) {
       flushBuffer(controller);
+      closeTextBlock(controller);
     },
   });
 }

+ 58 - 10
packages/react/src/catalog-types.ts

@@ -26,20 +26,44 @@ export type SetState = (
 // =============================================================================
 
 /**
- * Context passed to component render functions
+ * Handle returned by the `on()` function for a specific event.
+ * Provides metadata about the event binding and a method to fire it.
+ *
  * @example
- * const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => {
- *   return <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
- * }
+ * ```ts
+ * const press = on("press");
+ * if (press.shouldPreventDefault) e.preventDefault();
+ * press.emit();
+ * ```
  */
-export interface ComponentContext<
-  C extends Catalog,
-  K extends keyof InferCatalogComponents<C>,
-> {
-  props: InferComponentProps<C, K>;
+export interface EventHandle {
+  /** Fire the event (resolve action bindings) */
+  emit: () => void;
+  /** Whether any binding requested preventDefault */
+  shouldPreventDefault: boolean;
+  /** Whether any handler is bound to this event */
+  bound: boolean;
+}
+
+/**
+ * Catalog-agnostic base type for component render function arguments.
+ * Use this when building reusable component libraries (e.g. `@json-render/shadcn`)
+ * that are not tied to a specific catalog.
+ *
+ * @example
+ * ```ts
+ * const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
+ *   <div>{props.title}{children}</div>
+ * );
+ * ```
+ */
+export interface BaseComponentProps<P = Record<string, unknown>> {
+  props: P;
   children?: ReactNode;
-  /** Emit a named event. The renderer resolves the event to an action binding from the element's `on` field. */
+  /** Simple event emitter (shorthand). Fires the event and returns void. */
   emit: (event: string) => void;
+  /** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
+  on: (event: string) => EventHandle;
   /**
    * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
    * Maps prop name → absolute state path for write-back.
@@ -48,6 +72,18 @@ export interface ComponentContext<
   loading?: boolean;
 }
 
+/**
+ * Context passed to component render functions
+ * @example
+ * const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => {
+ *   return <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
+ * }
+ */
+export interface ComponentContext<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> extends BaseComponentProps<InferComponentProps<C, K>> {}
+
 /**
  * Component render function type for React
  * @example
@@ -104,3 +140,15 @@ export type ActionFn<
 export type Actions<C extends Catalog> = {
   [K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
 };
+
+/**
+ * True when the catalog declares at least one action, false otherwise.
+ * Used by defineRegistry to conditionally require the `actions` field.
+ */
+export type CatalogHasActions<C extends Catalog> = [
+  InferCatalogActions<C>,
+] extends [never]
+  ? false
+  : [keyof InferCatalogActions<C>] extends [never]
+    ? false
+    : true;

+ 2 - 0
packages/react/src/index.ts

@@ -58,6 +58,8 @@ export type { Spec } from "@json-render/core";
 
 // Catalog-aware types for React
 export type {
+  EventHandle,
+  BaseComponentProps,
   SetState,
   StateModel,
   ComponentContext,

+ 43 - 13
packages/react/src/renderer.tsx

@@ -29,6 +29,8 @@ import type {
   ActionFn,
   SetState,
   StateModel,
+  CatalogHasActions,
+  EventHandle,
 } from "./catalog-types";
 import { useIsVisible, useVisibility } from "./contexts/visibility";
 import { useActions } from "./contexts/actions";
@@ -50,6 +52,8 @@ export interface ComponentRenderProps<P = Record<string, unknown>> {
   children?: ReactNode;
   /** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */
   emit: (event: string) => void;
+  /** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */
+  on: (event: string) => EventHandle;
   /**
    * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
    * Maps prop name → absolute state path for write-back.
@@ -199,6 +203,24 @@ const ElementRenderer = React.memo(function ElementRenderer({
     [onBindings, execute, fullCtx],
   );
 
+  // Create on() function that returns an EventHandle with metadata for a specific event.
+  const on = useCallback(
+    (eventName: string): EventHandle => {
+      const binding = onBindings?.[eventName];
+      if (!binding) {
+        return { emit: () => {}, shouldPreventDefault: false, bound: false };
+      }
+      const actionBindings = Array.isArray(binding) ? binding : [binding];
+      const shouldPreventDefault = actionBindings.some((b) => b.preventDefault);
+      return {
+        emit: () => emit(eventName),
+        shouldPreventDefault,
+        bound: true,
+      };
+    },
+    [onBindings, emit],
+  );
+
   // Don't render if not visible
   if (!isVisible) {
     return null;
@@ -262,6 +284,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
       <Component
         element={resolvedElement}
         emit={emit}
+        on={on}
         bindings={elementBindings}
         loading={loading}
       >
@@ -466,12 +489,26 @@ export interface DefineRegistryResult {
   ) => Promise<void>;
 }
 
+/**
+ * Options for defineRegistry.
+ *
+ * When the catalog declares actions, the `actions` field is required.
+ * When the catalog has no actions (or `actions: {}`), the field is optional.
+ */
+type DefineRegistryOptions<C extends Catalog> = {
+  components?: Components<C>;
+} & (CatalogHasActions<C> extends true
+  ? { actions: Actions<C> }
+  : { actions?: Actions<C> });
+
 /**
  * Create a registry from a catalog with components and/or actions.
  *
+ * When the catalog declares actions, the `actions` field is required.
+ *
  * @example
  * ```tsx
- * // Components only
+ * // Components only (catalog has no actions)
  * const { registry } = defineRegistry(catalog, {
  *   components: {
  *     Card: ({ props, children }) => (
@@ -480,14 +517,7 @@ export interface DefineRegistryResult {
  *   },
  * });
  *
- * // Actions only
- * const { handlers, executeAction } = defineRegistry(catalog, {
- *   actions: {
- *     viewCustomers: async (params, setState) => { ... },
- *   },
- * });
- *
- * // Both
+ * // Both (catalog declares actions)
  * const { registry, handlers, executeAction } = defineRegistry(catalog, {
  *   components: { ... },
  *   actions: { ... },
@@ -496,10 +526,7 @@ export interface DefineRegistryResult {
  */
 export function defineRegistry<C extends Catalog>(
   _catalog: C,
-  options: {
-    components?: Components<C>;
-    actions?: Actions<C>;
-  },
+  options: DefineRegistryOptions<C>,
 ): DefineRegistryResult {
   // Build component registry
   const registry: ComponentRegistry = {};
@@ -509,6 +536,7 @@ export function defineRegistry<C extends Catalog>(
         element,
         children,
         emit,
+        on,
         bindings,
         loading,
       }: ComponentRenderProps) => {
@@ -516,6 +544,7 @@ export function defineRegistry<C extends Catalog>(
           props: element.props,
           children,
           emit,
+          on,
           bindings,
           loading,
         });
@@ -572,6 +601,7 @@ type DefineRegistryComponentFn = (ctx: {
   props: unknown;
   children?: React.ReactNode;
   emit: (event: string) => void;
+  on: (event: string) => EventHandle;
   bindings?: Record<string, string>;
   loading?: boolean;
 }) => React.ReactNode;

+ 17 - 0
packages/react/src/schema.ts

@@ -51,6 +51,23 @@ export const schema = defineSchema(
     }),
   }),
   {
+    builtInActions: [
+      {
+        name: "setState",
+        description:
+          "Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
+      },
+      {
+        name: "pushState",
+        description:
+          'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
+      },
+      {
+        name: "removeState",
+        description:
+          "Remove an item from an array in state by index. Params: { statePath: string, index: number }",
+      },
+    ],
     defaultRules: [
       // Element integrity
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",

+ 198 - 0
packages/shadcn/README.md

@@ -0,0 +1,198 @@
+# @json-render/shadcn
+
+Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. Drop-in catalog definitions and React implementations for 30+ components built on Radix UI + Tailwind CSS.
+
+## Installation
+
+```bash
+npm install @json-render/shadcn @json-render/core @json-render/react zod
+```
+
+## Quick Start
+
+### 1. Create a Catalog
+
+Import standard definitions from `@json-render/shadcn/catalog` and pass them to `defineCatalog`:
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
+
+const catalog = defineCatalog(schema, {
+  components: {
+    // Pick the components you need
+    Card: shadcnComponentDefinitions.Card,
+    Stack: shadcnComponentDefinitions.Stack,
+    Heading: shadcnComponentDefinitions.Heading,
+    Button: shadcnComponentDefinitions.Button,
+    Input: shadcnComponentDefinitions.Input,
+  },
+  actions: {},
+});
+```
+
+> **Note:** State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled automatically by `ActionProvider`. You don't need to declare them in your catalog.
+
+### 2. Create a Registry
+
+Import standard implementations from `@json-render/shadcn` and pass them to `defineRegistry`:
+
+```typescript
+import { defineRegistry } from "@json-render/react";
+import { shadcnComponents } from "@json-render/shadcn";
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: shadcnComponents.Card,
+    Stack: shadcnComponents.Stack,
+    Heading: shadcnComponents.Heading,
+    Button: shadcnComponents.Button,
+    Input: shadcnComponents.Input,
+  },
+});
+```
+
+### 3. Render
+
+```tsx
+import { Renderer } from "@json-render/react";
+
+function App({ spec }) {
+  return <Renderer spec={spec} registry={registry} />;
+}
+```
+
+## Extending with Custom Components
+
+Pick standard components as a base and add your own alongside them:
+
+```typescript
+import { z } from "zod";
+
+// Catalog
+const catalog = defineCatalog(schema, {
+  components: {
+    // Standard
+    Card: shadcnComponentDefinitions.Card,
+    Stack: shadcnComponentDefinitions.Stack,
+    Button: shadcnComponentDefinitions.Button,
+
+    // Custom
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        value: z.string(),
+        trend: z.enum(["up", "down", "neutral"]).nullable(),
+      }),
+      description: "KPI metric display",
+    },
+  },
+  actions: {},
+});
+
+// Registry
+const { registry } = defineRegistry(catalog, {
+  components: {
+    // Standard
+    Card: shadcnComponents.Card,
+    Stack: shadcnComponents.Stack,
+    Button: shadcnComponents.Button,
+
+    // Custom
+    Metric: ({ props }) => (
+      <div>
+        <span>{props.label}</span>
+        <span>{props.value}</span>
+      </div>
+    ),
+  },
+});
+```
+
+## Standard Components
+
+### Layout
+
+| Component | Description |
+|-----------|-------------|
+| `Card` | Container card with optional title and description |
+| `Stack` | Flex container (horizontal/vertical) with gap, alignment, justify |
+| `Grid` | Grid layout (1-6 columns) |
+| `Separator` | Visual separator line |
+
+### Navigation
+
+| Component | Description |
+|-----------|-------------|
+| `Tabs` | Tabbed navigation |
+| `Accordion` | Collapsible accordion sections |
+| `Collapsible` | Single collapsible section with trigger |
+| `Pagination` | Page navigation |
+
+### Overlay
+
+| Component | Description |
+|-----------|-------------|
+| `Dialog` | Modal dialog |
+| `Drawer` | Bottom drawer |
+| `Tooltip` | Hover tooltip |
+| `Popover` | Click-triggered popover |
+| `DropdownMenu` | Dropdown menu with selectable items |
+
+### Content
+
+| Component | Description |
+|-----------|-------------|
+| `Heading` | Heading text (h1-h4) |
+| `Text` | Paragraph text with variants (body, caption, muted, lead, code) |
+| `Image` | Placeholder image |
+| `Avatar` | User avatar with fallback initials |
+| `Badge` | Status badge |
+| `Alert` | Alert banner |
+| `Carousel` | Horizontally scrollable carousel |
+| `Table` | Data table with columns and rows |
+
+### Feedback
+
+| Component | Description |
+|-----------|-------------|
+| `Progress` | Progress bar |
+| `Skeleton` | Loading placeholder |
+| `Spinner` | Loading spinner |
+
+### Input
+
+| Component | Description |
+|-----------|-------------|
+| `Button` | Clickable button with variants |
+| `Link` | Anchor link |
+| `Input` | Text input with label and validation |
+| `Textarea` | Multi-line text input |
+| `Select` | Dropdown select |
+| `Checkbox` | Checkbox input |
+| `Radio` | Radio button group |
+| `Switch` | Toggle switch |
+| `Slider` | Range slider |
+| `Toggle` | Toggle button |
+| `ToggleGroup` | Group of toggle buttons |
+| `ButtonGroup` | Group of buttons with selected state |
+
+## Built-in Actions
+
+State actions (`setState`, `pushState`, `removeState`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog.
+
+| Action | Description |
+|--------|-------------|
+| `setState` | Set a value at a state path |
+| `pushState` | Push a value onto an array in state |
+| `removeState` | Remove an item from an array in state |
+
+## Exports
+
+| Entry Point | Exports |
+|-------------|---------|
+| `@json-render/shadcn` | `shadcnComponents` |
+| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` |
+
+The `/catalog` entry point contains only Zod schemas (no React dependency), so it can be used in server-side code for prompt generation.

+ 21 - 0
packages/shadcn/components.json

@@ -0,0 +1,21 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": false,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "aliases": {
+    "components": "src",
+    "utils": "src/lib/utils",
+    "ui": "src/ui",
+    "lib": "src/lib",
+    "hooks": "src/hooks"
+  }
+}

+ 80 - 0
packages/shadcn/package.json

@@ -0,0 +1,80 @@
+{
+  "name": "@json-render/shadcn",
+  "version": "0.0.1",
+  "license": "Apache-2.0",
+  "description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
+  "keywords": [
+    "json",
+    "ui",
+    "react",
+    "shadcn",
+    "tailwind",
+    "radix",
+    "ai",
+    "generative-ui",
+    "llm",
+    "renderer",
+    "streaming",
+    "components"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/shadcn"
+  },
+  "homepage": "https://github.com/vercel-labs/json-render#readme",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    },
+    "./catalog": {
+      "types": "./dist/catalog.d.ts",
+      "import": "./dist/catalog.mjs",
+      "require": "./dist/catalog.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "check-types": "tsc --noEmit",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "embla-carousel-react": "^8.6.0",
+    "lucide-react": "^0.564.0",
+    "radix-ui": "^1.4.3",
+    "tailwind-merge": "^3.4.1",
+    "vaul": "^1.1.2"
+  },
+  "devDependencies": {
+    "@repo/typescript-config": "workspace:*",
+    "@types/react": "19.2.3",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "zod": "^4.0.0"
+  },
+  "peerDependencies": {
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "tailwindcss": "^4.0.0",
+    "zod": "^4.0.0"
+  }
+}

+ 502 - 0
packages/shadcn/src/catalog.ts

@@ -0,0 +1,502 @@
+import { z } from "zod";
+
+// =============================================================================
+// shadcn/ui Component Definitions
+// =============================================================================
+
+/**
+ * shadcn/ui component definitions for json-render catalogs.
+ *
+ * These can be used directly or extended with custom components.
+ * All components are built using Radix UI primitives + Tailwind CSS.
+ */
+export const shadcnComponentDefinitions = {
+  // ==========================================================================
+  // Layout Components
+  // ==========================================================================
+
+  Card: {
+    props: z.object({
+      title: z.string().nullable(),
+      description: z.string().nullable(),
+      maxWidth: z.enum(["sm", "md", "lg", "full"]).nullable(),
+      centered: z.boolean().nullable(),
+    }),
+    slots: ["default"],
+    description:
+      "Container card for content sections. Use for forms/content boxes, NOT for page headers.",
+    example: { title: "Overview", description: "Your account summary" },
+  },
+
+  Stack: {
+    props: z.object({
+      direction: z.enum(["horizontal", "vertical"]).nullable(),
+      gap: z.enum(["none", "sm", "md", "lg"]).nullable(),
+      align: z.enum(["start", "center", "end", "stretch"]).nullable(),
+      justify: z
+        .enum(["start", "center", "end", "between", "around"])
+        .nullable(),
+    }),
+    slots: ["default"],
+    description: "Flex container for layouts",
+    example: { direction: "vertical", gap: "md" },
+  },
+
+  Grid: {
+    props: z.object({
+      columns: z.number().nullable(),
+      gap: z.enum(["sm", "md", "lg"]).nullable(),
+    }),
+    slots: ["default"],
+    description: "Grid layout (1-6 columns)",
+    example: { columns: 3, gap: "md" },
+  },
+
+  Separator: {
+    props: z.object({
+      orientation: z.enum(["horizontal", "vertical"]).nullable(),
+    }),
+    description: "Visual separator line",
+  },
+
+  Tabs: {
+    props: z.object({
+      tabs: z.array(
+        z.object({
+          label: z.string(),
+          value: z.string(),
+        }),
+      ),
+      defaultValue: z.string().nullable(),
+      value: z.string().nullable(),
+    }),
+    slots: ["default"],
+    events: ["change"],
+    description:
+      "Tab navigation. Use { $bindState } on value for active tab binding.",
+  },
+
+  Accordion: {
+    props: z.object({
+      items: z.array(
+        z.object({
+          title: z.string(),
+          content: z.string(),
+        }),
+      ),
+      type: z.enum(["single", "multiple"]).nullable(),
+    }),
+    description:
+      "Collapsible sections. Items as [{title, content}]. Type 'single' (default) or 'multiple'.",
+  },
+
+  Collapsible: {
+    props: z.object({
+      title: z.string(),
+      defaultOpen: z.boolean().nullable(),
+    }),
+    slots: ["default"],
+    description: "Collapsible section with trigger. Children render inside.",
+  },
+
+  Dialog: {
+    props: z.object({
+      title: z.string(),
+      description: z.string().nullable(),
+      openPath: z.string(),
+    }),
+    slots: ["default"],
+    description:
+      "Modal dialog. Set openPath to a boolean state path. Use setState to toggle.",
+  },
+
+  Drawer: {
+    props: z.object({
+      title: z.string(),
+      description: z.string().nullable(),
+      openPath: z.string(),
+    }),
+    slots: ["default"],
+    description:
+      "Bottom sheet drawer. Set openPath to a boolean state path. Use setState to toggle.",
+  },
+
+  Carousel: {
+    props: z.object({
+      items: z.array(
+        z.object({
+          title: z.string().nullable(),
+          description: z.string().nullable(),
+        }),
+      ),
+    }),
+    description: "Horizontally scrollable carousel of cards.",
+  },
+
+  // ==========================================================================
+  // Data Display Components
+  // ==========================================================================
+
+  Table: {
+    props: z.object({
+      columns: z.array(z.string()),
+      rows: z.array(z.array(z.string())),
+      caption: z.string().nullable(),
+    }),
+    description:
+      'Data table. columns: header labels. rows: 2D array of cell strings, e.g. [["Alice","admin"],["Bob","user"]].',
+    example: {
+      columns: ["Name", "Role"],
+      rows: [
+        ["Alice", "Admin"],
+        ["Bob", "User"],
+      ],
+    },
+  },
+
+  Heading: {
+    props: z.object({
+      text: z.string(),
+      level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
+    }),
+    description: "Heading text (h1-h4)",
+    example: { text: "Welcome", level: "h1" },
+  },
+
+  Text: {
+    props: z.object({
+      text: z.string(),
+      variant: z.enum(["body", "caption", "muted", "lead", "code"]).nullable(),
+    }),
+    description: "Paragraph text",
+    example: { text: "Hello, world!" },
+  },
+
+  Image: {
+    props: z.object({
+      src: z.string().nullable(),
+      alt: z.string(),
+      width: z.number().nullable(),
+      height: z.number().nullable(),
+    }),
+    description:
+      "Image component. Renders an img tag when src is provided, otherwise a placeholder.",
+  },
+
+  Avatar: {
+    props: z.object({
+      src: z.string().nullable(),
+      name: z.string(),
+      size: z.enum(["sm", "md", "lg"]).nullable(),
+    }),
+    description: "User avatar with fallback initials",
+    example: { name: "Jane Doe", size: "md" },
+  },
+
+  Badge: {
+    props: z.object({
+      text: z.string(),
+      variant: z
+        .enum(["default", "secondary", "destructive", "outline"])
+        .nullable(),
+    }),
+    description: "Status badge",
+    example: { text: "Active", variant: "default" },
+  },
+
+  Alert: {
+    props: z.object({
+      title: z.string(),
+      message: z.string().nullable(),
+      type: z.enum(["info", "success", "warning", "error"]).nullable(),
+    }),
+    description: "Alert banner",
+    example: {
+      title: "Note",
+      message: "Your changes have been saved.",
+      type: "success",
+    },
+  },
+
+  Progress: {
+    props: z.object({
+      value: z.number(),
+      max: z.number().nullable(),
+      label: z.string().nullable(),
+    }),
+    description: "Progress bar (value 0-100)",
+    example: { value: 65, max: 100, label: "Upload progress" },
+  },
+
+  Skeleton: {
+    props: z.object({
+      width: z.string().nullable(),
+      height: z.string().nullable(),
+      rounded: z.boolean().nullable(),
+    }),
+    description: "Loading placeholder skeleton",
+  },
+
+  Spinner: {
+    props: z.object({
+      size: z.enum(["sm", "md", "lg"]).nullable(),
+      label: z.string().nullable(),
+    }),
+    description: "Loading spinner indicator",
+  },
+
+  Tooltip: {
+    props: z.object({
+      content: z.string(),
+      text: z.string(),
+    }),
+    description: "Hover tooltip. Shows content on hover over text.",
+  },
+
+  Popover: {
+    props: z.object({
+      trigger: z.string(),
+      content: z.string(),
+    }),
+    description: "Popover that appears on click of trigger.",
+  },
+
+  // ==========================================================================
+  // Form Input Components
+  // ==========================================================================
+
+  Input: {
+    props: z.object({
+      label: z.string(),
+      name: z.string(),
+      type: z.enum(["text", "email", "password", "number"]).nullable(),
+      placeholder: z.string().nullable(),
+      value: z.string().nullable(),
+      checks: z
+        .array(
+          z.object({
+            type: z.string(),
+            message: z.string(),
+            args: z.record(z.string(), z.unknown()).optional(),
+          }),
+        )
+        .nullable(),
+    }),
+    events: ["submit", "focus", "blur"],
+    description:
+      "Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
+    example: {
+      label: "Email",
+      name: "email",
+      type: "email",
+      placeholder: "you@example.com",
+    },
+  },
+
+  Textarea: {
+    props: z.object({
+      label: z.string(),
+      name: z.string(),
+      placeholder: z.string().nullable(),
+      rows: z.number().nullable(),
+      value: z.string().nullable(),
+      checks: z
+        .array(
+          z.object({
+            type: z.string(),
+            message: z.string(),
+            args: z.record(z.string(), z.unknown()).optional(),
+          }),
+        )
+        .nullable(),
+    }),
+    description:
+      "Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
+  },
+
+  Select: {
+    props: z.object({
+      label: z.string(),
+      name: z.string(),
+      options: z.array(z.string()),
+      placeholder: z.string().nullable(),
+      value: z.string().nullable(),
+      checks: z
+        .array(
+          z.object({
+            type: z.string(),
+            message: z.string(),
+            args: z.record(z.string(), z.unknown()).optional(),
+          }),
+        )
+        .nullable(),
+    }),
+    events: ["change"],
+    description:
+      "Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
+  },
+
+  Checkbox: {
+    props: z.object({
+      label: z.string(),
+      name: z.string(),
+      checked: z.boolean().nullable(),
+    }),
+    events: ["change"],
+    description: "Checkbox input. Use { $bindState } on checked for binding.",
+  },
+
+  Radio: {
+    props: z.object({
+      label: z.string(),
+      name: z.string(),
+      options: z.array(z.string()),
+      value: z.string().nullable(),
+    }),
+    events: ["change"],
+    description: "Radio button group. Use { $bindState } on value for binding.",
+  },
+
+  Switch: {
+    props: z.object({
+      label: z.string(),
+      name: z.string(),
+      checked: z.boolean().nullable(),
+    }),
+    events: ["change"],
+    description: "Toggle switch. Use { $bindState } on checked for binding.",
+  },
+
+  Slider: {
+    props: z.object({
+      label: z.string().nullable(),
+      min: z.number().nullable(),
+      max: z.number().nullable(),
+      step: z.number().nullable(),
+      value: z.number().nullable(),
+    }),
+    events: ["change"],
+    description: "Range slider input. Use { $bindState } on value for binding.",
+  },
+
+  // ==========================================================================
+  // Action Components
+  // ==========================================================================
+
+  Button: {
+    props: z.object({
+      label: z.string(),
+      variant: z.enum(["primary", "secondary", "danger"]).nullable(),
+      disabled: z.boolean().nullable(),
+    }),
+    events: ["press"],
+    description: "Clickable button. Bind on.press for handler.",
+    example: { label: "Submit", variant: "primary" },
+  },
+
+  Link: {
+    props: z.object({
+      label: z.string(),
+      href: z.string(),
+    }),
+    events: ["press"],
+    description: "Anchor link. Bind on.press for click handler.",
+  },
+
+  DropdownMenu: {
+    props: z.object({
+      label: z.string(),
+      items: z.array(
+        z.object({
+          label: z.string(),
+          value: z.string(),
+        }),
+      ),
+      value: z.string().nullable(),
+    }),
+    events: ["select"],
+    description:
+      "Dropdown menu with trigger button and selectable items. Use { $bindState } on value for selected item binding.",
+  },
+
+  Toggle: {
+    props: z.object({
+      label: z.string(),
+      pressed: z.boolean().nullable(),
+      variant: z.enum(["default", "outline"]).nullable(),
+    }),
+    events: ["change"],
+    description:
+      "Toggle button. Use { $bindState } on pressed for state binding.",
+  },
+
+  ToggleGroup: {
+    props: z.object({
+      items: z.array(
+        z.object({
+          label: z.string(),
+          value: z.string(),
+        }),
+      ),
+      type: z.enum(["single", "multiple"]).nullable(),
+      value: z.string().nullable(),
+    }),
+    events: ["change"],
+    description:
+      "Group of toggle buttons. Type 'single' (default) or 'multiple'. Use { $bindState } on value.",
+  },
+
+  ButtonGroup: {
+    props: z.object({
+      buttons: z.array(
+        z.object({
+          label: z.string(),
+          value: z.string(),
+        }),
+      ),
+      selected: z.string().nullable(),
+    }),
+    events: ["change"],
+    description:
+      "Segmented button group. Use { $bindState } on selected for selected value.",
+  },
+
+  Pagination: {
+    props: z.object({
+      totalPages: z.number(),
+      page: z.number().nullable(),
+    }),
+    events: ["change"],
+    description:
+      "Page navigation. Use { $bindState } on page for current page number.",
+  },
+};
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/**
+ * Type for a component definition
+ */
+export type ComponentDefinition = {
+  props: z.ZodType;
+  slots?: string[];
+  events?: string[];
+  description: string;
+  example?: Record<string, unknown>;
+};
+
+/**
+ * Infer the props type for a shadcn component by name.
+ * Derives the TypeScript type directly from the Zod schema,
+ * so component implementations stay in sync with catalog definitions.
+ *
+ * @example
+ * ```ts
+ * type CardProps = ShadcnProps<"Card">;
+ * // { title: string | null; description: string | null; ... }
+ * ```
+ */
+export type ShadcnProps<K extends keyof typeof shadcnComponentDefinitions> =
+  z.output<(typeof shadcnComponentDefinitions)[K]["props"]>;

+ 1199 - 0
packages/shadcn/src/components.tsx

@@ -0,0 +1,1199 @@
+"use client";
+
+import { useState } from "react";
+import {
+  useBoundProp,
+  useStateBinding,
+  useFieldValidation,
+  type BaseComponentProps,
+} from "@json-render/react";
+
+import { Button } from "./ui/button";
+import {
+  Card,
+  CardHeader,
+  CardTitle,
+  CardDescription,
+  CardContent,
+} from "./ui/card";
+import { Input } from "./ui/input";
+import { Label } from "./ui/label";
+import { Textarea } from "./ui/textarea";
+import { Checkbox } from "./ui/checkbox";
+import { Switch } from "./ui/switch";
+import { Progress } from "./ui/progress";
+import { Separator } from "./ui/separator";
+import { Alert, AlertTitle, AlertDescription } from "./ui/alert";
+import {
+  Dialog as DialogPrimitive,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "./ui/dialog";
+import {
+  Accordion as AccordionPrimitive,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from "./ui/accordion";
+import {
+  Avatar as AvatarPrimitive,
+  AvatarImage,
+  AvatarFallback,
+} from "./ui/avatar";
+import { Badge } from "./ui/badge";
+import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "./ui/select";
+import {
+  Carousel as CarouselPrimitive,
+  CarouselContent,
+  CarouselItem,
+  CarouselNext,
+  CarouselPrevious,
+} from "./ui/carousel";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "./ui/collapsible";
+import {
+  Table as TablePrimitive,
+  TableBody,
+  TableCaption,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "./ui/table";
+import {
+  Drawer as DrawerPrimitive,
+  DrawerContent,
+  DrawerDescription,
+  DrawerHeader,
+  DrawerTitle,
+} from "./ui/drawer";
+import {
+  DropdownMenu as DropdownMenuPrimitive,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import {
+  Pagination as PaginationPrimitive,
+  PaginationContent,
+  PaginationEllipsis,
+  PaginationItem,
+  PaginationLink,
+  PaginationNext,
+  PaginationPrevious,
+} from "./ui/pagination";
+import {
+  Popover as PopoverPrimitive,
+  PopoverContent,
+  PopoverTrigger,
+} from "./ui/popover";
+import { Skeleton } from "./ui/skeleton";
+import { Slider } from "./ui/slider";
+import {
+  Tabs as TabsPrimitive,
+  TabsList,
+  TabsTrigger,
+  TabsContent,
+} from "./ui/tabs";
+import { Toggle } from "./ui/toggle";
+import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
+import {
+  Tooltip as TooltipPrimitive,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "./ui/tooltip";
+import { cn } from "./lib/utils";
+
+import { type ShadcnProps } from "./catalog";
+
+// =============================================================================
+// Helpers
+// =============================================================================
+
+function getPaginationRange(
+  current: number,
+  total: number,
+): Array<number | "ellipsis"> {
+  if (total <= 7) {
+    return Array.from({ length: total }, (_, i) => i + 1);
+  }
+  const pages: Array<number | "ellipsis"> = [];
+  pages.push(1);
+  if (current > 3) {
+    pages.push("ellipsis");
+  }
+  const start = Math.max(2, current - 1);
+  const end = Math.min(total - 1, current + 1);
+  for (let i = start; i <= end; i++) {
+    pages.push(i);
+  }
+  if (current < total - 2) {
+    pages.push("ellipsis");
+  }
+  pages.push(total);
+  return pages;
+}
+
+// =============================================================================
+// Standard Component Implementations
+// =============================================================================
+
+/**
+ * Standard shadcn/ui component implementations.
+ *
+ * Pass to `defineRegistry()` from `@json-render/react` to create a
+ * component registry for rendering JSON specs with shadcn/ui components.
+ *
+ * @example
+ * ```ts
+ * import { defineRegistry } from "@json-render/react";
+ * import { shadcnComponents } from "@json-render/shadcn";
+ *
+ * const { registry } = defineRegistry(catalog, {
+ *   components: {
+ *     Card: shadcnComponents.Card,
+ *     Button: shadcnComponents.Button,
+ *   },
+ * });
+ * ```
+ */
+export const shadcnComponents = {
+  // ── Layout ────────────────────────────────────────────────────────────
+
+  Card: ({ props, children }: BaseComponentProps<ShadcnProps<"Card">>) => {
+    const maxWidthClass =
+      props.maxWidth === "sm"
+        ? "max-w-xs sm:min-w-[280px]"
+        : props.maxWidth === "md"
+          ? "max-w-sm sm:min-w-[320px]"
+          : props.maxWidth === "lg"
+            ? "max-w-md sm:min-w-[360px]"
+            : "w-full";
+    const centeredClass = props.centered ? "mx-auto" : "";
+
+    return (
+      <Card className={cn(maxWidthClass, centeredClass)}>
+        {(props.title || props.description) && (
+          <CardHeader>
+            {props.title && <CardTitle>{props.title}</CardTitle>}
+            {props.description && (
+              <CardDescription>{props.description}</CardDescription>
+            )}
+          </CardHeader>
+        )}
+        <CardContent className="flex flex-col gap-3">{children}</CardContent>
+      </Card>
+    );
+  },
+
+  Stack: ({ props, children }: BaseComponentProps<ShadcnProps<"Stack">>) => {
+    const isHorizontal = props.direction === "horizontal";
+    const gapMap: Record<string, string> = {
+      none: "gap-0",
+      sm: "gap-2",
+      md: "gap-3",
+      lg: "gap-4",
+    };
+    const alignMap: Record<string, string> = {
+      start: "items-start",
+      center: "items-center",
+      end: "items-end",
+      stretch: "items-stretch",
+    };
+    const justifyMap: Record<string, string> = {
+      start: "",
+      center: "justify-center",
+      end: "justify-end",
+      between: "justify-between",
+      around: "justify-around",
+    };
+
+    const gapClass = gapMap[props.gap ?? "md"] ?? "gap-3";
+    const alignClass = alignMap[props.align ?? "start"] ?? "items-start";
+    const justifyClass = justifyMap[props.justify ?? ""] ?? "";
+
+    return (
+      <div
+        className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
+      >
+        {children}
+      </div>
+    );
+  },
+
+  Grid: ({ props, children }: BaseComponentProps<ShadcnProps<"Grid">>) => {
+    const colsMap: Record<number, string> = {
+      1: "grid-cols-1",
+      2: "grid-cols-2",
+      3: "grid-cols-3",
+      4: "grid-cols-4",
+      5: "grid-cols-5",
+      6: "grid-cols-6",
+    };
+    const gridGapMap: Record<string, string> = {
+      sm: "gap-2",
+      md: "gap-3",
+      lg: "gap-4",
+    };
+
+    const n = Math.max(1, Math.min(6, props.columns ?? 1));
+    const cols = colsMap[n] ?? "grid-cols-1";
+    const gridGap = gridGapMap[props.gap ?? "md"] ?? "gap-3";
+
+    return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
+  },
+
+  Separator: ({ props }: BaseComponentProps<ShadcnProps<"Separator">>) => {
+    return (
+      <Separator
+        orientation={props.orientation ?? "horizontal"}
+        className={props.orientation === "vertical" ? "h-full mx-2" : "my-3"}
+      />
+    );
+  },
+
+  Tabs: ({
+    props,
+    children,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Tabs">>) => {
+    const tabs = props.tabs ?? [];
+    const [boundValue, setBoundValue] = useBoundProp<string>(
+      props.value as string | undefined,
+      bindings?.value,
+    );
+    const [localValue, setLocalValue] = useState(
+      props.defaultValue ?? tabs[0]?.value ?? "",
+    );
+    const isBound = !!bindings?.value;
+    const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
+    const setValue = isBound ? setBoundValue : setLocalValue;
+
+    return (
+      <TabsPrimitive
+        value={value}
+        onValueChange={(v) => {
+          setValue(v);
+          emit("change");
+        }}
+      >
+        <TabsList>
+          {tabs.map((tab) => (
+            <TabsTrigger key={tab.value} value={tab.value}>
+              {tab.label}
+            </TabsTrigger>
+          ))}
+        </TabsList>
+        {children}
+      </TabsPrimitive>
+    );
+  },
+
+  Accordion: ({ props }: BaseComponentProps<ShadcnProps<"Accordion">>) => {
+    const items = props.items ?? [];
+    const isMultiple = props.type === "multiple";
+
+    const itemElements = items.map((item, i) => (
+      <AccordionItem key={i} value={`item-${i}`}>
+        <AccordionTrigger>{item.title}</AccordionTrigger>
+        <AccordionContent>{item.content}</AccordionContent>
+      </AccordionItem>
+    ));
+
+    if (isMultiple) {
+      return (
+        <AccordionPrimitive type="multiple" className="w-full">
+          {itemElements}
+        </AccordionPrimitive>
+      );
+    }
+    return (
+      <AccordionPrimitive type="single" collapsible className="w-full">
+        {itemElements}
+      </AccordionPrimitive>
+    );
+  },
+
+  Collapsible: ({
+    props,
+    children,
+  }: BaseComponentProps<ShadcnProps<"Collapsible">>) => {
+    const [open, setOpen] = useState(props.defaultOpen ?? false);
+    return (
+      <Collapsible open={open} onOpenChange={setOpen} className="w-full">
+        <CollapsibleTrigger asChild>
+          <button className="flex w-full items-center justify-between rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-muted transition-colors">
+            {props.title}
+            <svg
+              className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+              strokeWidth={2}
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                d="M19 9l-7 7-7-7"
+              />
+            </svg>
+          </button>
+        </CollapsibleTrigger>
+        <CollapsibleContent className="pt-2">{children}</CollapsibleContent>
+      </Collapsible>
+    );
+  },
+
+  Dialog: ({ props, children }: BaseComponentProps<ShadcnProps<"Dialog">>) => {
+    const [open, setOpen] = useStateBinding<boolean>(props.openPath ?? "");
+    return (
+      <DialogPrimitive open={open ?? false} onOpenChange={(v) => setOpen(v)}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>{props.title}</DialogTitle>
+            {props.description && (
+              <DialogDescription>{props.description}</DialogDescription>
+            )}
+          </DialogHeader>
+          {children}
+        </DialogContent>
+      </DialogPrimitive>
+    );
+  },
+
+  Drawer: ({ props, children }: BaseComponentProps<ShadcnProps<"Drawer">>) => {
+    const [open, setOpen] = useStateBinding<boolean>(props.openPath ?? "");
+    return (
+      <DrawerPrimitive open={open ?? false} onOpenChange={(v) => setOpen(v)}>
+        <DrawerContent>
+          <DrawerHeader>
+            <DrawerTitle>{props.title}</DrawerTitle>
+            {props.description && (
+              <DrawerDescription>{props.description}</DrawerDescription>
+            )}
+          </DrawerHeader>
+          <div className="p-4">{children}</div>
+        </DrawerContent>
+      </DrawerPrimitive>
+    );
+  },
+
+  Carousel: ({ props }: BaseComponentProps<ShadcnProps<"Carousel">>) => {
+    const items = props.items ?? [];
+    return (
+      <CarouselPrimitive className="w-full">
+        <CarouselContent>
+          {items.map((item, i) => (
+            <CarouselItem
+              key={i}
+              className="basis-3/4 md:basis-1/2 lg:basis-1/3"
+            >
+              <div className="border border-border rounded-lg p-4 bg-card h-full">
+                {item.title && (
+                  <h4 className="font-semibold text-sm mb-1">{item.title}</h4>
+                )}
+                {item.description && (
+                  <p className="text-sm text-muted-foreground">
+                    {item.description}
+                  </p>
+                )}
+              </div>
+            </CarouselItem>
+          ))}
+        </CarouselContent>
+        <CarouselPrevious />
+        <CarouselNext />
+      </CarouselPrimitive>
+    );
+  },
+
+  // ── Data Display ──────────────────────────────────────────────────────
+
+  Table: ({ props }: BaseComponentProps<ShadcnProps<"Table">>) => {
+    const columns = props.columns ?? [];
+    const rows = (props.rows ?? []).map((row) => row.map(String));
+
+    return (
+      <div className="rounded-md border border-border overflow-hidden">
+        <TablePrimitive>
+          {props.caption && <TableCaption>{props.caption}</TableCaption>}
+          <TableHeader>
+            <TableRow>
+              {columns.map((col) => (
+                <TableHead key={col}>{col}</TableHead>
+              ))}
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {rows.map((row, i) => (
+              <TableRow key={i}>
+                {row.map((cell, j) => (
+                  <TableCell key={j}>{cell}</TableCell>
+                ))}
+              </TableRow>
+            ))}
+          </TableBody>
+        </TablePrimitive>
+      </div>
+    );
+  },
+
+  Heading: ({ props }: BaseComponentProps<ShadcnProps<"Heading">>) => {
+    const level = props.level ?? "h2";
+    const headingClass =
+      level === "h1"
+        ? "text-2xl font-bold"
+        : level === "h3"
+          ? "text-base font-semibold"
+          : level === "h4"
+            ? "text-sm font-semibold"
+            : "text-lg font-semibold";
+
+    if (level === "h1")
+      return <h1 className={`${headingClass} text-left`}>{props.text}</h1>;
+    if (level === "h3")
+      return <h3 className={`${headingClass} text-left`}>{props.text}</h3>;
+    if (level === "h4")
+      return <h4 className={`${headingClass} text-left`}>{props.text}</h4>;
+    return <h2 className={`${headingClass} text-left`}>{props.text}</h2>;
+  },
+
+  Text: ({ props }: BaseComponentProps<ShadcnProps<"Text">>) => {
+    const textClass =
+      props.variant === "caption"
+        ? "text-xs"
+        : props.variant === "muted"
+          ? "text-sm text-muted-foreground"
+          : props.variant === "lead"
+            ? "text-xl text-muted-foreground"
+            : props.variant === "code"
+              ? "font-mono text-sm bg-muted px-1.5 py-0.5 rounded"
+              : "text-sm";
+
+    if (props.variant === "code") {
+      return <code className={`${textClass} text-left`}>{props.text}</code>;
+    }
+    return <p className={`${textClass} text-left`}>{props.text}</p>;
+  },
+
+  Image: ({ props }: BaseComponentProps<ShadcnProps<"Image">>) => {
+    if (props.src) {
+      return (
+        <img
+          src={props.src}
+          alt={props.alt ?? ""}
+          width={props.width ?? undefined}
+          height={props.height ?? undefined}
+          className="rounded max-w-full"
+        />
+      );
+    }
+    return (
+      <div
+        className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground"
+        style={{ width: props.width ?? 80, height: props.height ?? 60 }}
+      >
+        {props.alt || "img"}
+      </div>
+    );
+  },
+
+  Avatar: ({ props }: BaseComponentProps<ShadcnProps<"Avatar">>) => {
+    const name = props.name || "?";
+    const initials = name
+      .split(" ")
+      .map((n) => n[0])
+      .join("")
+      .slice(0, 2)
+      .toUpperCase();
+    const sizeClass =
+      props.size === "lg"
+        ? "h-12 w-12"
+        : props.size === "sm"
+          ? "h-8 w-8"
+          : "h-10 w-10";
+
+    return (
+      <AvatarPrimitive className={sizeClass}>
+        {props.src && <AvatarImage src={props.src} alt={name} />}
+        <AvatarFallback>{initials}</AvatarFallback>
+      </AvatarPrimitive>
+    );
+  },
+
+  Badge: ({ props }: BaseComponentProps<ShadcnProps<"Badge">>) => {
+    return <Badge variant={props.variant ?? "default"}>{props.text}</Badge>;
+  },
+
+  Alert: ({ props }: BaseComponentProps<ShadcnProps<"Alert">>) => {
+    const variant = props.type === "error" ? "destructive" : "default";
+    const customClass =
+      props.type === "success"
+        ? "border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100"
+        : props.type === "warning"
+          ? "border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-100"
+          : props.type === "info"
+            ? "border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100"
+            : "";
+
+    return (
+      <Alert variant={variant} className={customClass}>
+        <AlertTitle>{props.title}</AlertTitle>
+        {props.message && <AlertDescription>{props.message}</AlertDescription>}
+      </Alert>
+    );
+  },
+
+  Progress: ({ props }: BaseComponentProps<ShadcnProps<"Progress">>) => {
+    const value = Math.min(100, Math.max(0, props.value || 0));
+    return (
+      <div className="space-y-2">
+        {props.label && (
+          <Label className="text-sm text-muted-foreground">{props.label}</Label>
+        )}
+        <Progress value={value} />
+      </div>
+    );
+  },
+
+  Skeleton: ({ props }: BaseComponentProps<ShadcnProps<"Skeleton">>) => {
+    return (
+      <Skeleton
+        className={props.rounded ? "rounded-full" : "rounded-md"}
+        style={{
+          width: props.width ?? "100%",
+          height: props.height ?? "1.25rem",
+        }}
+      />
+    );
+  },
+
+  Spinner: ({ props }: BaseComponentProps<ShadcnProps<"Spinner">>) => {
+    const sizeClass =
+      props.size === "lg"
+        ? "h-8 w-8"
+        : props.size === "sm"
+          ? "h-4 w-4"
+          : "h-6 w-6";
+    return (
+      <div className="flex items-center gap-2">
+        <svg
+          className={`${sizeClass} animate-spin text-muted-foreground`}
+          viewBox="0 0 24 24"
+          fill="none"
+        >
+          <circle
+            className="opacity-25"
+            cx="12"
+            cy="12"
+            r="10"
+            stroke="currentColor"
+            strokeWidth="4"
+          />
+          <path
+            className="opacity-75"
+            fill="currentColor"
+            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+          />
+        </svg>
+        {props.label && (
+          <span className="text-sm text-muted-foreground">{props.label}</span>
+        )}
+      </div>
+    );
+  },
+
+  Tooltip: ({ props }: BaseComponentProps<ShadcnProps<"Tooltip">>) => {
+    return (
+      <TooltipProvider>
+        <TooltipPrimitive>
+          <TooltipTrigger asChild>
+            <span className="text-sm underline decoration-dotted cursor-help">
+              {props.text}
+            </span>
+          </TooltipTrigger>
+          <TooltipContent>
+            <p>{props.content}</p>
+          </TooltipContent>
+        </TooltipPrimitive>
+      </TooltipProvider>
+    );
+  },
+
+  Popover: ({ props }: BaseComponentProps<ShadcnProps<"Popover">>) => {
+    return (
+      <PopoverPrimitive>
+        <PopoverTrigger asChild>
+          <Button variant="outline" className="text-sm">
+            {props.trigger}
+          </Button>
+        </PopoverTrigger>
+        <PopoverContent className="w-64">
+          <p className="text-sm">{props.content}</p>
+        </PopoverContent>
+      </PopoverPrimitive>
+    );
+  },
+
+  // ── Form Inputs ───────────────────────────────────────────────────────
+
+  Input: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Input">>) => {
+    const [boundValue, setBoundValue] = useBoundProp<string>(
+      props.value as string | undefined,
+      bindings?.value,
+    );
+    const [localValue, setLocalValue] = useState("");
+    const isBound = !!bindings?.value;
+    const value = isBound ? (boundValue ?? "") : localValue;
+    const setValue = isBound ? setBoundValue : setLocalValue;
+
+    const hasValidation = !!(bindings?.value && props.checks?.length);
+    const { errors, validate } = useFieldValidation(
+      bindings?.value ?? "",
+      hasValidation ? { checks: props.checks ?? [] } : undefined,
+    );
+
+    return (
+      <div className="space-y-2">
+        {props.label && (
+          <Label htmlFor={props.name ?? undefined}>{props.label}</Label>
+        )}
+        <Input
+          id={props.name ?? undefined}
+          name={props.name ?? undefined}
+          type={props.type ?? "text"}
+          placeholder={props.placeholder ?? ""}
+          value={value}
+          onChange={(e) => setValue(e.target.value)}
+          onKeyDown={(e) => {
+            if (e.key === "Enter") emit("submit");
+          }}
+          onFocus={() => emit("focus")}
+          onBlur={() => {
+            if (hasValidation) validate();
+            emit("blur");
+          }}
+        />
+        {errors.length > 0 && (
+          <p className="text-sm text-destructive">{errors[0]}</p>
+        )}
+      </div>
+    );
+  },
+
+  Textarea: ({
+    props,
+    bindings,
+  }: BaseComponentProps<ShadcnProps<"Textarea">>) => {
+    const [boundValue, setBoundValue] = useBoundProp<string>(
+      props.value as string | undefined,
+      bindings?.value,
+    );
+    const [localValue, setLocalValue] = useState("");
+    const isBound = !!bindings?.value;
+    const value = isBound ? (boundValue ?? "") : localValue;
+    const setValue = isBound ? setBoundValue : setLocalValue;
+
+    const hasValidation = !!(bindings?.value && props.checks?.length);
+    const { errors, validate } = useFieldValidation(
+      bindings?.value ?? "",
+      hasValidation ? { checks: props.checks ?? [] } : undefined,
+    );
+
+    return (
+      <div className="space-y-2">
+        {props.label && (
+          <Label htmlFor={props.name ?? undefined}>{props.label}</Label>
+        )}
+        <Textarea
+          id={props.name ?? undefined}
+          name={props.name ?? undefined}
+          placeholder={props.placeholder ?? ""}
+          rows={props.rows ?? 3}
+          value={value}
+          onChange={(e) => setValue(e.target.value)}
+          onBlur={() => {
+            if (hasValidation) validate();
+          }}
+        />
+        {errors.length > 0 && (
+          <p className="text-sm text-destructive">{errors[0]}</p>
+        )}
+      </div>
+    );
+  },
+
+  Select: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Select">>) => {
+    const [boundValue, setBoundValue] = useBoundProp<string>(
+      props.value as string | undefined,
+      bindings?.value,
+    );
+    const [localValue, setLocalValue] = useState<string>("");
+    const isBound = !!bindings?.value;
+    const value = isBound ? (boundValue ?? "") : localValue;
+    const setValue = isBound ? setBoundValue : setLocalValue;
+    const rawOptions = props.options ?? [];
+    const options = rawOptions.map((opt) =>
+      typeof opt === "string" ? opt : String(opt ?? ""),
+    );
+
+    const hasValidation = !!(bindings?.value && props.checks?.length);
+    const { errors, validate } = useFieldValidation(
+      bindings?.value ?? "",
+      hasValidation ? { checks: props.checks ?? [] } : undefined,
+    );
+
+    return (
+      <div className="space-y-2">
+        <Label>{props.label}</Label>
+        <Select
+          value={value}
+          onValueChange={(v) => {
+            setValue(v);
+            if (hasValidation) validate();
+            emit("change");
+          }}
+        >
+          <SelectTrigger className="w-full">
+            <SelectValue placeholder={props.placeholder ?? "Select..."} />
+          </SelectTrigger>
+          <SelectContent>
+            {options.map((opt, idx) => (
+              <SelectItem key={`${idx}-${opt}`} value={opt || `option-${idx}`}>
+                {opt}
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+        {errors.length > 0 && (
+          <p className="text-sm text-destructive">{errors[0]}</p>
+        )}
+      </div>
+    );
+  },
+
+  Checkbox: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Checkbox">>) => {
+    const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
+      props.checked as boolean | undefined,
+      bindings?.checked,
+    );
+    const [localChecked, setLocalChecked] = useState(!!props.checked);
+    const isBound = !!bindings?.checked;
+    const checked = isBound ? (boundChecked ?? false) : localChecked;
+    const setChecked = isBound ? setBoundChecked : setLocalChecked;
+
+    return (
+      <div className="flex items-center space-x-2">
+        <Checkbox
+          id={props.name ?? undefined}
+          checked={checked}
+          onCheckedChange={(c) => {
+            setChecked(c === true);
+            emit("change");
+          }}
+        />
+        <Label htmlFor={props.name ?? undefined} className="cursor-pointer">
+          {props.label}
+        </Label>
+      </div>
+    );
+  },
+
+  Radio: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Radio">>) => {
+    const rawOptions = props.options ?? [];
+    const options = rawOptions.map((opt) =>
+      typeof opt === "string" ? opt : String(opt ?? ""),
+    );
+    const [boundValue, setBoundValue] = useBoundProp<string>(
+      props.value as string | undefined,
+      bindings?.value,
+    );
+    const [localValue, setLocalValue] = useState(options[0] ?? "");
+    const isBound = !!bindings?.value;
+    const value = isBound ? (boundValue ?? "") : localValue;
+    const setValue = isBound ? setBoundValue : setLocalValue;
+
+    return (
+      <div className="space-y-2">
+        {props.label && <Label>{props.label}</Label>}
+        <RadioGroup
+          value={value}
+          onValueChange={(v) => {
+            setValue(v);
+            emit("change");
+          }}
+        >
+          {options.map((opt, idx) => (
+            <div key={`${idx}-${opt}`} className="flex items-center space-x-2">
+              <RadioGroupItem
+                value={opt || `option-${idx}`}
+                id={`${props.name}-${idx}-${opt}`}
+              />
+              <Label
+                htmlFor={`${props.name}-${idx}-${opt}`}
+                className="cursor-pointer"
+              >
+                {opt}
+              </Label>
+            </div>
+          ))}
+        </RadioGroup>
+      </div>
+    );
+  },
+
+  Switch: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Switch">>) => {
+    const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
+      props.checked as boolean | undefined,
+      bindings?.checked,
+    );
+    const [localChecked, setLocalChecked] = useState(!!props.checked);
+    const isBound = !!bindings?.checked;
+    const checked = isBound ? (boundChecked ?? false) : localChecked;
+    const setChecked = isBound ? setBoundChecked : setLocalChecked;
+
+    return (
+      <div className="flex items-center justify-between space-x-2">
+        <Label htmlFor={props.name ?? undefined} className="cursor-pointer">
+          {props.label}
+        </Label>
+        <Switch
+          id={props.name ?? undefined}
+          checked={checked}
+          onCheckedChange={(c) => {
+            setChecked(c);
+            emit("change");
+          }}
+        />
+      </div>
+    );
+  },
+
+  Slider: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Slider">>) => {
+    const [boundValue, setBoundValue] = useBoundProp<number>(
+      props.value as number | undefined,
+      bindings?.value,
+    );
+    const [localValue, setLocalValue] = useState(props.min ?? 0);
+    const isBound = !!bindings?.value;
+    const value = isBound ? (boundValue ?? props.min ?? 0) : localValue;
+    const setValue = isBound ? setBoundValue : setLocalValue;
+
+    return (
+      <div className="space-y-2">
+        {props.label && (
+          <div className="flex justify-between">
+            <Label className="text-sm">{props.label}</Label>
+            <span className="text-sm text-muted-foreground">{value}</span>
+          </div>
+        )}
+        <Slider
+          value={[value]}
+          min={props.min ?? 0}
+          max={props.max ?? 100}
+          step={props.step ?? 1}
+          onValueChange={(v) => {
+            setValue(v[0] ?? 0);
+            emit("change");
+          }}
+        />
+      </div>
+    );
+  },
+
+  // ── Actions ───────────────────────────────────────────────────────────
+
+  Button: ({ props, emit }: BaseComponentProps<ShadcnProps<"Button">>) => {
+    const variant =
+      props.variant === "danger"
+        ? "destructive"
+        : props.variant === "secondary"
+          ? "secondary"
+          : "default";
+
+    return (
+      <Button
+        variant={variant}
+        disabled={props.disabled ?? false}
+        onClick={() => emit("press")}
+      >
+        {props.label}
+      </Button>
+    );
+  },
+
+  Link: ({ props, on }: BaseComponentProps<ShadcnProps<"Link">>) => {
+    return (
+      <a
+        href={props.href ?? "#"}
+        className="text-primary underline-offset-4 hover:underline text-sm font-medium"
+        onClick={(e) => {
+          const press = on("press");
+          if (press.shouldPreventDefault) e.preventDefault();
+          press.emit();
+        }}
+      >
+        {props.label}
+      </a>
+    );
+  },
+
+  DropdownMenu: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"DropdownMenu">>) => {
+    const items = props.items ?? [];
+    const [, setBoundValue] = useBoundProp<string>(
+      props.value as string | undefined,
+      bindings?.value,
+    );
+    return (
+      <DropdownMenuPrimitive>
+        <DropdownMenuTrigger asChild>
+          <Button variant="outline">{props.label}</Button>
+        </DropdownMenuTrigger>
+        <DropdownMenuContent>
+          {items.map((item) => (
+            <DropdownMenuItem
+              key={item.value}
+              onClick={() => {
+                setBoundValue(item.value);
+                emit("select");
+              }}
+            >
+              {item.label}
+            </DropdownMenuItem>
+          ))}
+        </DropdownMenuContent>
+      </DropdownMenuPrimitive>
+    );
+  },
+
+  Toggle: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Toggle">>) => {
+    const [boundPressed, setBoundPressed] = useBoundProp<boolean>(
+      props.pressed as boolean | undefined,
+      bindings?.pressed,
+    );
+    const [localPressed, setLocalPressed] = useState(props.pressed ?? false);
+    const isBound = !!bindings?.pressed;
+    const pressed = isBound ? (boundPressed ?? false) : localPressed;
+    const setPressed = isBound ? setBoundPressed : setLocalPressed;
+
+    return (
+      <Toggle
+        variant={props.variant ?? "default"}
+        pressed={pressed}
+        onPressedChange={(v) => {
+          setPressed(v);
+          emit("change");
+        }}
+      >
+        {props.label}
+      </Toggle>
+    );
+  },
+
+  ToggleGroup: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"ToggleGroup">>) => {
+    const type = props.type ?? "single";
+    const items = props.items ?? [];
+    const [boundValue, setBoundValue] = useBoundProp<string>(
+      props.value as string | undefined,
+      bindings?.value,
+    );
+    const [localValue, setLocalValue] = useState(items[0]?.value ?? "");
+    const isBound = !!bindings?.value;
+    const value = isBound ? (boundValue ?? "") : localValue;
+    const setValue = isBound ? setBoundValue : setLocalValue;
+
+    if (type === "multiple") {
+      const selected = value ? value.split(",").filter(Boolean) : [];
+      return (
+        <ToggleGroup
+          type="multiple"
+          value={selected}
+          onValueChange={(v) => {
+            setValue(v.join(","));
+            emit("change");
+          }}
+        >
+          {items.map((item) => (
+            <ToggleGroupItem key={item.value} value={item.value}>
+              {item.label}
+            </ToggleGroupItem>
+          ))}
+        </ToggleGroup>
+      );
+    }
+
+    return (
+      <ToggleGroup
+        type="single"
+        value={value}
+        onValueChange={(v) => {
+          if (v) {
+            setValue(v);
+            emit("change");
+          }
+        }}
+      >
+        {items.map((item) => (
+          <ToggleGroupItem key={item.value} value={item.value}>
+            {item.label}
+          </ToggleGroupItem>
+        ))}
+      </ToggleGroup>
+    );
+  },
+
+  ButtonGroup: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"ButtonGroup">>) => {
+    const buttons = props.buttons ?? [];
+    const [boundSelected, setBoundSelected] = useBoundProp<string>(
+      props.selected as string | undefined,
+      bindings?.selected,
+    );
+    const [localValue, setLocalValue] = useState(buttons[0]?.value ?? "");
+    const isBound = !!bindings?.selected;
+    const value = isBound ? (boundSelected ?? "") : localValue;
+    const setValue = isBound ? setBoundSelected : setLocalValue;
+
+    return (
+      <div className="inline-flex rounded-md border border-border">
+        {buttons.map((btn, i) => (
+          <button
+            key={btn.value}
+            className={`px-3 py-1.5 text-sm transition-colors ${
+              value === btn.value
+                ? "bg-primary text-primary-foreground"
+                : "bg-background hover:bg-muted"
+            } ${i > 0 ? "border-l border-border" : ""} ${
+              i === 0 ? "rounded-l-md" : ""
+            } ${i === buttons.length - 1 ? "rounded-r-md" : ""}`}
+            onClick={() => {
+              setValue(btn.value);
+              emit("change");
+            }}
+          >
+            {btn.label}
+          </button>
+        ))}
+      </div>
+    );
+  },
+
+  Pagination: ({
+    props,
+    bindings,
+    emit,
+  }: BaseComponentProps<ShadcnProps<"Pagination">>) => {
+    const [boundPage, setBoundPage] = useBoundProp<number>(
+      props.page as number | undefined,
+      bindings?.page,
+    );
+    const currentPage = boundPage ?? 1;
+    const totalPages = props.totalPages ?? 1;
+    const pages = getPaginationRange(currentPage, totalPages);
+
+    return (
+      <PaginationPrimitive>
+        <PaginationContent>
+          <PaginationItem>
+            <PaginationPrevious
+              href="#"
+              onClick={(e) => {
+                e.preventDefault();
+                if (currentPage > 1) {
+                  setBoundPage(currentPage - 1);
+                  emit("change");
+                }
+              }}
+            />
+          </PaginationItem>
+          {pages.map((page, idx) =>
+            page === "ellipsis" ? (
+              <PaginationItem key={`ellipsis-${idx}`}>
+                <PaginationEllipsis />
+              </PaginationItem>
+            ) : (
+              <PaginationItem key={page}>
+                <PaginationLink
+                  href="#"
+                  isActive={page === currentPage}
+                  onClick={(e) => {
+                    e.preventDefault();
+                    setBoundPage(page);
+                    emit("change");
+                  }}
+                >
+                  {page}
+                </PaginationLink>
+              </PaginationItem>
+            ),
+          )}
+          <PaginationItem>
+            <PaginationNext
+              href="#"
+              onClick={(e) => {
+                e.preventDefault();
+                if (currentPage < totalPages) {
+                  setBoundPage(currentPage + 1);
+                  emit("change");
+                }
+              }}
+            />
+          </PaginationItem>
+        </PaginationContent>
+      </PaginationPrimitive>
+    );
+  },
+};

+ 9 - 0
packages/shadcn/src/index.ts

@@ -0,0 +1,9 @@
+// Component implementations
+export { shadcnComponents } from "./components";
+
+// Catalog definitions (also available via @json-render/shadcn/catalog)
+export {
+  shadcnComponentDefinitions,
+  type ComponentDefinition,
+  type ShadcnProps,
+} from "./catalog";

+ 6 - 0
packages/shadcn/src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 64 - 0
packages/shadcn/src/ui/accordion.tsx

@@ -0,0 +1,64 @@
+import * as React from "react";
+import { ChevronDownIcon } from "lucide-react";
+import { Accordion as AccordionPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Accordion({
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
+  return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
+}
+
+function AccordionItem({
+  className,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
+  return (
+    <AccordionPrimitive.Item
+      data-slot="accordion-item"
+      className={cn("border-b last:border-b-0", className)}
+      {...props}
+    />
+  );
+}
+
+function AccordionTrigger({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
+  return (
+    <AccordionPrimitive.Header className="flex">
+      <AccordionPrimitive.Trigger
+        data-slot="accordion-trigger"
+        className={cn(
+          "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
+      </AccordionPrimitive.Trigger>
+    </AccordionPrimitive.Header>
+  );
+}
+
+function AccordionContent({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
+  return (
+    <AccordionPrimitive.Content
+      data-slot="accordion-content"
+      className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
+      {...props}
+    >
+      <div className={cn("pt-0 pb-4", className)}>{children}</div>
+    </AccordionPrimitive.Content>
+  );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

+ 66 - 0
packages/shadcn/src/ui/alert.tsx

@@ -0,0 +1,66 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "../lib/utils";
+
+const alertVariants = cva(
+  "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+  {
+    variants: {
+      variant: {
+        default: "bg-card text-card-foreground",
+        destructive:
+          "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+);
+
+function Alert({
+  className,
+  variant,
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
+  return (
+    <div
+      data-slot="alert"
+      role="alert"
+      className={cn(alertVariants({ variant }), className)}
+      {...props}
+    />
+  );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-title"
+      className={cn(
+        "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function AlertDescription({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-description"
+      className={cn(
+        "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Alert, AlertTitle, AlertDescription };

+ 53 - 0
packages/shadcn/src/ui/avatar.tsx

@@ -0,0 +1,53 @@
+"use client";
+
+import * as React from "react";
+import { Avatar as AvatarPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Avatar({
+  className,
+  ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
+  return (
+    <AvatarPrimitive.Root
+      data-slot="avatar"
+      className={cn(
+        "relative flex size-10 shrink-0 overflow-hidden rounded-full",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function AvatarImage({
+  className,
+  ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
+  return (
+    <AvatarPrimitive.Image
+      data-slot="avatar-image"
+      className={cn("aspect-square size-full", className)}
+      {...props}
+    />
+  );
+}
+
+function AvatarFallback({
+  className,
+  ...props
+}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
+  return (
+    <AvatarPrimitive.Fallback
+      data-slot="avatar-fallback"
+      className={cn(
+        "bg-muted flex size-full items-center justify-center rounded-full",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };

+ 46 - 0
packages/shadcn/src/ui/badge.tsx

@@ -0,0 +1,46 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+const badgeVariants = cva(
+  "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+        secondary:
+          "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+        destructive:
+          "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+);
+
+function Badge({
+  className,
+  variant = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"span"> &
+  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+  const Comp = asChild ? Slot.Root : "span";
+
+  return (
+    <Comp
+      data-slot="badge"
+      data-variant={variant}
+      className={cn(badgeVariants({ variant }), className)}
+      {...props}
+    />
+  );
+}
+
+export { Badge, badgeVariants };

+ 64 - 0
packages/shadcn/src/ui/button.tsx

@@ -0,0 +1,64 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+const buttonVariants = cva(
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        destructive:
+          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        ghost:
+          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-9 px-4 py-2 has-[>svg]:px-3",
+        xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+        icon: "size-9",
+        "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+        "icon-sm": "size-8",
+        "icon-lg": "size-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  },
+);
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"button"> &
+  VariantProps<typeof buttonVariants> & {
+    asChild?: boolean;
+  }) {
+  const Comp = asChild ? Slot.Root : "button";
+
+  return (
+    <Comp
+      data-slot="button"
+      data-variant={variant}
+      data-size={size}
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  );
+}
+
+export { Button, buttonVariants };

+ 78 - 0
packages/shadcn/src/ui/card.tsx

@@ -0,0 +1,78 @@
+import * as React from "react";
+
+import { cn } from "../lib/utils";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card"
+      className={cn(
+        "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-header"
+      className={cn(
+        "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-title"
+      className={cn("leading-none font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-content"
+      className={cn("px-6", className)}
+      {...props}
+    />
+  );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-footer"
+      className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Card,
+  CardHeader,
+  CardFooter,
+  CardTitle,
+  CardDescription,
+  CardContent,
+};

+ 241 - 0
packages/shadcn/src/ui/carousel.tsx

@@ -0,0 +1,241 @@
+"use client";
+
+import * as React from "react";
+import useEmblaCarousel, {
+  type UseEmblaCarouselType,
+} from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+
+import { cn } from "../lib/utils";
+import { Button } from "./button";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+  opts?: CarouselOptions;
+  plugins?: CarouselPlugin;
+  orientation?: "horizontal" | "vertical";
+  setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+  carouselRef: ReturnType<typeof useEmblaCarousel>[0];
+  api: ReturnType<typeof useEmblaCarousel>[1];
+  scrollPrev: () => void;
+  scrollNext: () => void;
+  canScrollPrev: boolean;
+  canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext<CarouselContextProps | null>(null);
+
+function useCarousel() {
+  const context = React.useContext(CarouselContext);
+
+  if (!context) {
+    throw new Error("useCarousel must be used within a <Carousel />");
+  }
+
+  return context;
+}
+
+function Carousel({
+  orientation = "horizontal",
+  opts,
+  setApi,
+  plugins,
+  className,
+  children,
+  ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+  const [carouselRef, api] = useEmblaCarousel(
+    {
+      ...opts,
+      axis: orientation === "horizontal" ? "x" : "y",
+    },
+    plugins,
+  );
+  const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+  const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+  const onSelect = React.useCallback((api: CarouselApi) => {
+    if (!api) return;
+    setCanScrollPrev(api.canScrollPrev());
+    setCanScrollNext(api.canScrollNext());
+  }, []);
+
+  const scrollPrev = React.useCallback(() => {
+    api?.scrollPrev();
+  }, [api]);
+
+  const scrollNext = React.useCallback(() => {
+    api?.scrollNext();
+  }, [api]);
+
+  const handleKeyDown = React.useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>) => {
+      if (event.key === "ArrowLeft") {
+        event.preventDefault();
+        scrollPrev();
+      } else if (event.key === "ArrowRight") {
+        event.preventDefault();
+        scrollNext();
+      }
+    },
+    [scrollPrev, scrollNext],
+  );
+
+  React.useEffect(() => {
+    if (!api || !setApi) return;
+    setApi(api);
+  }, [api, setApi]);
+
+  React.useEffect(() => {
+    if (!api) return;
+    onSelect(api);
+    api.on("reInit", onSelect);
+    api.on("select", onSelect);
+
+    return () => {
+      api?.off("select", onSelect);
+    };
+  }, [api, onSelect]);
+
+  return (
+    <CarouselContext.Provider
+      value={{
+        carouselRef,
+        api: api,
+        opts,
+        orientation:
+          orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
+        scrollPrev,
+        scrollNext,
+        canScrollPrev,
+        canScrollNext,
+      }}
+    >
+      <div
+        onKeyDownCapture={handleKeyDown}
+        className={cn("relative", className)}
+        role="region"
+        aria-roledescription="carousel"
+        data-slot="carousel"
+        {...props}
+      >
+        {children}
+      </div>
+    </CarouselContext.Provider>
+  );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+  const { carouselRef, orientation } = useCarousel();
+
+  return (
+    <div
+      ref={carouselRef}
+      className="overflow-hidden"
+      data-slot="carousel-content"
+    >
+      <div
+        className={cn(
+          "flex",
+          orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
+          className,
+        )}
+        {...props}
+      />
+    </div>
+  );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+  const { orientation } = useCarousel();
+
+  return (
+    <div
+      role="group"
+      aria-roledescription="slide"
+      data-slot="carousel-item"
+      className={cn(
+        "min-w-0 shrink-0 grow-0 basis-full",
+        orientation === "horizontal" ? "pl-4" : "pt-4",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function CarouselPrevious({
+  className,
+  variant = "outline",
+  size = "icon",
+  ...props
+}: React.ComponentProps<typeof Button>) {
+  const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+  return (
+    <Button
+      data-slot="carousel-previous"
+      variant={variant}
+      size={size}
+      className={cn(
+        "absolute size-8 rounded-full",
+        orientation === "horizontal"
+          ? "top-1/2 -left-12 -translate-y-1/2"
+          : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
+        className,
+      )}
+      disabled={!canScrollPrev}
+      onClick={scrollPrev}
+      {...props}
+    >
+      <ArrowLeft />
+      <span className="sr-only">Previous slide</span>
+    </Button>
+  );
+}
+
+function CarouselNext({
+  className,
+  variant = "outline",
+  size = "icon",
+  ...props
+}: React.ComponentProps<typeof Button>) {
+  const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+  return (
+    <Button
+      data-slot="carousel-next"
+      variant={variant}
+      size={size}
+      className={cn(
+        "absolute size-8 rounded-full",
+        orientation === "horizontal"
+          ? "top-1/2 -right-12 -translate-y-1/2"
+          : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
+        className,
+      )}
+      disabled={!canScrollNext}
+      onClick={scrollNext}
+      {...props}
+    >
+      <ArrowRight />
+      <span className="sr-only">Next slide</span>
+    </Button>
+  );
+}
+
+export {
+  type CarouselApi,
+  Carousel,
+  CarouselContent,
+  CarouselItem,
+  CarouselPrevious,
+  CarouselNext,
+};

+ 32 - 0
packages/shadcn/src/ui/checkbox.tsx

@@ -0,0 +1,32 @@
+"use client";
+
+import * as React from "react";
+import { CheckIcon } from "lucide-react";
+import { Checkbox as CheckboxPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Checkbox({
+  className,
+  ...props
+}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+  return (
+    <CheckboxPrimitive.Root
+      data-slot="checkbox"
+      className={cn(
+        "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className,
+      )}
+      {...props}
+    >
+      <CheckboxPrimitive.Indicator
+        data-slot="checkbox-indicator"
+        className="grid place-content-center text-current transition-none"
+      >
+        <CheckIcon className="size-3.5" />
+      </CheckboxPrimitive.Indicator>
+    </CheckboxPrimitive.Root>
+  );
+}
+
+export { Checkbox };

+ 32 - 0
packages/shadcn/src/ui/collapsible.tsx

@@ -0,0 +1,32 @@
+import * as React from "react";
+import { Collapsible as CollapsiblePrimitive } from "radix-ui";
+
+function Collapsible({
+  ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
+  return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
+}
+
+function CollapsibleTrigger({
+  ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
+  return (
+    <CollapsiblePrimitive.CollapsibleTrigger
+      data-slot="collapsible-trigger"
+      {...props}
+    />
+  );
+}
+
+function CollapsibleContent({
+  ...props
+}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
+  return (
+    <CollapsiblePrimitive.CollapsibleContent
+      data-slot="collapsible-content"
+      {...props}
+    />
+  );
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };

+ 158 - 0
packages/shadcn/src/ui/dialog.tsx

@@ -0,0 +1,158 @@
+"use client";
+
+import * as React from "react";
+import { XIcon } from "lucide-react";
+import { Dialog as DialogPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+import { Button } from "./button";
+
+function Dialog({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+  return <DialogPrimitive.Root data-slot="dialog" {...props} />;
+}
+
+function DialogTrigger({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
+}
+
+function DialogPortal({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
+}
+
+function DialogClose({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Close>) {
+  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
+}
+
+function DialogOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+  return (
+    <DialogPrimitive.Overlay
+      data-slot="dialog-overlay"
+      className={cn(
+        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DialogContent({
+  className,
+  children,
+  showCloseButton = true,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Content> & {
+  showCloseButton?: boolean;
+}) {
+  return (
+    <DialogPortal data-slot="dialog-portal">
+      <DialogOverlay />
+      <DialogPrimitive.Content
+        data-slot="dialog-content"
+        className={cn(
+          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        {showCloseButton && (
+          <DialogPrimitive.Close
+            data-slot="dialog-close"
+            className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
+          >
+            <XIcon />
+            <span className="sr-only">Close</span>
+          </DialogPrimitive.Close>
+        )}
+      </DialogPrimitive.Content>
+    </DialogPortal>
+  );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="dialog-header"
+      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+      {...props}
+    />
+  );
+}
+
+function DialogFooter({
+  className,
+  showCloseButton = false,
+  children,
+  ...props
+}: React.ComponentProps<"div"> & {
+  showCloseButton?: boolean;
+}) {
+  return (
+    <div
+      data-slot="dialog-footer"
+      className={cn(
+        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+      {showCloseButton && (
+        <DialogPrimitive.Close asChild>
+          <Button variant="outline">Close</Button>
+        </DialogPrimitive.Close>
+      )}
+    </div>
+  );
+}
+
+function DialogTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Title>) {
+  return (
+    <DialogPrimitive.Title
+      data-slot="dialog-title"
+      className={cn("text-lg leading-none font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function DialogDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Description>) {
+  return (
+    <DialogPrimitive.Description
+      data-slot="dialog-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogOverlay,
+  DialogPortal,
+  DialogTitle,
+  DialogTrigger,
+};

+ 133 - 0
packages/shadcn/src/ui/drawer.tsx

@@ -0,0 +1,133 @@
+import * as React from "react";
+import { Drawer as DrawerPrimitive } from "vaul";
+
+import { cn } from "../lib/utils";
+
+function Drawer({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
+  return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
+}
+
+function DrawerTrigger({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
+  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
+}
+
+function DrawerPortal({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
+  return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
+}
+
+function DrawerClose({
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
+  return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
+}
+
+function DrawerOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
+  return (
+    <DrawerPrimitive.Overlay
+      data-slot="drawer-overlay"
+      className={cn(
+        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DrawerContent({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
+  return (
+    <DrawerPortal data-slot="drawer-portal">
+      <DrawerOverlay />
+      <DrawerPrimitive.Content
+        data-slot="drawer-content"
+        className={cn(
+          "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
+          "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
+          "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
+          "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
+          "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
+          className,
+        )}
+        {...props}
+      >
+        <div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
+        {children}
+      </DrawerPrimitive.Content>
+    </DrawerPortal>
+  );
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="drawer-header"
+      className={cn(
+        "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="drawer-footer"
+      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+      {...props}
+    />
+  );
+}
+
+function DrawerTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
+  return (
+    <DrawerPrimitive.Title
+      data-slot="drawer-title"
+      className={cn("text-foreground font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function DrawerDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
+  return (
+    <DrawerPrimitive.Description
+      data-slot="drawer-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Drawer,
+  DrawerPortal,
+  DrawerOverlay,
+  DrawerTrigger,
+  DrawerClose,
+  DrawerContent,
+  DrawerHeader,
+  DrawerFooter,
+  DrawerTitle,
+  DrawerDescription,
+};

+ 257 - 0
packages/shadcn/src/ui/dropdown-menu.tsx

@@ -0,0 +1,257 @@
+"use client";
+
+import * as React from "react";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function DropdownMenu({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
+}
+
+function DropdownMenuPortal({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
+  return (
+    <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
+  );
+}
+
+function DropdownMenuTrigger({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
+  return (
+    <DropdownMenuPrimitive.Trigger
+      data-slot="dropdown-menu-trigger"
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuContent({
+  className,
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
+  return (
+    <DropdownMenuPrimitive.Portal>
+      <DropdownMenuPrimitive.Content
+        data-slot="dropdown-menu-content"
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
+          className,
+        )}
+        {...props}
+      />
+    </DropdownMenuPrimitive.Portal>
+  );
+}
+
+function DropdownMenuGroup({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
+  return (
+    <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
+  );
+}
+
+function DropdownMenuItem({
+  className,
+  inset,
+  variant = "default",
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
+  inset?: boolean;
+  variant?: "default" | "destructive";
+}) {
+  return (
+    <DropdownMenuPrimitive.Item
+      data-slot="dropdown-menu-item"
+      data-inset={inset}
+      data-variant={variant}
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuCheckboxItem({
+  className,
+  children,
+  checked,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
+  return (
+    <DropdownMenuPrimitive.CheckboxItem
+      data-slot="dropdown-menu-checkbox-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      checked={checked}
+      {...props}
+    >
+      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+        <DropdownMenuPrimitive.ItemIndicator>
+          <CheckIcon className="size-4" />
+        </DropdownMenuPrimitive.ItemIndicator>
+      </span>
+      {children}
+    </DropdownMenuPrimitive.CheckboxItem>
+  );
+}
+
+function DropdownMenuRadioGroup({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
+  return (
+    <DropdownMenuPrimitive.RadioGroup
+      data-slot="dropdown-menu-radio-group"
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuRadioItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
+  return (
+    <DropdownMenuPrimitive.RadioItem
+      data-slot="dropdown-menu-radio-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    >
+      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+        <DropdownMenuPrimitive.ItemIndicator>
+          <CircleIcon className="size-2 fill-current" />
+        </DropdownMenuPrimitive.ItemIndicator>
+      </span>
+      {children}
+    </DropdownMenuPrimitive.RadioItem>
+  );
+}
+
+function DropdownMenuLabel({
+  className,
+  inset,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
+  inset?: boolean;
+}) {
+  return (
+    <DropdownMenuPrimitive.Label
+      data-slot="dropdown-menu-label"
+      data-inset={inset}
+      className={cn(
+        "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
+  return (
+    <DropdownMenuPrimitive.Separator
+      data-slot="dropdown-menu-separator"
+      className={cn("bg-border -mx-1 my-1 h-px", className)}
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="dropdown-menu-shortcut"
+      className={cn(
+        "text-muted-foreground ml-auto text-xs tracking-widest",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function DropdownMenuSub({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
+  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
+}
+
+function DropdownMenuSubTrigger({
+  className,
+  inset,
+  children,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
+  inset?: boolean;
+}) {
+  return (
+    <DropdownMenuPrimitive.SubTrigger
+      data-slot="dropdown-menu-sub-trigger"
+      data-inset={inset}
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+      <ChevronRightIcon className="ml-auto size-4" />
+    </DropdownMenuPrimitive.SubTrigger>
+  );
+}
+
+function DropdownMenuSubContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
+  return (
+    <DropdownMenuPrimitive.SubContent
+      data-slot="dropdown-menu-sub-content"
+      className={cn(
+        "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export {
+  DropdownMenu,
+  DropdownMenuPortal,
+  DropdownMenuTrigger,
+  DropdownMenuContent,
+  DropdownMenuGroup,
+  DropdownMenuLabel,
+  DropdownMenuItem,
+  DropdownMenuCheckboxItem,
+  DropdownMenuRadioGroup,
+  DropdownMenuRadioItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuSub,
+  DropdownMenuSubTrigger,
+  DropdownMenuSubContent,
+};

+ 21 - 0
packages/shadcn/src/ui/input.tsx

@@ -0,0 +1,21 @@
+import * as React from "react";
+
+import { cn } from "../lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+  return (
+    <input
+      type={type}
+      data-slot="input"
+      className={cn(
+        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
+        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Input };

+ 22 - 0
packages/shadcn/src/ui/label.tsx

@@ -0,0 +1,22 @@
+import * as React from "react";
+import { Label as LabelPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Label({
+  className,
+  ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+  return (
+    <LabelPrimitive.Root
+      data-slot="label"
+      className={cn(
+        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Label };

+ 127 - 0
packages/shadcn/src/ui/pagination.tsx

@@ -0,0 +1,127 @@
+import * as React from "react";
+import {
+  ChevronLeftIcon,
+  ChevronRightIcon,
+  MoreHorizontalIcon,
+} from "lucide-react";
+
+import { cn } from "../lib/utils";
+import { buttonVariants, type Button } from "./button";
+
+function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
+  return (
+    <nav
+      role="navigation"
+      aria-label="pagination"
+      data-slot="pagination"
+      className={cn("mx-auto flex w-full justify-center", className)}
+      {...props}
+    />
+  );
+}
+
+function PaginationContent({
+  className,
+  ...props
+}: React.ComponentProps<"ul">) {
+  return (
+    <ul
+      data-slot="pagination-content"
+      className={cn("flex flex-row items-center gap-1", className)}
+      {...props}
+    />
+  );
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+  return <li data-slot="pagination-item" {...props} />;
+}
+
+type PaginationLinkProps = {
+  isActive?: boolean;
+} & Pick<React.ComponentProps<typeof Button>, "size"> &
+  React.ComponentProps<"a">;
+
+function PaginationLink({
+  className,
+  isActive,
+  size = "icon",
+  ...props
+}: PaginationLinkProps) {
+  return (
+    <a
+      aria-current={isActive ? "page" : undefined}
+      data-slot="pagination-link"
+      data-active={isActive}
+      className={cn(
+        buttonVariants({
+          variant: isActive ? "outline" : "ghost",
+          size,
+        }),
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function PaginationPrevious({
+  className,
+  ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+  return (
+    <PaginationLink
+      aria-label="Go to previous page"
+      size="default"
+      className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
+      {...props}
+    >
+      <ChevronLeftIcon />
+      <span className="hidden sm:block">Previous</span>
+    </PaginationLink>
+  );
+}
+
+function PaginationNext({
+  className,
+  ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+  return (
+    <PaginationLink
+      aria-label="Go to next page"
+      size="default"
+      className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
+      {...props}
+    >
+      <span className="hidden sm:block">Next</span>
+      <ChevronRightIcon />
+    </PaginationLink>
+  );
+}
+
+function PaginationEllipsis({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      aria-hidden
+      data-slot="pagination-ellipsis"
+      className={cn("flex size-9 items-center justify-center", className)}
+      {...props}
+    >
+      <MoreHorizontalIcon className="size-4" />
+      <span className="sr-only">More pages</span>
+    </span>
+  );
+}
+
+export {
+  Pagination,
+  PaginationContent,
+  PaginationLink,
+  PaginationItem,
+  PaginationPrevious,
+  PaginationNext,
+  PaginationEllipsis,
+};

+ 89 - 0
packages/shadcn/src/ui/popover.tsx

@@ -0,0 +1,89 @@
+"use client";
+
+import * as React from "react";
+import { Popover as PopoverPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Popover({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+  return <PopoverPrimitive.Root data-slot="popover" {...props} />;
+}
+
+function PopoverTrigger({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
+}
+
+function PopoverContent({
+  className,
+  align = "center",
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+  return (
+    <PopoverPrimitive.Portal>
+      <PopoverPrimitive.Content
+        data-slot="popover-content"
+        align={align}
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
+          className,
+        )}
+        {...props}
+      />
+    </PopoverPrimitive.Portal>
+  );
+}
+
+function PopoverAnchor({
+  ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="popover-header"
+      className={cn("flex flex-col gap-1 text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
+  return (
+    <div
+      data-slot="popover-title"
+      className={cn("font-medium", className)}
+      {...props}
+    />
+  );
+}
+
+function PopoverDescription({
+  className,
+  ...props
+}: React.ComponentProps<"p">) {
+  return (
+    <p
+      data-slot="popover-description"
+      className={cn("text-muted-foreground", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Popover,
+  PopoverTrigger,
+  PopoverContent,
+  PopoverAnchor,
+  PopoverHeader,
+  PopoverTitle,
+  PopoverDescription,
+};

+ 29 - 0
packages/shadcn/src/ui/progress.tsx

@@ -0,0 +1,29 @@
+import * as React from "react";
+import { Progress as ProgressPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Progress({
+  className,
+  value,
+  ...props
+}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
+  return (
+    <ProgressPrimitive.Root
+      data-slot="progress"
+      className={cn(
+        "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
+        className,
+      )}
+      {...props}
+    >
+      <ProgressPrimitive.Indicator
+        data-slot="progress-indicator"
+        className="bg-primary h-full w-full flex-1 transition-all"
+        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
+      />
+    </ProgressPrimitive.Root>
+  );
+}
+
+export { Progress };

+ 45 - 0
packages/shadcn/src/ui/radio-group.tsx

@@ -0,0 +1,45 @@
+"use client";
+
+import * as React from "react";
+import { CircleIcon } from "lucide-react";
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function RadioGroup({
+  className,
+  ...props
+}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
+  return (
+    <RadioGroupPrimitive.Root
+      data-slot="radio-group"
+      className={cn("grid gap-3", className)}
+      {...props}
+    />
+  );
+}
+
+function RadioGroupItem({
+  className,
+  ...props
+}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
+  return (
+    <RadioGroupPrimitive.Item
+      data-slot="radio-group-item"
+      className={cn(
+        "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className,
+      )}
+      {...props}
+    >
+      <RadioGroupPrimitive.Indicator
+        data-slot="radio-group-indicator"
+        className="relative flex items-center justify-center"
+      >
+        <CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
+      </RadioGroupPrimitive.Indicator>
+    </RadioGroupPrimitive.Item>
+  );
+}
+
+export { RadioGroup, RadioGroupItem };

+ 188 - 0
packages/shadcn/src/ui/select.tsx

@@ -0,0 +1,188 @@
+import * as React from "react";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { Select as SelectPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Select({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+  return <SelectPrimitive.Root data-slot="select" {...props} />;
+}
+
+function SelectGroup({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Group>) {
+  return <SelectPrimitive.Group data-slot="select-group" {...props} />;
+}
+
+function SelectValue({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Value>) {
+  return <SelectPrimitive.Value data-slot="select-value" {...props} />;
+}
+
+function SelectTrigger({
+  className,
+  size = "default",
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
+  size?: "sm" | "default";
+}) {
+  return (
+    <SelectPrimitive.Trigger
+      data-slot="select-trigger"
+      data-size={size}
+      className={cn(
+        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+      <SelectPrimitive.Icon asChild>
+        <ChevronDownIcon className="size-4 opacity-50" />
+      </SelectPrimitive.Icon>
+    </SelectPrimitive.Trigger>
+  );
+}
+
+function SelectContent({
+  className,
+  children,
+  position = "item-aligned",
+  align = "center",
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+  return (
+    <SelectPrimitive.Portal>
+      <SelectPrimitive.Content
+        data-slot="select-content"
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
+          position === "popper" &&
+            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+          className,
+        )}
+        position={position}
+        align={align}
+        {...props}
+      >
+        <SelectScrollUpButton />
+        <SelectPrimitive.Viewport
+          className={cn(
+            "p-1",
+            position === "popper" &&
+              "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
+          )}
+        >
+          {children}
+        </SelectPrimitive.Viewport>
+        <SelectScrollDownButton />
+      </SelectPrimitive.Content>
+    </SelectPrimitive.Portal>
+  );
+}
+
+function SelectLabel({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Label>) {
+  return (
+    <SelectPrimitive.Label
+      data-slot="select-label"
+      className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+      {...props}
+    />
+  );
+}
+
+function SelectItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Item>) {
+  return (
+    <SelectPrimitive.Item
+      data-slot="select-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+        className,
+      )}
+      {...props}
+    >
+      <span
+        data-slot="select-item-indicator"
+        className="absolute right-2 flex size-3.5 items-center justify-center"
+      >
+        <SelectPrimitive.ItemIndicator>
+          <CheckIcon className="size-4" />
+        </SelectPrimitive.ItemIndicator>
+      </span>
+      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+    </SelectPrimitive.Item>
+  );
+}
+
+function SelectSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+  return (
+    <SelectPrimitive.Separator
+      data-slot="select-separator"
+      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
+      {...props}
+    />
+  );
+}
+
+function SelectScrollUpButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
+  return (
+    <SelectPrimitive.ScrollUpButton
+      data-slot="select-scroll-up-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className,
+      )}
+      {...props}
+    >
+      <ChevronUpIcon className="size-4" />
+    </SelectPrimitive.ScrollUpButton>
+  );
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
+  return (
+    <SelectPrimitive.ScrollDownButton
+      data-slot="select-scroll-down-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className,
+      )}
+      {...props}
+    >
+      <ChevronDownIcon className="size-4" />
+    </SelectPrimitive.ScrollDownButton>
+  );
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+};

+ 28 - 0
packages/shadcn/src/ui/separator.tsx

@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import { Separator as SeparatorPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Separator({
+  className,
+  orientation = "horizontal",
+  decorative = true,
+  ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+  return (
+    <SeparatorPrimitive.Root
+      data-slot="separator"
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Separator };

+ 13 - 0
packages/shadcn/src/ui/skeleton.tsx

@@ -0,0 +1,13 @@
+import { cn } from "../lib/utils";
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="skeleton"
+      className={cn("bg-accent animate-pulse rounded-md", className)}
+      {...props}
+    />
+  );
+}
+
+export { Skeleton };

+ 63 - 0
packages/shadcn/src/ui/slider.tsx

@@ -0,0 +1,63 @@
+"use client";
+
+import * as React from "react";
+import { Slider as SliderPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Slider({
+  className,
+  defaultValue,
+  value,
+  min = 0,
+  max = 100,
+  ...props
+}: React.ComponentProps<typeof SliderPrimitive.Root>) {
+  const _values = React.useMemo(
+    () =>
+      Array.isArray(value)
+        ? value
+        : Array.isArray(defaultValue)
+          ? defaultValue
+          : [min, max],
+    [value, defaultValue, min, max],
+  );
+
+  return (
+    <SliderPrimitive.Root
+      data-slot="slider"
+      defaultValue={defaultValue}
+      value={value}
+      min={min}
+      max={max}
+      className={cn(
+        "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
+        className,
+      )}
+      {...props}
+    >
+      <SliderPrimitive.Track
+        data-slot="slider-track"
+        className={cn(
+          "bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
+        )}
+      >
+        <SliderPrimitive.Range
+          data-slot="slider-range"
+          className={cn(
+            "bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
+          )}
+        />
+      </SliderPrimitive.Track>
+      {Array.from({ length: _values.length }, (_, index) => (
+        <SliderPrimitive.Thumb
+          data-slot="slider-thumb"
+          key={index}
+          className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
+        />
+      ))}
+    </SliderPrimitive.Root>
+  );
+}
+
+export { Slider };

+ 35 - 0
packages/shadcn/src/ui/switch.tsx

@@ -0,0 +1,35 @@
+"use client";
+
+import * as React from "react";
+import { Switch as SwitchPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Switch({
+  className,
+  size = "default",
+  ...props
+}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
+  size?: "sm" | "default";
+}) {
+  return (
+    <SwitchPrimitive.Root
+      data-slot="switch"
+      data-size={size}
+      className={cn(
+        "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
+        className,
+      )}
+      {...props}
+    >
+      <SwitchPrimitive.Thumb
+        data-slot="switch-thumb"
+        className={cn(
+          "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
+        )}
+      />
+    </SwitchPrimitive.Root>
+  );
+}
+
+export { Switch };

+ 114 - 0
packages/shadcn/src/ui/table.tsx

@@ -0,0 +1,114 @@
+import * as React from "react";
+
+import { cn } from "../lib/utils";
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+  return (
+    <div
+      data-slot="table-container"
+      className="relative w-full overflow-x-auto"
+    >
+      <table
+        data-slot="table"
+        className={cn("w-full caption-bottom text-sm", className)}
+        {...props}
+      />
+    </div>
+  );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+  return (
+    <thead
+      data-slot="table-header"
+      className={cn("[&_tr]:border-b", className)}
+      {...props}
+    />
+  );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+  return (
+    <tbody
+      data-slot="table-body"
+      className={cn("[&_tr:last-child]:border-0", className)}
+      {...props}
+    />
+  );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+  return (
+    <tfoot
+      data-slot="table-footer"
+      className={cn(
+        "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+  return (
+    <tr
+      data-slot="table-row"
+      className={cn(
+        "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+  return (
+    <th
+      data-slot="table-head"
+      className={cn(
+        "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+  return (
+    <td
+      data-slot="table-cell"
+      className={cn(
+        "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TableCaption({
+  className,
+  ...props
+}: React.ComponentProps<"caption">) {
+  return (
+    <caption
+      data-slot="table-caption"
+      className={cn("text-muted-foreground mt-4 text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Table,
+  TableHeader,
+  TableBody,
+  TableFooter,
+  TableHead,
+  TableRow,
+  TableCell,
+  TableCaption,
+};

+ 91 - 0
packages/shadcn/src/ui/tabs.tsx

@@ -0,0 +1,91 @@
+"use client";
+
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Tabs as TabsPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function Tabs({
+  className,
+  orientation = "horizontal",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      data-orientation={orientation}
+      orientation={orientation}
+      className={cn(
+        "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+const tabsListVariants = cva(
+  "rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
+  {
+    variants: {
+      variant: {
+        default: "bg-muted",
+        line: "gap-1 bg-transparent",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+);
+
+function TabsList({
+  className,
+  variant = "default",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.List> &
+  VariantProps<typeof tabsListVariants>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      data-variant={variant}
+      className={cn(tabsListVariants({ variant }), className)}
+      {...props}
+    />
+  );
+}
+
+function TabsTrigger({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+  return (
+    <TabsPrimitive.Trigger
+      data-slot="tabs-trigger"
+      className={cn(
+        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
+        "data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
+        "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TabsContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+  return (
+    <TabsPrimitive.Content
+      data-slot="tabs-content"
+      className={cn("flex-1 outline-none", className)}
+      {...props}
+    />
+  );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

+ 18 - 0
packages/shadcn/src/ui/textarea.tsx

@@ -0,0 +1,18 @@
+import * as React from "react";
+
+import { cn } from "../lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+  return (
+    <textarea
+      data-slot="textarea"
+      className={cn(
+        "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Textarea };

+ 83 - 0
packages/shadcn/src/ui/toggle-group.tsx

@@ -0,0 +1,83 @@
+"use client";
+
+import * as React from "react";
+import { type VariantProps } from "class-variance-authority";
+import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+import { toggleVariants } from "./toggle";
+
+const ToggleGroupContext = React.createContext<
+  VariantProps<typeof toggleVariants> & {
+    spacing?: number;
+  }
+>({
+  size: "default",
+  variant: "default",
+  spacing: 0,
+});
+
+function ToggleGroup({
+  className,
+  variant,
+  size,
+  spacing = 0,
+  children,
+  ...props
+}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
+  VariantProps<typeof toggleVariants> & {
+    spacing?: number;
+  }) {
+  return (
+    <ToggleGroupPrimitive.Root
+      data-slot="toggle-group"
+      data-variant={variant}
+      data-size={size}
+      data-spacing={spacing}
+      style={{ "--gap": spacing } as React.CSSProperties}
+      className={cn(
+        "group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
+        className,
+      )}
+      {...props}
+    >
+      <ToggleGroupContext.Provider value={{ variant, size, spacing }}>
+        {children}
+      </ToggleGroupContext.Provider>
+    </ToggleGroupPrimitive.Root>
+  );
+}
+
+function ToggleGroupItem({
+  className,
+  children,
+  variant,
+  size,
+  ...props
+}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
+  VariantProps<typeof toggleVariants>) {
+  const context = React.useContext(ToggleGroupContext);
+
+  return (
+    <ToggleGroupPrimitive.Item
+      data-slot="toggle-group-item"
+      data-variant={context.variant || variant}
+      data-size={context.size || size}
+      data-spacing={context.spacing}
+      className={cn(
+        toggleVariants({
+          variant: context.variant || variant,
+          size: context.size || size,
+        }),
+        "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
+        "data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+    </ToggleGroupPrimitive.Item>
+  );
+}
+
+export { ToggleGroup, ToggleGroupItem };

+ 45 - 0
packages/shadcn/src/ui/toggle.tsx

@@ -0,0 +1,45 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Toggle as TogglePrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+const toggleVariants = cva(
+  "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
+  {
+    variants: {
+      variant: {
+        default: "bg-transparent",
+        outline:
+          "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+      },
+      size: {
+        default: "h-9 px-2 min-w-9",
+        sm: "h-8 px-1.5 min-w-8",
+        lg: "h-10 px-2.5 min-w-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  },
+);
+
+function Toggle({
+  className,
+  variant,
+  size,
+  ...props
+}: React.ComponentProps<typeof TogglePrimitive.Root> &
+  VariantProps<typeof toggleVariants>) {
+  return (
+    <TogglePrimitive.Root
+      data-slot="toggle"
+      className={cn(toggleVariants({ variant, size, className }))}
+      {...props}
+    />
+  );
+}
+
+export { Toggle, toggleVariants };

+ 57 - 0
packages/shadcn/src/ui/tooltip.tsx

@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
+
+import { cn } from "../lib/utils";
+
+function TooltipProvider({
+  delayDuration = 0,
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
+  return (
+    <TooltipPrimitive.Provider
+      data-slot="tooltip-provider"
+      delayDuration={delayDuration}
+      {...props}
+    />
+  );
+}
+
+function Tooltip({
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
+  return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
+}
+
+function TooltipTrigger({
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
+  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
+}
+
+function TooltipContent({
+  className,
+  sideOffset = 0,
+  children,
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
+  return (
+    <TooltipPrimitive.Portal>
+      <TooltipPrimitive.Content
+        data-slot="tooltip-content"
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
+      </TooltipPrimitive.Content>
+    </TooltipPrimitive.Portal>
+  );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

+ 9 - 0
packages/shadcn/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@repo/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 16 - 0
packages/shadcn/tsup.config.ts

@@ -0,0 +1,16 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts", "src/catalog.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: [
+    "react",
+    "react-dom",
+    "@json-render/core",
+    "@json-render/react",
+    "zod",
+  ],
+});

文件差異過大導致無法顯示
+ 192 - 163
pnpm-lock.yaml


+ 151 - 0
skills/json-render-shadcn/SKILL.md

@@ -0,0 +1,151 @@
+---
+name: json-render-shadcn
+description: Pre-built shadcn/ui components for json-render. Use when working with @json-render/shadcn, adding standard UI components to a catalog, or building web UIs with Radix UI + Tailwind CSS components.
+---
+
+# @json-render/shadcn
+
+Pre-built shadcn/ui component definitions and implementations for json-render. Provides 30+ components built on Radix UI + Tailwind CSS.
+
+## Two Entry Points
+
+| Entry Point | Exports | Use For |
+|-------------|---------|---------|
+| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` | Catalog schemas (no React dependency, safe for server) |
+| `@json-render/shadcn` | `shadcnComponents` | React implementations |
+
+## Usage Pattern
+
+Pick the components you need from the standard definitions. Do not spread all definitions -- explicitly select what your app uses:
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
+import { defineRegistry } from "@json-render/react";
+import { shadcnComponents } from "@json-render/shadcn";
+
+// Catalog: pick definitions
+const catalog = defineCatalog(schema, {
+  components: {
+    Card: shadcnComponentDefinitions.Card,
+    Stack: shadcnComponentDefinitions.Stack,
+    Heading: shadcnComponentDefinitions.Heading,
+    Button: shadcnComponentDefinitions.Button,
+    Input: shadcnComponentDefinitions.Input,
+  },
+  actions: {},
+});
+
+// Registry: pick matching implementations
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: shadcnComponents.Card,
+    Stack: shadcnComponents.Stack,
+    Heading: shadcnComponents.Heading,
+    Button: shadcnComponents.Button,
+    Input: shadcnComponents.Input,
+  },
+});
+```
+
+> State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled by `ActionProvider` automatically. No need to declare them.
+
+## Extending with Custom Components
+
+Add custom components alongside standard ones:
+
+```typescript
+const catalog = defineCatalog(schema, {
+  components: {
+    // Standard
+    Card: shadcnComponentDefinitions.Card,
+    Stack: shadcnComponentDefinitions.Stack,
+
+    // Custom
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        value: z.string(),
+        trend: z.enum(["up", "down", "neutral"]).nullable(),
+      }),
+      description: "KPI metric display",
+    },
+  },
+  actions: {},
+});
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Card: shadcnComponents.Card,
+    Stack: shadcnComponents.Stack,
+    Metric: ({ props }) => <div>{props.label}: {props.value}</div>,
+  },
+});
+```
+
+## Available Components
+
+### Layout
+- **Card** - Container with optional title, description, maxWidth, centered
+- **Stack** - Flex container with direction, gap, align, justify
+- **Grid** - Grid layout with columns (number) and gap
+- **Separator** - Visual divider with orientation
+
+### Navigation
+- **Tabs** - Tabbed navigation with tabs array, defaultValue, value
+- **Accordion** - Collapsible sections with items array and type (single/multiple)
+- **Collapsible** - Single collapsible section with title
+- **Pagination** - Page navigation with totalPages and page
+
+### Overlay
+- **Dialog** - Modal dialog with title, description, openPath
+- **Drawer** - Bottom drawer with title, description, openPath
+- **Tooltip** - Hover tooltip with content and text
+- **Popover** - Click-triggered popover with trigger and content
+- **DropdownMenu** - Dropdown with label and items array
+
+### Content
+- **Heading** - Heading text with level (h1-h4)
+- **Text** - Paragraph with variant (body, caption, muted, lead, code)
+- **Image** - Image with alt, width, height
+- **Avatar** - User avatar with src, name, size
+- **Badge** - Status badge with text and variant (default, secondary, destructive, outline)
+- **Alert** - Alert banner with title, message, type (success, warning, info, error)
+- **Carousel** - Scrollable carousel with items array
+- **Table** - Data table with columns (string[]) and rows (string[][])
+
+### Feedback
+- **Progress** - Progress bar with value, max, label
+- **Skeleton** - Loading placeholder with width, height, rounded
+- **Spinner** - Loading spinner with size and label
+
+### Input
+- **Button** - Button with label, variant (primary, secondary, danger), disabled
+- **Link** - Anchor link with label and href
+- **Input** - Text input with label, name, type, placeholder, value, checks
+- **Textarea** - Multi-line input with label, name, placeholder, rows, value, checks
+- **Select** - Dropdown select with label, name, options (string[]), value, checks
+- **Checkbox** - Checkbox with label, name, checked
+- **Radio** - Radio group with label, name, options (string[]), value
+- **Switch** - Toggle switch with label, name, checked
+- **Slider** - Range slider with label, min, max, step, value
+- **Toggle** - Toggle button with label, pressed, variant
+- **ToggleGroup** - Group of toggles with items, type, value
+- **ButtonGroup** - Button group with buttons array and selected
+
+## Built-in Actions (from `@json-render/react`)
+
+These are built into the React schema and handled by `ActionProvider` automatically. They appear in prompts without needing to be declared in the catalog.
+
+- **setState** - Set a value at a state path (`{ statePath, value }`)
+- **pushState** - Push a value onto an array (`{ statePath, value, clearStatePath? }`)
+- **removeState** - Remove an array item by index (`{ statePath, index }`)
+
+## Important Notes
+
+- The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
+- Components use Tailwind CSS classes -- your app must have Tailwind configured
+- Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
+- Form inputs support `checks` for validation (type + message pairs)
+- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`

部分文件因文件數量過多而無法顯示