Architecture

Three Files Per Button: Prim's Component Architecture Explained

P

Prim UI Team

Prim UI · Feb 8, 2026 · 9 min read

Every component in Prim has three files. This sounds like overengineering until you understand the problem it solves: the editor's live preview and the exported production code have fundamentally different requirements, and conflating them creates a mess.

The Three Layers

The Definition file is pure data — a TypeScript object that declares the component's variants, overrides, animation slots, and metadata. No React, no CSS. It's the contract that both the preview and code generation layers consume. When you add a new variant to a button, you add it here and both layers pick it up.

The Preview file renders the component in Prim's editor. It reads CSS custom properties from the Zustand store and updates in real-time as you adjust tokens. This layer prioritizes interactivity: hover states respond to your animation settings, colors update as you drag the picker, border radius changes are instant. It uses runtime CSS variables because that's what makes live editing possible.

The Code Generation layer produces the component you'll actually ship. It resolves CSS variables to concrete Tailwind classes or vanilla CSS values, inlines animation configurations, and generates self-contained React components. This layer prioritizes portability: the output should work in any React project with zero dependencies on Prim's runtime.

The key insight is that these layers evolve independently. You can add a new preview interaction (like a hover effect preview) without changing what gets exported. You can add a new export format (say, Vue components) without touching the editor. And the definition layer ensures both sides stay synchronized on what variants and overrides exist.

example.ts
// 1. Definition — pure data contract
// src/prim-components/button/button.def.ts
export const buttonDef: ComponentDef = {
  id: "button",
  variants: [
    { name: "primary", label: "Primary" },
    { name: "secondary", label: "Secondary" },
    { name: "outline", label: "Outline" },
    { name: "ghost", label: "Ghost" },
  ],
  overrides: [
    { key: "paddingX", label: "Padding X", type: "range",
      min: 8, max: 40, step: 2, unit: "px", defaultValue: 20 },
    { key: "paddingY", label: "Padding Y", type: "range",
      min: 4, max: 20, step: 2, unit: "px", defaultValue: 10 },
  ],
  animationSlots: [
    { name: "hover", label: "Hover", defaultEnabled: true },
    { name: "tap", label: "Tap", defaultEnabled: true },
  ],
};

// 2. Preview — runtime CSS vars, reactive
// src/prim-components/button/ButtonPreview.tsx
export function ButtonPreview({ variant, overrides }) {
  return (
    <button style={{
      background: "var(--prim-color-primary-500)",
      padding: `${overrides.paddingY}px ${overrides.paddingX}px`,
      borderRadius: "var(--prim-radius)",
    }}>
      {variant}
    </button>
  );
}

// 3. Code Gen — static Tailwind output
// src/prim-components/button/button.template.ts
export function generateButton(tokens, variant) {
  return `<button className="bg-primary-500
    px-4 py-2 rounded-lg">`;
}
PropertyTypeDefault
LayerFile patternResponsibility
Definition*.def.tsDeclares variants, overrides, slots
Preview*Preview.tsxLive editor rendering with CSS vars
Code Gen*.template.tsStatic Tailwind/CSS output

Same source of truth, two different consumption patterns. The preview layer reads from the store reactively. The code generation layer reads at export time, resolving everything to static values.

prim ui
EditorBlogPrivacy PolicyTerms
© 2026 prim ui