Skip to content

Session 12 UI UX Design

Maximiliano Militzer edited this page Apr 7, 2025 · 1 revision

Session 12: Make it Pretty (UI/UX Design with shadcn/ui)

Overview

Welcome to Session 12! In this session, we'll focus on creating beautiful and user-friendly interfaces for our web applications. We'll learn about UI/UX design principles and how to implement them using shadcn/ui, a collection of reusable components built with Radix UI and Tailwind CSS.

Learning Objectives

  • Understand key UI/UX design principles
  • Learn how to use shadcn/ui components
  • Create consistent and accessible user interfaces
  • Implement responsive designs for different screen sizes
  • Use design systems and component libraries effectively
  • Apply dark mode and theming to your applications

Topics Covered

1. UI/UX Design Fundamentals

What is UI/UX?

  • UI (User Interface): The visual elements users interact with (buttons, forms, layout)
  • UX (User Experience): How users feel when interacting with your interface

Key Design Principles:

  1. Consistency: Use consistent elements, patterns, and behaviors
  2. Hierarchy: Guide users through your interface with clear visual hierarchies
  3. Simplicity: Keep interfaces clean and eliminate unnecessary elements
  4. Accessibility: Design for all users, including those with disabilities
  5. Feedback: Provide clear feedback for user actions
  6. Affordance: Make it obvious how elements can be interacted with

2. Introduction to shadcn/ui

shadcn/ui is a collection of reusable components that you can copy and paste into your applications. It's built on top of:

  • Radix UI: Primitive, accessible, unstyled components
  • Tailwind CSS: Utility-first CSS framework
  • React: JavaScript library for building user interfaces

Benefits of shadcn/ui:

  • Beautifully designed components
  • Fully accessible
  • Customizable with Tailwind CSS
  • Not a dependency; components are copied directly into your project
  • Dark mode support
  • TypeScript support

3. Setting Up shadcn/ui

Installation:

# Create a new Next.js project if you don't have one
npx create-next-app@latest my-app --typescript --tailwind --eslint

# Navigate to your project
cd my-app

# Add shadcn/ui CLI
npm install -D @shadcn/ui

# Initialize shadcn/ui
npx shadcn-ui init

Answer the initialization questions:

  • Would you like to use TypeScript? → Yes
  • Which style would you like to use? → Default
  • Which color would you like to use as base color? → Choose any
  • Where is your tailwind.config.js located? → tailwind.config.js
  • Configure the import alias for components? → @/components
  • Configure the import alias for utils? → @/lib/utils

4. Essential shadcn/ui Components

Let's explore some key components provided by shadcn/ui:

Button Component:

# Install Button component
npx shadcn-ui add button
import { Button } from "@/components/ui/button";

export default function ButtonDemo() {
  return (
    <div className="flex gap-4">
      <Button variant="default">Default</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>
    </div>
  );
}

Card Component:

# Install Card component
npx shadcn-ui add card
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";

export default function CardDemo() {
  return (
    <Card className="w-[350px]">
      <CardHeader>
        <CardTitle>Create project</CardTitle>
        <CardDescription>Deploy your new project in one-click.</CardDescription>
      </CardHeader>
      <CardContent>
        <form>
          <div className="grid w-full items-center gap-4">
            <div className="flex flex-col space-y-1.5">
              <label htmlFor="name">Name</label>
              <input id="name" placeholder="Name of your project" />
            </div>
            <div className="flex flex-col space-y-1.5">
              <label htmlFor="framework">Framework</label>
              <select id="framework">
                <option value="next">Next.js</option>
                <option value="sveltekit">SvelteKit</option>
                <option value="astro">Astro</option>
                <option value="nuxt">Nuxt.js</option>
              </select>
            </div>
          </div>
        </form>
      </CardContent>
      <CardFooter className="flex justify-between">
        <Button variant="outline">Cancel</Button>
        <Button>Deploy</Button>
      </CardFooter>
    </Card>
  );
}

