Tailwind CSS + shadcn/ui — Component Patterns¶
OWNER: alexander ALSO_USED_BY: floris, floor LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: shadcn ^3.8.4, class-variance-authority ^0.7.1, radix-ui ^1.4.3
Overview¶
This page covers how GE builds, customizes, and composes UI components. shadcn/ui is the base layer. CVA handles variants. Radix provides accessibility. Agents use this page when building new components or extending existing ones.
CVA (Class Variance Authority) — Variant System¶
CVA is the standard way to define component variants in GE projects. Every component with visual variants MUST use CVA.
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
// Base classes — always applied
"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-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
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",
},
}
)
CHECK: A component has multiple visual states. IF: The states are prop-driven (variant, size, color). THEN: Use CVA to define them.
ANTI_PATTERN: Ternary chains for styling: className={isLarge ? "px-8" : isPrimary ? "bg-blue" : "bg-gray"}.
FIX: Define variants in CVA and pass props: <Button variant="primary" size="lg">.
Component Template (GE Standard)¶
Every GE component follows this structure:
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const componentVariants = cva("base-classes", {
variants: { /* ... */ },
defaultVariants: { /* ... */ },
})
export interface ComponentProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof componentVariants> {
// Additional props here
}
const Component = React.forwardRef<HTMLDivElement, ComponentProps>(
({ className, variant, size, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(componentVariants({ variant, size }), className)}
{...props}
/>
)
}
)
Component.displayName = "Component"
export { Component, componentVariants }
CHECK: A new component is being created.
THEN: Follow this template exactly.
THEN: Always use React.forwardRef — parent components need ref access.
THEN: Always export both the component AND the variants.
THEN: Always accept className and merge with cn().
Compound Components¶
For complex UI with multiple related sub-components, use compound component pattern.
// Card compound component
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
)
)
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
)
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
)
)
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CHECK: A component has distinct sections (header, body, footer). IF: Each section is optional or independently stylable. THEN: Use compound components — one export per sub-component.
ANTI_PATTERN: A single component with headerContent, bodyContent, footerContent props.
FIX: Use compound components for composition: <Card><CardHeader>...</CardHeader></Card>.
Extending shadcn Components¶
When a shadcn base component needs GE-specific behavior:
// components/app-button.tsx — GE wrapper around shadcn Button
import { Button, type ButtonProps } from "@/components/ui/button"
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
interface AppButtonProps extends ButtonProps {
loading?: boolean
}
export function AppButton({ loading, disabled, children, className, ...props }: AppButtonProps) {
return (
<Button
disabled={disabled || loading}
className={cn(loading && "cursor-wait", className)}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</Button>
)
}
CHECK: You need loading states, analytics, or extra behavior on a shadcn component.
THEN: Create a wrapper in components/ — never modify components/ui/.
Form Components¶
GE uses shadcn Form primitives with react-hook-form and Zod.
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
})
export function ContactForm() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}
CHECK: A form is being built. THEN: Use react-hook-form + Zod + shadcn Form components — this is the GE standard. THEN: Validation schema lives in a separate file if reused across forms.
ANTI_PATTERN: Uncontrolled forms with manual onChange handlers and useState.
FIX: Use react-hook-form — it manages state, validation, and error display.
Data Table Pattern¶
GE uses TanStack Table with shadcn's DataTable component.
CHECK: You need a table with sorting, filtering, or pagination.
THEN: Use @tanstack/react-table with shadcn Table primitives.
THEN: Column definitions go in a separate columns.tsx file.
THEN: Table toolbar (filters, search) is a separate component.
ANTI_PATTERN: Building custom table sorting/filtering logic. FIX: TanStack Table handles all table state — use its APIs.
Dialog / Sheet / Drawer Pattern¶
// Controlled dialog with state lifted to parent
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
interface EditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
item: Item
}
export function EditDialog({ open, onOpenChange, item }: EditDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit {item.name}</DialogTitle>
</DialogHeader>
{/* Content */}
</DialogContent>
</Dialog>
)
}
CHECK: A modal/dialog/sheet is needed.
THEN: Lift open state to parent — controlled mode.
THEN: Use onOpenChange callback — Radix handles Escape, backdrop click, focus trap.
ANTI_PATTERN: Using e.stopPropagation() or manual focus trapping in dialogs.
FIX: Radix handles all of this — removing it breaks accessibility.
Icon Usage¶
GE uses Lucide React (lucide-react) for icons.
CHECK: An icon is needed.
THEN: Import from lucide-react — tree-shaken, consistent with shadcn.
THEN: Always set className="h-4 w-4" (or appropriate size) — icons have no default size.
ANTI_PATTERN: Mixing icon libraries (Heroicons + Lucide + custom SVG). FIX: Use Lucide exclusively unless a specific icon is unavailable.
Accessibility Rules¶
CHECK: Every interactive element.
THEN: Must be keyboard-navigable (Tab, Enter, Escape, Arrow keys).
THEN: Must have visible focus ring (shadcn provides this via focus-visible:ring-2).
THEN: Must have appropriate ARIA attributes (Radix provides these automatically).
CHECK: Color contrast. THEN: All text meets WCAG 2.1 AA (4.5:1 for normal text, 3:1 for large text). THEN: Never use color alone to convey information — add text or icons.
ANTI_PATTERN: Adding outline-none without focus-visible replacement.
FIX: Use focus-visible:outline-none focus-visible:ring-2 — hides outline for mouse, shows for keyboard.
Cross-References¶
READ_ALSO: wiki/docs/stack/tailwind-shadcn/index.md READ_ALSO: wiki/docs/stack/tailwind-shadcn/design-tokens.md READ_ALSO: wiki/docs/stack/tailwind-shadcn/pitfalls.md READ_ALSO: wiki/docs/stack/tailwind-shadcn/checklist.md READ_ALSO: wiki/docs/stack/vitest/patterns.md READ_ALSO: wiki/docs/stack/playwright/patterns.md