Files
whitemagic/.agents/skills/interface-kit/references/component-patterns.md
T
kbe 286bb9cab1 Add local agent workspace files and skills
Includes .agents/, additional .claude/skills/, and skills-lock.json.
2026-07-22 02:24:51 +02:00

18 KiB

Component Implementation Patterns

Deep-dive reference for building production interfaces with shadcn/ui, Radix UI, and modern React.


1. shadcn/ui Setup

npx shadcn@latest init
npx shadcn@latest add button input form card dialog select sheet toast

Key concepts:

  • Not an npm package -- components are copied into your project. You own the code and can modify it freely.
  • Built on Radix UI primitives, which provide accessibility out of the box (focus management, ARIA attributes, keyboard navigation).
  • Styled with Tailwind CSS utilities -- no CSS-in-JS runtime.
  • Required dependencies:
    • class-variance-authority (CVA) -- variant management
    • clsx -- conditional class joining
    • tailwind-merge -- deduplicates conflicting Tailwind classes
    • lucide-react -- icon library
    • tailwindcss-animate -- animation utilities

The cn() utility combines clsx and tailwind-merge:

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

2. CSS Variables for Theming (HSL Format)

shadcn uses HSL values without the hsl() wrapper so Tailwind can apply opacity modifiers:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    /* ... remaining dark overrides */
  }
}

Usage in tailwind.config.ts:

theme: {
  extend: {
    colors: {
      background: "hsl(var(--background))",
      foreground: "hsl(var(--foreground))",
      primary: {
        DEFAULT: "hsl(var(--primary))",
        foreground: "hsl(var(--primary-foreground))",
      },
      // ...
    },
    borderRadius: {
      lg: "var(--radius)",
      md: "calc(var(--radius) - 2px)",
      sm: "calc(var(--radius) - 4px)",
    },
  },
}

3. Button Patterns

Use CVA to define variants declaratively:

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 active:scale-[0.97]",
  {
    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",
    },
  }
)

Design rules:

  • Press feedback: active:scale-[0.97] gives tactile response without layout shift.
  • Focus ring: Always visible via focus-visible:ring-2. Never use outline: none without a replacement.
  • Loading state: Disable the button and show a spinner inline.
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
  VariantProps<typeof buttonVariants> {
  isLoading?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, isLoading, children, ...props }, ref) => (
    <button
      className={cn(buttonVariants({ variant, size, className }))}
      ref={ref}
      disabled={isLoading || props.disabled}
      {...props}
    >
      {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {children}
    </button>
  )
)

4. Form Patterns (React Hook Form + Zod)

Schema-first validation keeps validation logic co-located and type-safe:

import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"

const formSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  name: z.string().min(2).max(50),
})

type FormValues = z.infer<typeof formSchema>

The shadcn Form components wire React Hook Form to accessible markup:

function SignUpForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { email: "", password: "", name: "" },
    mode: "onBlur", // validate on blur, not keystroke
  })

  function onSubmit(values: FormValues) {
    // handle submission
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="you@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* ...more fields */}
        <Button type="submit" isLoading={form.formState.isSubmitting}>
          Sign Up
        </Button>
      </form>
    </Form>
  )
}

Accessibility rules:

  • FormMessage renders error text with aria-describedby linked to the input.
  • Inputs get aria-invalid="true" when in error state automatically.
  • Mark required fields with aria-required="true".
  • Validate on blur, not on every keystroke -- reduces noise and respects user flow.
  • Use progressive disclosure for complex forms: show additional fields only when relevant.

5. Card Patterns

import {
  Card, CardHeader, CardTitle, CardDescription,
  CardContent, CardFooter,
} from "@/components/ui/card"

<Card className="hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200">
  <CardHeader>
    <CardTitle>Project Settings</CardTitle>
    <CardDescription>Manage your project configuration.</CardDescription>
  </CardHeader>
  <CardContent>
    {/* form fields or content */}
  </CardContent>
  <CardFooter className="flex justify-between">
    <Button variant="outline">Cancel</Button>
    <Button>Save</Button>
  </CardFooter>
</Card>

Design rules:

  • Concentric border radius: Outer radius = inner radius + padding. If inner elements have rounded-md (6px) and padding is 16px, outer card should be rounded-xl (12px) or greater.
  • Layered shadows: Use multiple shadow values for natural depth -- shadow-sm at rest, shadow-lg on hover.
  • Hover lift: Subtle translateY(-2px) on hover, never more than 4px.
  • Use semantic color tokens (bg-card, text-card-foreground) so cards adapt to theme changes.

