Custom Styled Components
Build reusable styled components with Tailwind, CVA variants, and polymorphic prop patterns
When to Use
- Creating a component library or design system with Tailwind
- Building components that need variant props AND className overrides
- Making components that can render as different HTML elements (
asprop) - Structuring a
components/ui/directory following shadcn/ui patterns
Instructions
- Combine CVA (variants) + cn (merge) + forwardRef (DOM access) for every shared component.
- Extend the native HTML element's props so consumers can pass any valid attribute.
- Always accept and merge
classNameso consumers can override styles. - Use
React.forwardReffor components that wrap native elements (needed for Radix, tooltips, focus management). - Export both the component and its variant function for reuse.
- Support the
asChildpattern (via Radix'sSlot) for polymorphic rendering.
// components/ui/button.tsx — complete production pattern
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-white hover:bg-destructive/90',
outline: 'border border-border bg-background hover:bg-muted/50',
secondary: 'bg-muted/30 text-foreground hover:bg-muted/50',
ghost: 'hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
// Usage
<Button>Default</Button>
<Button variant="destructive" size="lg">Delete</Button>
<Button variant="ghost" className="w-full">Full Width Ghost</Button>
{/* asChild — renders as a link, not a button */}
<Button asChild variant="link">
<a href="/docs">Documentation</a>
</Button>
// components/ui/input.tsx — simple styled wrapper
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
Details
The asChild pattern: Instead of an as prop (which breaks TypeScript), asChild uses Radix's Slot component to merge props onto the child element. The Button renders as whatever element is its child:
<Button asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
// Renders as <a> with all Button styles + Link behavior
Component file structure:
components/
ui/
button.tsx # CVA + forwardRef + cn
input.tsx # forwardRef + cn
dialog.tsx # Radix Dialog + Tailwind
card.tsx # Compound component (Card, CardHeader, CardContent, CardFooter)
Compound components:
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('rounded-lg border bg-card shadow-sm', className)} {...props} />;
}
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />;
}
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-6 pt-0', className)} {...props} />;
}
export { Card, CardHeader, CardContent };
Rules for component APIs:
- Always extend the native element's HTML attributes
- Always accept and merge
className - Always use
forwardReffor DOM elements - Use CVA for multi-variant components
- Use simple
cn()for single-style components
Source
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.