UI Development

Guide for building UI components with Shadcn/UI, Tailwind CSS, and accessibility best practices

UI Development

This guide covers UI development in ShipKit using Shadcn/UI components, Tailwind CSS, and accessibility best practices.

Component Architecture

Component Structure

// src/components/ui/card/product-card.tsx
import { cn } from "@/lib/utils";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Image } from "@/components/ui/image";

interface ProductCardProps {
  title: string;
  description: string;
  price: number;
  image: string;
  onAddToCart: () => void;
  className?: string;
}

export const ProductCard = ({
  title,
  description,
  price,
  image,
  onAddToCart,
  className,
}: ProductCardProps) => {
  return (
    <Card className={cn("max-w-sm", className)}>
      <CardHeader>
        <Image
          src={image}
          alt={title}
          width={400}
          height={300}
          className="object-cover rounded-t-lg"
        />
      </CardHeader>
      <CardContent>
        <h3 className="text-lg font-semibold">{title}</h3>
        <p className="text-sm text-gray-600">{description}</p>
        <p className="mt-2 text-lg font-bold">${price.toFixed(2)}</p>
      </CardContent>
      <CardFooter>
        <Button onClick={onAddToCart} className="w-full">
          Add to Cart
        </Button>
      </CardFooter>
    </Card>
  );
};

Component Organization

src/components/
├── ui/               # Base UI components
│   ├── button/
│   ├── card/
│   └── input/
├── layout/          # Layout components
│   ├── header/
│   └── footer/
├── features/        # Feature-specific components
│   ├── auth/
│   └── products/
└── shared/          # Shared components
    ├── icons/
    └── loaders/

Styling with Tailwind

Custom Configuration

// tailwind.config.ts
import { type Config } from "tailwindcss";

export default {
  content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#f0fdf4",
          100: "#dcfce7",
          // ... other shades
          900: "#14532d",
        },
      },
      spacing: {
        container: "2rem",
        "container-lg": "4rem",
      },
      fontFamily: {
        sans: ["var(--font-inter)"],
        mono: ["var(--font-mono)"],
      },
    },
  },
  plugins: [
    require("@tailwindcss/typography"),
    require("@tailwindcss/forms"),
  ],
} satisfies Config;

Utility Functions

// src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

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

export const variants = {
  fade: {
    initial: { opacity: 0 },
    animate: { opacity: 1 },
    exit: { opacity: 0 },
  },
  slide: {
    initial: { x: -20, opacity: 0 },
    animate: { x: 0, opacity: 1 },
    exit: { x: 20, opacity: 0 },
  },
} as const;

Accessibility

ARIA Attributes

// src/components/ui/dialog/dialog.tsx
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";

export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;

export const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPrimitive.Portal>
    <DialogPrimitive.Overlay
      className="fixed inset-0 bg-black/50 backdrop-blur-sm"
      aria-hidden="true"
    />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
        "w-full max-w-lg rounded-lg bg-white p-6 shadow-lg",
        "focus:outline-none focus-visible:ring-2",
        className
      )}
      {...props}
    >
      {children}
    </DialogPrimitive.Content>
  </DialogPrimitive.Portal>
));
DialogContent.displayName = "DialogContent";

Keyboard Navigation

// src/components/ui/navigation/nav-menu.tsx
import { useKeyboardNavigation } from "@/hooks/use-keyboard-navigation";

export const NavMenu = () => {
  const { activeIndex, setActiveIndex } = useKeyboardNavigation({
    itemCount: items.length,
    onEnter: (index) => {
      // Handle selection
    },
  });

  return (
    <nav
      role="navigation"
      aria-label="Main navigation"
    >
      <ul className="flex space-x-4">
        {items.map((item, index) => (
          <li key={item.id}>
            <a
              href={item.href}
              className={cn(
                "px-4 py-2 rounded-md",
                activeIndex === index && "bg-gray-100"
              )}
              aria-current={activeIndex === index ? "page" : undefined}
              onKeyDown={(e) => {
                if (e.key === "Enter") {
                  // Handle selection
                }
              }}
              tabIndex={0}
            >
              {item.label}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
};

Forms and Validation

Form Components

// src/components/ui/form/input-field.tsx
import { useId } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ErrorMessage } from "@/components/ui/error-message";

interface InputFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  description?: string;
}

export const InputField = ({
  label,
  error,
  description,
  id: propId,
  ...props
}: InputFieldProps) => {
  const fallbackId = useId();
  const id = propId ?? fallbackId;
  const descriptionId = `${id}-description`;
  const errorId = `${id}-error`;

  return (
    <div className="space-y-2">
      <Label htmlFor={id}>{label}</Label>
      <Input
        id={id}
        aria-describedby={
          error
            ? errorId
            : description
            ? descriptionId
            : undefined
        }
        aria-invalid={!!error}
        {...props}
      />
      {description && !error && (
        <p
          id={descriptionId}
          className="text-sm text-gray-500"
        >
          {description}
        </p>
      )}
      {error && (
        <ErrorMessage id={errorId}>
          {error}
        </ErrorMessage>
      )}
    </div>
  );
};

Form Validation

// src/lib/validations/forms.ts
import { z } from "zod";

export const contactFormSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  message: z.string().min(10, "Message must be at least 10 characters"),
});