6. Dialog (Modal) Patterns

import {
  Dialog, DialogTrigger, DialogContent,
  DialogHeader, DialogTitle, DialogDescription,
  DialogFooter, DialogClose,
} from "@/components/ui/dialog"

<Dialog>
  <DialogTrigger asChild>
    <Button variant="outline">Edit Profile</Button>
  </DialogTrigger>
  <DialogContent className="sm:max-w-[425px]">
    <DialogHeader>
      <DialogTitle>Edit Profile</DialogTitle>
      <DialogDescription>
        Make changes to your profile here.
      </DialogDescription>
    </DialogHeader>
    <div className="grid gap-4 py-4">
      {/* form content */}
    </div>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button type="submit">Save changes</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Accessibility and interaction rules (handled by Radix):

  • Focus trap: Focus stays inside the modal while open. Tab wraps from last to first focusable element.
  • ESC to close: Always. No exceptions.
  • Click outside overlay: Closes the dialog by default.
  • aria-modal="true" is set automatically.
  • aria-labelledby points to DialogTitle, aria-describedby points to DialogDescription.
  • Restore focus: When dialog closes, focus returns to the trigger element.
  • Animation origin: transform-origin: center -- dialogs are an exception to the popover origin-from-trigger rule since they appear center-screen.

7. Select/Dropdown Patterns

import {
  Select, SelectTrigger, SelectValue,
  SelectContent, SelectItem, SelectGroup, SelectLabel,
} from "@/components/ui/select"

<Select>
  <SelectTrigger className="w-[180px]">
    <SelectValue placeholder="Select a fruit" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectLabel>Fruits</SelectLabel>
      <SelectItem value="apple">Apple</SelectItem>
      <SelectItem value="banana">Banana</SelectItem>
      <SelectItem value="blueberry">Blueberry</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

Interaction rules:

  • Keyboard navigation: Arrow keys to move between items, Enter/Space to select, ESC to close, type-ahead to jump to matching items.
  • ARIA: aria-haspopup="listbox" on trigger, aria-expanded toggles with open state.
  • Transform origin: Popover should animate from the trigger position (origin-aware), not from center.
  • Tooltip delay skip: If a user hovers over one select and then moves to another, skip the tooltip delay on the second hover.

8. Sheet (Slide-over) Patterns

import {
  Sheet, SheetTrigger, SheetContent,
  SheetHeader, SheetTitle, SheetDescription,
  SheetFooter, SheetClose,
} from "@/components/ui/sheet"

<Sheet>
  <SheetTrigger asChild>
    <Button variant="outline">Open Menu</Button>
  </SheetTrigger>
  <SheetContent side="right"> {/* "left" | "right" | "top" | "bottom" */}
    <SheetHeader>
      <SheetTitle>Navigation</SheetTitle>
      <SheetDescription>Browse sections of the app.</SheetDescription>
    </SheetHeader>
    <nav className="flex flex-col gap-2 py-4">
      {/* nav links */}
    </nav>
    <SheetFooter>
      <SheetClose asChild>
        <Button variant="outline">Close</Button>
      </SheetClose>
    </SheetFooter>
  </SheetContent>
</Sheet>

Use cases:

  • Mobile navigation: Slide from left with full-height overlay.
  • Detail panels: Slide from right to show item details without leaving the list view.
  • Filters: Slide from bottom on mobile for filter controls.

Sheets share the same accessibility behavior as Dialog: focus trap, ESC to close, overlay click to close, and focus restoration.


9. Toast/Notification Patterns

Using the shadcn Toast (or Sonner for a lighter API):

// With shadcn toast
import { useToast } from "@/components/ui/use-toast"

function SaveButton() {
  const { toast } = useToast()

  return (
    <Button
      onClick={() => {
        toast({
          title: "Changes saved",
          description: "Your settings have been updated.",
        })
      }}
    >
      Save
    </Button>
  )
}

// With Sonner (simpler API)
import { toast } from "sonner"

toast.success("Changes saved")
toast.error("Something went wrong")
toast.promise(saveSettings(), {
  loading: "Saving...",
  success: "Settings saved",
  error: "Could not save",
})

Design and accessibility rules:

  • Auto-dismiss: 3-5 seconds for informational toasts. Errors should persist or have longer duration.
  • aria-live="polite" -- screen readers announce without stealing focus.
  • CSS transitions, not keyframes -- toasts can be triggered rapidly; transitions handle interruption gracefully while keyframes restart from the beginning.
  • Pause timers when the browser tab is hidden (document.visibilityState).
  • Swipe to dismiss: Support horizontal swipe with momentum detection (velocity > threshold = dismiss, otherwise snap back).

