shadcn-ui

shadcn/ui Component Usage

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "shadcn-ui" with this command: npx skills add josechifflet/architecture-patterns/josechifflet-architecture-patterns-shadcn-ui

shadcn/ui Component Usage

Purpose

Provide comprehensive patterns for implementing shadcn/ui components in Next.js 16 applications with Atlas-specific conventions. Focus on composition over props, accessibility-first design, and type-safe integration with tRPC and react-hook-form.

When To Use This Skill

Component Implementation:

  • Adding new shadcn/ui components to the project

  • Customizing component variants and styles

  • Building composite components from primitives (Dialog, Form, Table)

  • Implementing responsive mobile/desktop patterns

Form Integration:

  • Integrating react-hook-form with shadcn Form components

  • Validating forms with Zod schemas

  • Connecting forms to tRPC mutations

  • Handling form state and errors

Data Display:

  • Creating data tables with sorting and filtering

  • Building card layouts and list views

  • Implementing skeleton loading states

Interactive Patterns:

  • Building modal dialogs and drawers (sheets)

  • Implementing toast notifications

  • Creating dropdown menus and popovers

  • Adding tooltips and hover states

Accessibility:

  • Ensuring keyboard navigation works correctly

  • Adding proper ARIA labels (especially icon buttons)

  • Implementing focus management

  • Meeting WCAG 2.1 AAA standards (44px minimum touch targets)

Core Principles

  1. Copy-Not-Import Philosophy

shadcn/ui components are copied into the project, not imported as dependencies. Customize directly in src/components/ui/ .

// ✅ Customize directly // src/components/ui/button.tsx const buttonVariants = cva("...", { variants: { variant: { default: "bg-primary text-primary-foreground", // Add your custom variant atlas: "bg-blue-600 text-white hover:bg-blue-700", }, }, });

  1. Composition Over Props

Build complex components by composing primitives rather than adding props:

// ❌ Avoid: Too many props <Dialog title="Delete Item" description="Are you sure?" showCloseButton={true} size="lg" />

// ✅ Prefer: Composition <Dialog> <DialogContent> <DialogHeader> <DialogTitle>Delete Item</DialogTitle> <DialogDescription>Are you sure?</DialogDescription> </DialogHeader> </DialogContent> </Dialog>

  1. className Customization

Extend styles via className prop using Tailwind utilities:

<Button variant="default" className="w-full sm:w-auto shadow-lg"> Submit </Button>

  1. Accessibility First

All components are accessible by default (built on Radix UI):

  • Keyboard navigation

  • Screen reader support

  • ARIA attributes

  • Focus management

Icon buttons require labels for accessibility:

// ❌ Inaccessible <Button size="icon"> <TrashIcon /> </Button>

// ✅ Accessible <Button size="icon" aria-label="Delete item"> <TrashIcon /> </Button>

Quick Reference

Common Components

Component Use Case Key Features

Button

Actions, triggers Variants, sizes, loading state

Input

Text entry Types, validation states

Form

Form validation react-hook-form integration

Dialog

Modals Portal, overlay, animations

Sheet

Side panels Mobile-friendly drawers

Table

Data display Semantic HTML, responsive

Select

Dropdowns Searchable, keyboard nav

Checkbox

Boolean input Indeterminate state

Label

Form labels Auto-linked to inputs

Textarea

Multi-line input Auto-resize support

Tabs

Navigation Keyboard accessible

Card

Content containers Header, content, footer

Badge

Status labels Variants for states

Skeleton

Loading states Placeholder UI

Separator

Visual dividers Horizontal/vertical

Dropdown Menu

Actions menu Nested menus, shortcuts

Alert

Notifications Info, warning, error

Progress

Loading indicators Determinate/indeterminate

Tooltip

Hover hints Delay, positioning

Button Variants & Sizes

<Button variant="default">Primary Action</Button> <Button variant="secondary">Secondary Action</Button> <Button variant="outline">Outlined</Button> <Button variant="ghost">Subtle</Button> <Button variant="destructive">Delete</Button> <Button variant="link">Link Style</Button>

<Button size="sm">Small</Button> {/* 32px mobile, 40px desktop /} <Button size="default">Default</Button> {/ 44px mobile, 36px desktop /} <Button size="lg">Large</Button> {/ 48px mobile, 40px desktop /} <Button size="icon" aria-label="Add"> {/ 44x44px - WCAG 2.1 AAA */} <PlusIcon /> </Button>

Loading States

<Button loading={isPending} loadingText="Saving..."> Save Changes </Button>

// Or manually: <Button disabled={isPending}> {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Save Changes </Button>

Form Integration

Basic Form Pattern

"use client";

import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button";