Form Components:

# Install form components
npx shadcn-ui add form
npx shadcn-ui add input
npx shadcn-ui add select
npx shadcn-ui add textarea
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";

const formSchema = z.object({
  username: z.string().min(2, {
    message: "Username must be at least 2 characters.",
  }),
  email: z.string().email({
    message: "Please enter a valid email address.",
  }),
});

export function ProfileForm() {
  const form = useForm({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
    },
  });

  function onSubmit(values) {
    toast({
      title: "You submitted the following values:",
      description: (
        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
          <code className="text-white">{JSON.stringify(values, null, 2)}</code>
        </pre>
      ),
    });
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="shadcn" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="example@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

Dialog (Modal) Component:

# Install Dialog component
npx shadcn-ui add dialog
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function DialogDemo() {
  return (
    <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. Click save when you're done.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">
              Name
            </Label>
            <Input id="name" value="Pedro Duarte" className="col-span-3" />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="username" className="text-right">
              Username
            </Label>
            <Input id="username" value="@peduarte" className="col-span-3" />
          </div>
        </div>
        <DialogFooter>
          <Button type="submit">Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

5. Building a UI Component System

Creating a Layout Component:

// src/components/layout.jsx
import { ThemeProvider } from "@/components/theme-provider";
import Navbar from "@/components/navbar";
import Footer from "@/components/footer";

export default function Layout({ children }) {
  return (
    <ThemeProvider defaultTheme="system" storageKey="ui-theme">
      <div className="flex min-h-screen flex-col">
        <Navbar />
        <main className="flex-1">{children}</main>
        <Footer />
      </div>
    </ThemeProvider>
  );
}

Creating a Responsive Navbar:

// src/components/navbar.jsx
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/mode-toggle";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
  Sheet,
  SheetContent,
  SheetTrigger,
} from "@/components/ui/sheet";
import { Menu } from "lucide-react";

export default function Navbar() {
  const [open, setOpen] = useState(false);
  const isDesktop = useMediaQuery("(min-width: 768px)");

  const navItems = [
    { label: "Home", href: "/" },
    { label: "Features", href: "/features" },
    { label: "Pricing", href: "/pricing" },
    { label: "About", href: "/about" },
  ];

  return (
    <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
      <div className="container flex h-16 items-center justify-between">
        <div className="flex items-center gap-2">
          <Link href="/" className="font-bold text-xl">
            MyApp
          </Link>
        </div>
        
        {isDesktop ? (
          <nav className="flex items-center gap-6">
            {navItems.map((item) => (
              <Link
                key={item.href}
                href={item.href}
                className="text-sm font-medium transition-colors hover:text-primary"
              >
                {item.label}
              </Link>
            ))}
            <ModeToggle />
            <Button asChild>
              <Link href="/login">Sign In</Link>
            </Button>
          </nav>
        ) : (
          <div className="flex items-center gap-2">
            <ModeToggle />
            <Sheet open={open} onOpenChange={setOpen}>
              <SheetTrigger asChild>
                <Button variant="outline" size="icon">
                  <Menu className="h-5 w-5" />
                  <span className="sr-only">Toggle menu</span>
                </Button>
              </SheetTrigger>
              <SheetContent side="right" className="w-[240px] sm:w-[300px]">
                <nav className="flex flex-col gap-4 mt-8">
                  {navItems.map((item) => (
                    <Link
                      key={item.href}
                      href={item.href}
                      onClick={() => setOpen(false)}
                      className="text-sm font-medium transition-colors hover:text-primary"
                    >
                      {item.label}
                    </Link>
                  ))}
                  <Button asChild className="mt-2">
                    <Link href="/login" onClick={() => setOpen(false)}>
                      Sign In
                    </Link>
                  </Button>
                </nav>
              </SheetContent>
            </Sheet>
          </div>
        )}
      </div>
    </header>
  );
}

6. Implementing Dark Mode

shadcn/ui comes with built-in dark mode support. Here's how to implement it:

# Install necessary components
npx shadcn-ui add dropdown-menu

Create a theme provider component:

// src/components/theme-provider.jsx
"use client";

import { createContext, useContext, useEffect, useState } from "react";

const ThemeContext = createContext({ theme: "system", setTheme: () => null });

export function ThemeProvider({
  children,
  defaultTheme = "system",
  storageKey = "ui-theme",
  ...props
}) {
  const [theme, setTheme] = useState(defaultTheme);

  useEffect(() => {
    const root = window.document.documentElement;
    const savedTheme = localStorage.getItem(storageKey);

    if (savedTheme) {
      setTheme(savedTheme);

      if (savedTheme === "dark" || 
          (savedTheme === "system" && 
           window.matchMedia("(prefers-color-scheme: dark)").matches)) {
        root.classList.add("dark");
      } else {
        root.classList.remove("dark");
      }
    }
  }, [storageKey]);

  useEffect(() => {
    const root = window.document.documentElement;
    localStorage.setItem(storageKey, theme);

    if (theme === "dark" || 
        (theme === "system" && 
         window.matchMedia("(prefers-color-scheme: dark)").matches)) {
      root.classList.add("dark");
    } else {
      root.classList.remove("dark");
    }
  }, [theme, storageKey]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }} {...props}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

Create a theme toggle component:

// src/components/mode-toggle.jsx
"use client";

import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Moon, Sun } from "lucide-react";

export function ModeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          {theme === "dark" ? (
            <Moon className="h-5 w-5" />
          ) : (
            <Sun className="h-5 w-5" />
          )}
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

7. Responsive Design Principles

When designing for multiple screen sizes, follow these principles:

  1. Mobile First: Design for mobile devices first, then enhance for larger screens
  2. Fluid Layouts: Use percentage-based widths and Flexbox/Grid for layouts
  3. Media Queries: Adjust design at specific breakpoints
  4. Flexible Images: Ensure images scale appropriately
  5. Touch-Friendly: Design for finger navigation on mobile (larger tap targets)
// Example of responsive design with Tailwind CSS
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  <Card>
    <CardHeader>
      <CardTitle>Feature 1</CardTitle>
    </CardHeader>
    <CardContent>
      <p>Description of feature 1</p>
    </CardContent>
  </Card>
  <Card>
    <CardHeader>
      <CardTitle>Feature 2</CardTitle>
    </CardHeader>
    <CardContent>
      <p>Description of feature 2</p>
    </CardContent>
  </Card>
  <Card>
    <CardHeader>
      <CardTitle>Feature 3</CardTitle>
    </CardHeader>
    <CardContent>
      <p>Description of feature 3</p>
    </CardContent>
  </Card>
</div>

8. Accessibility (a11y) Best Practices

Ensure your UI is accessible to all users:

  1. Proper Semantics: Use appropriate HTML elements (e.g., <button> for buttons)
  2. ARIA Attributes: Add when necessary (shadcn/ui handles many cases)
  3. Keyboard Navigation: Ensure all interactive elements are keyboard accessible
  4. Color Contrast: Maintain sufficient contrast ratios
  5. Focus States: Ensure visible focus indicators
  6. Screen Reader Support: Provide text alternatives for images and icons

shadcn/ui components are built with accessibility in mind, but always test your interfaces with keyboard-only navigation and screen readers.

9. Redesigning Our Todo App with shadcn/ui

Let's enhance our Todo app from the previous session with shadcn/ui components:

// src/app/tasks/page.jsx
"use client";

import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import useAuthStore from "@/store/authStore";
import useTaskStore from "@/store/taskStore";

// UI Components
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@/components/ui/tabs";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Calendar } from "@/components/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { CalendarIcon, Trash2, PlusCircle } from "lucide-react";
import { cn } from "@/lib/utils";

export default function TasksPage() {
  const router = useRouter();
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
  const logout = useAuthStore((state) => state.logout);

  const {
    tasks,
    fetchTasks,
    categories,
    fetchCategories,
    toggleTaskCompletion,
    deleteTask,
    createTask,
    isLoading,
    error,
  } = useTaskStore();

  const [filter, setFilter] = useState("all");
  const [categoryFilter, setCategoryFilter] = useState("");
  const [newTask, setNewTask] = useState({
    title: "",
    category_id: "",
    due_date: null,
  });

  useEffect(() => {
    if (!isAuthenticated) {
      router.push("/login");
      return;
    }

    fetchTasks();
    fetchCategories();
  }, [isAuthenticated, router, fetchTasks, fetchCategories]);

  const handleLogout = () => {
    logout();
    router.push("/login");
  };

  const handleCreateTask = async (e) => {
    e.preventDefault();
    if (!newTask.title.trim()) return;

    await createTask({
      title: newTask.title,
      category_id: newTask.category_id || null,
      due_date: newTask.due_date,
    });

    setNewTask({
      title: "",
      category_id: "",
      due_date: null,
    });
  };

  const handleToggleTask = async (taskId) => {
    await toggleTaskCompletion(taskId);
  };

  const handleDeleteTask = async (taskId) => {
    await deleteTask(taskId);
  };

  const filteredTasks = tasks.filter((task) => {
    if (filter === "active" && task.completed) return false;
    if (filter === "completed" && !task.completed) return false;
    if (categoryFilter && task.category_id !== parseInt(categoryFilter))
      return false;
    return true;
  });

  const getCategoryById = (categoryId) => {
    return categories.find((category) => category.id === categoryId) || null;
  };

  return (
    <div className="container max-w-4xl py-8">
      <Card className="mb-8">
        <CardHeader className="flex flex-row items-center justify-between">
          <div>
            <CardTitle className="text-2xl">Your Tasks</CardTitle>
            <CardDescription>
              {filteredTasks.length} tasks (
              {tasks.filter((t) => t.completed).length} completed)
            </CardDescription>
          </div>
          <Button variant="destructive" onClick={handleLogout}>
            Logout
          </Button>
        </CardHeader>

        <CardContent>
          {/* New Task Form */}
          <form onSubmit={handleCreateTask} className="mb-6">
            <div className="flex flex-col gap-4">
              <div className="grid grid-cols-12 gap-2">
                <Input
                  className="col-span-12 md:col-span-6"
                  placeholder="Add a new task..."
                  value={newTask.title}
                  onChange={(e) =>
                    setNewTask({ ...newTask, title: e.target.value })
                  }
                />
                <Select
                  value={newTask.category_id}
                  onValueChange={(value) =>
                    setNewTask({ ...newTask, category_id: value })
                  }
                >
                  <SelectTrigger className="col-span-6 md:col-span-3">
                    <SelectValue placeholder="Category" />
                  </SelectTrigger>
                  <SelectContent>
                    {categories.map((category) => (
                      <SelectItem
                        key={category.id}
                        value={category.id.toString()}
                      >
                        {category.name}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                <Popover>
                  <PopoverTrigger asChild>
                    <Button
                      variant="outline"
                      className="col-span-6 md:col-span-2 justify-start text-left font-normal"
                    >
                      <CalendarIcon className="mr-2 h-4 w-4" />
                      {newTask.due_date ? (
                        format(newTask.due_date, "PPP")
                      ) : (
                        <span>Due Date</span>
                      )}
                    </Button>
                  </PopoverTrigger>
                  <PopoverContent className="w-auto p-0">
                    <Calendar
                      mode="single"
                      selected={newTask.due_date}
                      onSelect={(date) =>
                        setNewTask({ ...newTask, due_date: date })
                      }
                      initialFocus
                    />
                  </PopoverContent>
                </Popover>
                <Button
                  type="submit"
                  className="col-span-12 md:col-span-1"
                  size="icon"
                >
                  <PlusCircle className="h-5 w-5" />
                </Button>
              </div>
            </div>
          </form>

          {/* Filters */}
          <div className="flex flex-col sm:flex-row justify-between gap-4 mb-6">
            <Tabs
              defaultValue="all"
              value={filter}
              onValueChange={setFilter}
              className="w-full sm:w-auto"
            >
              <TabsList className="grid grid-cols-3 w-full sm:w-[300px]">
                <TabsTrigger value="all">All</TabsTrigger>
                <TabsTrigger value="active">Active</TabsTrigger>
                <TabsTrigger value="completed">Completed</TabsTrigger>
              </TabsList>
            </Tabs>

            <Select
              value={categoryFilter}
              onValueChange={setCategoryFilter}
              className="w-full sm:w-[200px]"
            >
              <SelectTrigger>
                <SelectValue placeholder="All Categories" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="">All Categories</SelectItem>
                {categories.map((category) => (
                  <SelectItem key={category.id} value={category.id.toString()}>
                    {category.name}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>

          {/* Task List */}
          {isLoading ? (
            <div className="text-center py-8">
              <p className="text-muted-foreground">Loading tasks...</p>
            </div>
          ) : error ? (
            <div className="text-center py-8">
              <p className="text-destructive">{error}</p>
              <Button onClick={fetchTasks} className="mt-2">
                Retry
              </Button>
            </div>
          ) : filteredTasks.length === 0 ? (
            <div className="text-center py-8 border rounded-md">
              <p className="text-muted-foreground">No tasks found</p>
            </div>
          ) : (
            <div className="space-y-2">
              {filteredTasks.map((task) => {
                const category = task.category_id
                  ? getCategoryById(task.category_id)
                  : null;

                return (
                  <div
                    key={task.id}
                    className={cn(
                      "flex items-center justify-between p-4 border rounded-md",
                      task.completed && "bg-muted"
                    )}
                  >
                    <div className="flex items-center gap-3">
                      <Checkbox
                        id={`task-${task.id}`}
                        checked={task.completed}
                        onCheckedChange={() => handleToggleTask(task.id)}
                      />
                      <div>
                        <label
                          htmlFor={`task-${task.id}`}
                          className={cn(
                            task.completed && "line-through text-muted-foreground"
                          )}
                        >
                          {task.title}
                        </label>
                        <div className="flex items-center gap-2 mt-1">
                          {category && (
                            <Badge
                              style={{
                                backgroundColor: category.color,
                                color: "#fff",
                              }}
                              variant="outline"
                            >
                              {category.name}
                            </Badge>
                          )}
                          {task.due_date && (
                            <div className="text-xs text-muted-foreground">
                              {format(new Date(task.due_date), "PPP")}
                            </div>
                          )}
                        </div>
                      </div>
                    </div>
                    <Button
                      variant="ghost"
                      size="icon"
                      onClick={() => handleDeleteTask(task.id)}
                    >
                      <Trash2 className="h-4 w-4 text-destructive" />
                    </Button>
                  </div>
                );
              })}
            </div>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

Practice Exercises

  1. Install shadcn/ui in your Next.js project
  2. Create a responsive navigation bar with a mobile menu
  3. Implement dark mode toggle functionality
  4. Design a user profile page with form components
  5. Create a dashboard layout with multiple cards and data visualization
  6. Enhance your Todo application with shadcn/ui components
  7. Create a custom theme for your application
  8. Implement accessible form validation

Additional Resources

Next Steps

Now that you've learned how to create beautiful and accessible user interfaces with shadcn/ui, let's move on to Session 13: Testing Your App where you'll learn how to test your applications to ensure they work correctly and reliably.

Clone this wiki locally