The component authoring contract
Every shadcn/ui component follows an implicit contract. Your custom components should follow the same rules if you want them to behave consistently with the built-in ones.
Rule 1: Use semantic color tokens, not hardcoded values. bg-primary, not bg-indigo-500. text-muted-foreground, not text-gray-500.
Rule 2: Accept className as a prop and merge it with cn(). This lets consumers override styles without forking.
Rule 3: Forward refs. Every interactive component should use React.forwardRef.
Rule 4: Use cva (class-variance-authority) for variant styles. This gives you a predictable API for component variants.
Anatomy of a shadcn-compatible component
Here's a custom Status Badge component built following the contract:
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const statusBadgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
success: "bg-green-500/15 text-green-700 dark:text-green-400",
warning: "bg-yellow-500/15 text-yellow-700 dark:text-yellow-400",
error: "bg-destructive/15 text-destructive",
neutral: "bg-muted text-muted-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface StatusBadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof statusBadgeVariants> {}
const StatusBadge = React.forwardRef<HTMLSpanElement, StatusBadgeProps>(
({ className, variant, ...props }, ref) => {
return (
<span
ref={ref}
className={cn(statusBadgeVariants({ variant }), className)}
{...props}
/>
);
}
);
StatusBadge.displayName = "StatusBadge";
export { StatusBadge, statusBadgeVariants };This component follows every rule. Semantic tokens (bg-primary, text-destructive, bg-muted). Accepts and merges className. Forwards ref. Uses cva for variants. Switch themes and it adapts automatically.
Where to put custom components
shadcn/ui installs components to components/ui/. Put your custom components in the same directory. This keeps the import path consistent:
components/
ui/
button.tsx /* shadcn built-in */
card.tsx /* shadcn built-in */
status-badge.tsx /* your custom component */
metric-card.tsx /* your custom component */If a shadcn CLI update adds a component with the same name as yours, you'll get a conflict. Name your custom components specifically (status-badge, not badge-v2) to avoid collisions.
Composing with existing components
The most powerful pattern: build custom components by composing shadcn primitives. This way, your components inherit all theme compatibility and accessibility for free.
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface MetricCardProps {
title: string;
value: string;
change?: string;
trend?: "up" | "down" | "neutral";
className?: string;
}
export function MetricCard({ title, value, change, trend, className }: MetricCardProps) {
return (
<Card className={cn("", className)}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{change && (
<Badge variant={trend === "up" ? "default" : "secondary"}>
{change}
</Badge>
)}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
</CardContent>
</Card>
);
}This MetricCard uses Card, CardContent, CardHeader, CardTitle, and Badge from shadcn. Change the theme and every piece adapts. No custom color logic needed.
Adding custom CSS variables
Sometimes you need component-specific tokens that don't exist in the default theme. Add them to your globals.css following the same pattern:
:root {
/* Custom tokens for your components */
--success: 142 76% 36%;
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 100%;
--info: 199 89% 48%;
--info-foreground: 0 0% 100%;
}
.dark {
--success: 142 70% 45%;
--success-foreground: 142 76% 10%;
--warning: 38 90% 55%;
--warning-foreground: 38 92% 10%;
--info: 199 85% 55%;
--info-foreground: 199 89% 10%;
}Then register them in your Tailwind config (v3) or @theme (v4) so you can use utilities like bg-success and text-warning.
Handling updates from the shadcn CLI
When shadcn releases component updates, the CLI can overwrite your modifications. Protect yourself:
1. Don't modify built-in components directly. If you need a modified Button, create a custom component that wraps or extends the original.
2. Use composition over modification. Instead of changing button.tsx, create brand-button.tsx that uses Button internally.
3. Track your overrides. If you must modify a built-in component, add a comment at the top noting what you changed and why. When the CLI updates the file, you can re-apply your changes.
/* brand-button.tsx */
/* Wraps shadcn Button with brand-specific defaults */
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function BrandButton({ className, ...props }: ButtonProps) {
return (
<Button
className={cn("rounded-full font-semibold tracking-tight", className)}
{...props}
/>
);
}Making components theme-aware
The ultimate test: does your component look correct in every theme? Not just light and dark. If someone swaps the entire color palette via CSS variables, does your component adapt?
If you followed the contract (semantic tokens, no hardcoded colors), the answer is yes. This is exactly what makes SeedFlip design seeds work. Each seed exports a complete set of CSS variables. Paste them in, and every component (built-in and custom) that uses semantic tokens gets the new look automatically.
Custom components don't have to fight the system. Follow the contract, use semantic tokens, and compose with existing primitives. For the foundational globals.css structure these tokens live in, read How to Customize globals.css. For understanding which design variables actually impact components, see Design Variables That Actually Matter. And for browsing pre-built theme configurations to test your components against, try shadcn/ui Theme Generator.