const schema = z.object({ email: z.string().email(), name: z.string().min(2), });

type FormValues = z.infer<typeof schema>;

export function MyFormClient() { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: "", name: "" }, });

const onSubmit = form.handleSubmit(async (data) => { // Handle submission });

return ( <Form {...form}> <form onSubmit={onSubmit} className="space-y-4"> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input type="email" placeholder="you@example.com" {...field} /> </FormControl> <FormDescription>We'll never share your email.</FormDescription> <FormMessage /> </FormItem> )} />

    &#x3C;Button type="submit" loading={form.formState.isSubmitting}>
      Submit
    &#x3C;/Button>
  &#x3C;/form>
&#x3C;/Form>

); }

Form with tRPC Mutation

"use client";

import { api } from "@/lib/api/react"; import { toast } from "sonner";

export function CreateStackFormClient() { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { /* ... */ }, });

const utils = api.useUtils(); const createMutation = api.stacks.create.useMutation({ onSuccess: () => { toast.success("Stack created successfully"); utils.stacks.list.invalidate(); // Refetch list form.reset(); }, onError: (error) => { toast.error(error.message); }, });

const onSubmit = form.handleSubmit((data) => { createMutation.mutate(data); });

return ( <Form {...form}> <form onSubmit={onSubmit} className="space-y-4"> {/* Form fields */} <Button type="submit" loading={createMutation.isPending} disabled={createMutation.isPending} > Create Stack </Button> </form> </Form> ); }

Common Form Field Patterns

Number Input:

<FormField control={form.control} name="quantity" render={({ field }) => ( <FormItem> <FormLabel>Quantity</FormLabel> <FormControl> <Input type="number" inputMode="numeric" placeholder="100" {...field} onChange={(e) => field.onChange(e.target.valueAsNumber)} /> </FormControl> <FormMessage /> </FormItem> )} />

Date Input:

<FormField control={form.control} name="scheduledFor" render={({ field: { value, onChange, ...field } }) => ( <FormItem> <FormLabel>Scheduled Date</FormLabel> <FormControl> <Input type="date" {...field} value={value instanceof Date ? value.toISOString().split("T")[0] : ""} onChange={(e) => { const dateStr = e.target.value; if (dateStr) onChange(new Date(dateStr)); }} /> </FormControl> <FormMessage /> </FormItem> )} />

Select/Dropdown:

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

<FormField control={form.control} name="status" render={({ field }) => ( <FormItem> <FormLabel>Status</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> <SelectValue placeholder="Select a status" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="active">Active</SelectItem> <SelectItem value="inactive">Inactive</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> )} />;

Textarea:

import { Textarea } from "@/components/ui/textarea";

<FormField control={form.control} name="notes" render={({ field }) => ( <FormItem> <FormLabel>Notes</FormLabel> <FormControl> <Textarea placeholder="Optional notes..." maxLength={1000} {...field} /> </FormControl> <FormMessage /> </FormItem> )} />;

Checkbox:

import { Checkbox } from "@/components/ui/checkbox";

<FormField control={form.control} name="acceptTerms" render={({ field }) => ( <FormItem className="flex flex-row items-start gap-3 space-y-0"> <FormControl> <Checkbox checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <div className="space-y-1 leading-none"> <FormLabel>Accept terms and conditions</FormLabel> <FormDescription>You agree to our Terms of Service.</FormDescription> </div> </FormItem> )} />;

Dialog Patterns

Basic Dialog

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

<Dialog> <DialogTrigger asChild> <Button variant="outline">Open Dialog</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Are you sure?</DialogTitle> <DialogDescription>This action cannot be undone.</DialogDescription> </DialogHeader> <DialogFooter> <Button variant="outline">Cancel</Button> <Button variant="destructive">Delete</Button> </DialogFooter> </DialogContent> </Dialog>;

Controlled Dialog with Form

"use client";

import { useState } from "react";

export function CreateDialogClient() { const [open, setOpen] = useState(false); const form = useForm({ /* ... */ });

const createMutation = api.stacks.create.useMutation({ onSuccess: () => { toast.success("Created successfully"); form.reset(); setOpen(false); // Close dialog }, });

const onSubmit = form.handleSubmit((data) => { createMutation.mutate(data); });

return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button>Create New</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Create Stack</DialogTitle> <DialogDescription>Add a new stack to the system.</DialogDescription> </DialogHeader>

    &#x3C;Form {...form}>
      &#x3C;form onSubmit={onSubmit} className="space-y-4">
        {/* Form fields */}
        &#x3C;DialogFooter>
          &#x3C;Button
            type="button"
            variant="outline"
            onClick={() => setOpen(false)}
            disabled={createMutation.isPending}
          >
            Cancel
          &#x3C;/Button>
          &#x3C;Button type="submit" loading={createMutation.isPending}>
            Create
          &#x3C;/Button>
        &#x3C;/DialogFooter>
      &#x3C;/form>
    &#x3C;/Form>
  &#x3C;/DialogContent>
&#x3C;/Dialog>

); }

Responsive Dialog/Sheet

Atlas has a custom ResponsiveDialogSheet that shows Dialog on desktop, Sheet on mobile:

import { ResponsiveDialogSheet } from "@/components/features/common";

<ResponsiveDialogSheet open={open} onOpenChange={setOpen} title="Create Stack" description="Add a new stack to the system" trigger={<Button>Open</Button>}

{/* Content - works on both desktop and mobile /} <Form {...form}> <form onSubmit={onSubmit}>{/ Fields */}</form> </Form> </ResponsiveDialogSheet>;

Data Table Patterns

Basic Table

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

<Table> <TableCaption>A list of your recent invoices.</TableCaption> <TableHeader> <TableRow> <TableHead>Invoice</TableHead> <TableHead>Status</TableHead> <TableHead className="text-right">Amount</TableHead> </TableRow> </TableHeader> <TableBody> {invoices.map((invoice) => ( <TableRow key={invoice.id}> <TableCell>{invoice.invoice}</TableCell> <TableCell>{invoice.status}</TableCell> <TableCell className="text-right">{invoice.amount}</TableCell> </TableRow> ))} </TableBody> </Table>;

Loading Skeleton

import { Skeleton } from "@/components/ui/skeleton";

if (isLoading) { return ( <div className="space-y-3"> <Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" /> </div> ); }

Toast Notifications

Atlas uses Sonner for toast notifications:

import { toast } from "sonner";

// Success toast.success("Stack created successfully");

// Error toast.error("Failed to create stack");

// Info toast.info("Processing your request...");

// Warning toast.warning("This action is irreversible");

// With action toast.success("Stack deleted", { action: { label: "Undo", onClick: () => { /* Undo logic */ }, }, });

// Promise toast (auto-updates based on promise state) toast.promise(createMutation.mutateAsync(data), { loading: "Creating stack...", success: "Stack created successfully", error: "Failed to create stack", });

Styling Patterns

Responsive Design

<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> {/* Mobile: 1 column, Tablet: 2 columns, Desktop: 3 columns */} </div>

<Button className="w-full sm:w-auto"> {/* Full width on mobile, auto on desktop */} </Button>

Conditional Styles with cn()

import { cn } from "@/lib/utils";

<Button className={cn( "base-classes", isActive && "bg-blue-600", isDisabled && "opacity-50 cursor-not-allowed", variant === "large" && "text-lg px-6", )}

Click me </Button>;

Dark Mode Support

All shadcn components support dark mode automatically via CSS variables:

<div className="bg-background text-foreground"> {/* Automatically adapts to light/dark mode */} </div>

Common Mistakes

❌ Missing aria-label on icon buttons

// Bad <Button size="icon"><TrashIcon /></Button>

// Good <Button size="icon" aria-label="Delete item"><TrashIcon /></Button>

❌ Not using FormControl

// Bad - missing ARIA attributes <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <Input {...field} /> {/* Missing FormControl */} <FormMessage /> </FormItem> )} />

// Good - proper ARIA linkage <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} />

❌ Forgetting DialogTrigger asChild

// Bad - creates nested button <DialogTrigger><Button>Open</Button></DialogTrigger>

// Good - merges props into Button <DialogTrigger asChild><Button>Open</Button></DialogTrigger>

Installation

Install shadcn CLI

pnpm dlx shadcn@latest init

Add components

pnpm dlx shadcn@latest add button input form dialog table select checkbox textarea label

Resources

  • Official docs: https://ui.shadcn.com

  • Examples: See references/examples.md

  • Recipes: See references/recipes.md

  • Patterns: See references/patterns.md

  • Atlas patterns: @/framework/patterns/shadcn.md

Summary

Key practices:

  • Copy, don't import - customize in src/components/ui/

  • Compose, don't prop - build from primitives

  • className over props - extend with Tailwind

  • Accessibility first - labels on icon buttons

  • Forms with react-hook-form - use Form components

  • Mobile-first - 44px minimum touch targets

  • Type-safe - derive types from tRPC RouterOutputs

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

shadcn-ui

No summary provided by upstream source.

Repository SourceNeeds Review
1.2K-jezweb
General

shadcn

No summary provided by upstream source.

Repository SourceNeeds Review
General

shadcn-ui

No summary provided by upstream source.

Repository SourceNeeds Review
General

architecture-patterns

No summary provided by upstream source.

Repository SourceNeeds Review