10. Table Patterns

import {
  Table, TableHeader, TableBody, TableFooter,
  TableHead, TableRow, TableCell, TableCaption,
} from "@/components/ui/table"

<div className="overflow-x-auto rounded-md border">
  <Table>
    <TableCaption>A list of recent invoices.</TableCaption>
    <TableHeader>
      <TableRow>
        <TableHead className="w-[100px]">Invoice</TableHead>
        <TableHead>Status</TableHead>
        <TableHead className="text-right">Amount</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      {invoices.map((invoice) => (
        <TableRow key={invoice.id}>
          <TableCell className="font-medium">{invoice.id}</TableCell>
          <TableCell>{invoice.status}</TableCell>
          <TableCell className="text-right">{invoice.amount}</TableCell>
        </TableRow>
      ))}
    </TableBody>
  </Table>
</div>

Rules:

  • Responsive: Wrap table in overflow-x-auto container. Below tablet breakpoint, allow horizontal scroll rather than collapsing columns.
  • Sortable columns: Use aria-sort="ascending" or aria-sort="descending" on the active TableHead. Show a visual indicator (chevron icon).
  • Virtualization: For lists exceeding ~50 items, use @tanstack/react-virtual or similar to render only visible rows.
  • Row distinction: Use zebra striping (even:bg-muted/50) or subtle borders between rows. Never rely on color alone.

11. Chart Integration

When integrating charts (Recharts, Chart.js, or similar):

  • Match chart type to data intent:

    • Trend over time: line chart
    • Comparison across categories: bar chart
    • Part-of-whole: pie/donut chart
    • Distribution: histogram
    • Correlation: scatter plot
  • Accessible color palettes: Use colors distinguishable by colorblind users. Supplement with patterns, textures, or different shapes for data points.

  • Always include a legend and provide tooltips on hover/focus for precise values.

  • Screen reader alternative: Provide a visually hidden <table> with the same data so screen readers can access it.

  • Respect prefers-reduced-motion: Skip entrance animations or reduce them to simple fades when the user has requested reduced motion.

const prefersReducedMotion = window.matchMedia(
  "(prefers-reduced-motion: reduce)"
).matches

<LineChart data={data}>
  <Line
    type="monotone"
    dataKey="value"
    animationDuration={prefersReducedMotion ? 0 : 500}
  />
</LineChart>

12. Server Component Wrapping (Next.js)

Most shadcn/ui components use React state or event handlers and require "use client". Structure your components to keep data fetching in server components:

// app/dashboard/page.tsx (Server Component -- no "use client")
import { getProjects } from "@/lib/data"
import { ProjectList } from "./project-list"

export default async function DashboardPage() {
  const projects = await getProjects()
  return <ProjectList projects={projects} />
}
// app/dashboard/project-list.tsx (Client Component)
"use client"

import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"

interface ProjectListProps {
  projects: { id: string; name: string; status: string }[]
}

export function ProjectList({ projects }: ProjectListProps) {
  return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
      {projects.map((project) => (
        <Card key={project.id}>
          <CardHeader>
            <CardTitle>{project.name}</CardTitle>
          </CardHeader>
          <CardContent>
            <p>{project.status}</p>
            <Button variant="outline" size="sm">View</Button>
          </CardContent>
        </Card>
      ))}
    </div>
  )
}

The pattern: Server component fetches data, passes to client component as serializable props. This keeps the client bundle small and data fetching on the server.


13. CVA (class-variance-authority) Deep Dive

CVA lets you define component variants declaratively, replacing sprawling conditional class logic:

import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
        secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
        destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
        outline: "text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
  return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

Key patterns:

  • Compose with cn(): Always wrap CVA output with cn() so consumer-passed className can override defaults via tailwind-merge.
  • Type extraction: VariantProps<typeof badgeVariants> generates the TypeScript type for variant props automatically.
  • Compound variants: Handle combinations of variant values that need special styling:
const inputVariants = cva("...", {
  variants: {
    size: { sm: "...", lg: "..." },
    state: { error: "...", success: "..." },
  },
  compoundVariants: [
    { size: "sm", state: "error", class: "border-2 border-red-500" },
  ],
})
  • Use CVA for any component with visual variants -- buttons, badges, alerts, inputs, cards. It replaces manual if/else class concatenation with a declarative, type-safe API.