// src/components/forms/contact-form.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { type ContactFormData, contactFormSchema } from "@/lib/validations/forms";

export const ContactForm = () => {
  const form = useForm<ContactFormData>({
    resolver: zodResolver(contactFormSchema),
  });

  const onSubmit = async (data: ContactFormData) => {
    try {
      // Handle form submission
    } catch (error) {
      // Handle error
    }
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
      <InputField
        label="Name"
        {...form.register("name")}
        error={form.formState.errors.name?.message}
      />
      {/* Other fields */}
    </form>
  );
};

Responsive Design

Breakpoint System

// src/lib/breakpoints.ts
export const breakpoints = {
  sm: "640px",
  md: "768px",
  lg: "1024px",
  xl: "1280px",
  "2xl": "1536px",
} as const;

// Usage with CSS-in-JS
export const mediaQueries = {
  sm: `@media (min-width: ${breakpoints.sm})`,
  md: `@media (min-width: ${breakpoints.md})`,
  lg: `@media (min-width: ${breakpoints.lg})`,
  xl: `@media (min-width: ${breakpoints.xl})`,
  "2xl": `@media (min-width: ${breakpoints["2xl"]})`,
} as const;

Responsive Components

// src/components/layout/responsive-grid.tsx
interface ResponsiveGridProps {
  children: React.ReactNode;
  className?: string;
}

export const ResponsiveGrid = ({
  children,
  className,
}: ResponsiveGridProps) => {
  return (
    <div
      className={cn(
        "grid gap-4",
        "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
        className
      )}
    >
      {children}
    </div>
  );
};

Best Practices

Performance

  1. Code Splitting

    • Use dynamic imports for large components
    • Lazy load below-the-fold content
    • Implement proper loading states
  2. Image Optimization

    • Use Next.js Image component
    • Implement responsive images
    • Optimize image formats
  3. Bundle Size

    • Monitor bundle size with tools
    • Tree-shake unused components
    • Use code splitting effectively

Accessibility

  1. Semantic HTML

    • Use proper heading hierarchy
    • Implement ARIA landmarks
    • Add descriptive alt text
  2. Keyboard Navigation

    • Ensure proper tab order
    • Implement focus management
    • Add keyboard shortcuts
  3. Screen Readers

    • Test with screen readers
    • Add aria-labels
    • Use proper roles

Component Design

  1. Composition

    • Use composition over inheritance
    • Create small, reusable components
    • Implement proper prop drilling
  2. State Management

    • Use appropriate state solutions
    • Implement proper data flow
    • Handle loading states
  3. Error Handling

    • Implement error boundaries
    • Add proper error states
    • Handle edge cases

Examples

Complete Component

// src/components/features/products/product-grid.tsx
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import { ProductCard } from "./product-card";
import { Spinner } from "@/components/ui/spinner";
import { fetchProducts } from "@/lib/api";

export const ProductGrid = () => {
  const { ref, inView } = useInView();

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ["products"],
    queryFn: fetchProducts,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  React.useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView, fetchNextPage, hasNextPage]);

  if (status === "loading") {
    return <Spinner />;
  }

  if (status === "error") {
    return <div>Error loading products</div>;
  }

  return (
    <div className="space-y-8">
      <ResponsiveGrid>
        {data.pages.map((page) =>
          page.products.map((product) => (
            <ProductCard
              key={product.id}
              product={product}
            />
          ))
        )}
      </ResponsiveGrid>

      <div
        ref={ref}
        className="flex justify-center p-4"
      >
        {isFetchingNextPage ? (
          <Spinner />
        ) : hasNextPage ? (
          "Loading more..."
        ) : (
          "No more products"
        )}
      </div>
    </div>
  );
};

Related Resources