Skip to content

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