-
Notifications
You must be signed in to change notification settings - Fork 0
Session 12 UI UX Design
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.
- 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
- UI (User Interface): The visual elements users interact with (buttons, forms, layout)
- UX (User Experience): How users feel when interacting with your interface
- Consistency: Use consistent elements, patterns, and behaviors
- Hierarchy: Guide users through your interface with clear visual hierarchies
- Simplicity: Keep interfaces clean and eliminate unnecessary elements
- Accessibility: Design for all users, including those with disabilities
- Feedback: Provide clear feedback for user actions
- Affordance: Make it obvious how elements can be interacted with
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
- Beautifully designed components
- Fully accessible
- Customizable with Tailwind CSS
- Not a dependency; components are copied directly into your project
- Dark mode support
- TypeScript support
# 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 initAnswer 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
Let's explore some key components provided by shadcn/ui:
# Install Button component
npx shadcn-ui add buttonimport { 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>
);
}# Install Card component
npx shadcn-ui add cardimport {
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>
);
}# Install form components
npx shadcn-ui add form
npx shadcn-ui add input
npx shadcn-ui add select
npx shadcn-ui add textareaimport { 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>
);
}# Install Dialog component
npx shadcn-ui add dialogimport {
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>
);
}// 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>
);
}// 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>
);
}shadcn/ui comes with built-in dark mode support. Here's how to implement it:
# Install necessary components
npx shadcn-ui add dropdown-menuCreate 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>
);
}When designing for multiple screen sizes, follow these principles:
- Mobile First: Design for mobile devices first, then enhance for larger screens
- Fluid Layouts: Use percentage-based widths and Flexbox/Grid for layouts
- Media Queries: Adjust design at specific breakpoints
- Flexible Images: Ensure images scale appropriately
- 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>Ensure your UI is accessible to all users:
-
Proper Semantics: Use appropriate HTML elements (e.g.,
<button>for buttons) - ARIA Attributes: Add when necessary (shadcn/ui handles many cases)
- Keyboard Navigation: Ensure all interactive elements are keyboard accessible
- Color Contrast: Maintain sufficient contrast ratios
- Focus States: Ensure visible focus indicators
- 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.
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>
);
}- Install shadcn/ui in your Next.js project
- Create a responsive navigation bar with a mobile menu
- Implement dark mode toggle functionality
- Design a user profile page with form components
- Create a dashboard layout with multiple cards and data visualization
- Enhance your Todo application with shadcn/ui components
- Create a custom theme for your application
- Implement accessible form validation
- shadcn/ui Documentation
- Radix UI Documentation
- Tailwind CSS Documentation
- Web Content Accessibility Guidelines (WCAG)
- The A11Y Project
- Refactoring UI Book
- Laws of UX
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.