Chapter 10: Advanced Tailwind CSS
You've got the fundamentals โ utility classes, responsive design, state modifiers. Now let's go deeper: custom themes, dark mode, animations, and patterns that turn a "looks okay" app into a polished product. This is where TaskFlow starts to feel designed.
๐ Where we are: TaskFlow has Tailwind basics (Ch 9) โ layout, spacing, typography, responsive breakpoints. The theme toggle from Ch 5 exists but doesn't work with Tailwind's dark mode yet. Let's connect them and add polish.
๐ง Conceptsโ
1. Custom Theme Configurationโ
Tailwind v4 uses CSS-based configuration instead of the old tailwind.config.js. You customize your theme directly in your CSS file:
/* src/index.css */
@import "tailwindcss";
@theme {
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", monospace;
}
Now you can use text-brand-500, bg-brand-50, font-sans, etc. throughout your code.
Tailwind v4 vs v3: In v3, you edited
tailwind.config.jswith a JavaScript object. In v4, everything lives in CSS with@theme. Much simpler. If you see JavaScript config files in tutorials, that's the old way.
2. Extending vs Overridingโ
When you define values in @theme, you extend the defaults โ your custom values coexist with Tailwind's built-in ones.
@theme {
/* This ADDS brand colors alongside gray, red, blue, etc. */
--color-brand-500: #6366f1;
/* To OVERRIDE the entire color palette, you'd prefix with -- and
use the full set. But usually extending is what you want. */
}
3. The @apply Directive โ Use It Sparinglyโ
@apply lets you compose Tailwind utilities into a traditional CSS class:
/* src/index.css */
@layer components {
.btn-primary {
@apply rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white
hover:bg-blue-700 focus:outline-none focus:ring-2
focus:ring-blue-500 focus:ring-offset-2;
}
}
<button className="btn-primary">Save</button>
Why use it sparingly?
- It defeats Tailwind's colocation benefit โ styles are back in a separate file
- It makes your CSS larger (utilities get duplicated)
- It doesn't support all Tailwind features (some modifiers behave differently)
When it makes sense:
- Prose/article styling where utilities would be absurd
- Third-party components you can't add classes to
- Truly repeated patterns across 10+ components (but consider a React component first!)
The rule of thumb: If you can solve it with a React component that has utilities in JSX, do that instead of @apply.
4. Dark Modeโ
Tailwind supports dark mode with the dark: modifier. There are two strategies:
Media strategy (follows OS preference):
/* Automatic โ respects prefers-color-scheme */
@media (prefers-color-scheme: dark) { ... }
Class strategy (you control it):
/* In v4, configure in CSS: */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
This makes the dark: modifier activate when a .dark class is on an ancestor element โ typically <html>.
// Toggle dark mode by adding/removing the class
document.documentElement.classList.toggle("dark");
Using dark mode utilities:
<div className="bg-white dark:bg-gray-900">
<h1 className="text-gray-900 dark:text-gray-100">Dashboard</h1>
<p className="text-gray-500 dark:text-gray-400">Welcome back!</p>
</div>
Every color/background needs a dark: counterpart. It's more work upfront, but the result is a seamless dark theme.
5. Animations & Transitionsโ
CSS Transitions โ Smooth property changes:
{/* Transition on hover */}
<div className="transition-all duration-200 ease-in-out
hover:scale-105 hover:shadow-lg">
Smooth hover effect
</div>
{/* Transition specific properties */}
<button className="transition-colors duration-150 bg-blue-500 hover:bg-blue-600">
Click me
</button>
Built-in animations:
<div className="animate-spin">๐</div> {/* Continuous rotation */}
<div className="animate-bounce">โฌ๏ธ</div> {/* Bouncing */}
<div className="animate-pulse">...</div> {/* Fade in/out (loading skeletons) */}
<div className="animate-ping">๐</div> {/* Ping (notification dot) */}
Custom keyframes (in v4 CSS config):
@theme {
--animate-slide-in: slide-in 0.3s ease-out;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
<div className="animate-slide-in">I slide in!</div>
6. Group and Peer Modifiers in Depthโ
These are powerful for interactive UI without JavaScript:
group โ Style children based on parent state:
<Link to={`/task/${task.id}`} className="group block rounded-lg border p-4">
<h3 className="font-medium text-gray-900 group-hover:text-blue-600">
{task.title}
</h3>
{/* Actions appear only on hover */}
<div className="mt-2 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<button className="text-xs text-gray-400 hover:text-blue-500">Edit</button>
<button className="text-xs text-gray-400 hover:text-red-500">Delete</button>
</div>
</Link>
Named groups โ Multiple group scopes:
<div className="group/card">
<div className="group/header">
<h3 className="group-hover/header:underline">Title</h3>
</div>
<p className="group-hover/card:text-gray-600">Description</p>
</div>
peer โ Style based on sibling state:
<div>
<input
type="checkbox"
className="peer sr-only"
id="toggle-done"
/>
<label
htmlFor="toggle-done"
className="cursor-pointer rounded-md border px-3 py-1
peer-checked:bg-green-500 peer-checked:text-white"
>
Mark as Done
</label>
</div>
7. Container Queriesโ
Style elements based on their container's size, not the viewport. Perfect for reusable components:
<div className="@container">
<div className="flex flex-col @md:flex-row @md:items-center gap-4">
<h3 className="text-sm @lg:text-lg">Task Title</h3>
<span className="hidden @md:inline">Extra info shown in wider containers</span>
</div>
</div>
Container queries use @ prefixed breakpoints. The component adapts based on how much space it has, not the screen size. This is a game-changer for component libraries.
8. Performance: content-visibility for Long Listsโ
Vercel tip: For lists with hundreds of items, use
content-visibilityto skip rendering off-screen content:
<div className="[content-visibility:auto] [contain-intrinsic-size:auto_80px]">
{/* Browser skips rendering this div's contents when off-screen */}
<TaskCard task={task} />
</div>
This is a CSS property (not Tailwind-specific) that tells the browser to skip layout/paint for off-screen elements. Massive performance win for long task lists. The contain-intrinsic-size tells the browser the estimated height so scrollbar calculations stay accurate.
๐ก Examplesโ
Theme Toggle Componentโ
Wire this up with the ThemeContext from Chapter 5:
import { useContext } from "react";
import { ThemeContext } from "../context/ThemeContext";
const ThemeToggle = () => {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button
onClick={toggleTheme}
className="rounded-lg p-2 text-gray-500 transition-colors
hover:bg-gray-100 hover:text-gray-700
dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
>
{theme === "light" ? (
<span className="text-lg">๐</span>
) : (
<span className="text-lg">โ๏ธ</span>
)}
</button>
);
}
In your ThemeProvider, apply the class to <html>:
useEffect(() => {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}, [theme]);
Priority Badge with Color Codingโ
const priorityConfig = {
low: {
label: "Low",
classes: "bg-green-50 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-800",
dot: "bg-green-500",
},
medium: {
label: "Medium",
classes: "bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-950 dark:text-yellow-300 dark:border-yellow-800",
dot: "bg-yellow-500",
},
high: {
label: "High",
classes: "bg-red-50 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800",
dot: "bg-red-500",
},
} as const;
const PriorityBadge = ({ priority }: { priority: "low" | "medium" | "high" }) => {
const config = priorityConfig[priority];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5
text-xs font-medium ${config.classes}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${config.dot}`} />
{config.label}
</span>
);
}
Animated Task Cardโ
const TaskCard = ({ task, index }: { task: Task; index: number }) => {
return (
<div
className="animate-slide-in rounded-lg border border-gray-200 bg-white p-4
shadow-sm transition-all duration-200 hover:-translate-y-0.5
hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="flex items-start justify-between">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{task.title}
</h3>
<PriorityBadge priority={task.priority} />
</div>
{task.description && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{task.description}
</p>
)}
</div>
);
}
The style={{ animationDelay }} creates a staggered entrance โ each card slides in slightly after the previous one.
Loading Skeletonโ
const TaskCardSkeleton = () => {
return (
<div className="animate-pulse rounded-lg border border-gray-200 bg-white p-4
dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-start justify-between">
<div className="h-5 w-2/3 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-5 w-16 rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
<div className="mt-3 space-y-2">
<div className="h-4 w-full rounded bg-gray-100 dark:bg-gray-700" />
<div className="h-4 w-4/5 rounded bg-gray-100 dark:bg-gray-700" />
</div>
</div>
);
}
const TaskListSkeleton = ({ count = 5 }: { count?: number }) => {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => (
<TaskCardSkeleton key={i} />
))}
</div>
);
}
๐จ Project Task: Polish TaskFlowโ
Step 1: Set Up Custom Themeโ
Update src/index.css:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Brand colors */
--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
/* Fonts */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Custom animation */
--animate-slide-in: slide-in 0.3s ease-out forwards;
--animate-fade-in: fade-in 0.2s ease-out;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Base styles */
@layer base {
body {
@apply bg-gray-50 text-gray-900 antialiased dark:bg-gray-950 dark:text-gray-100;
}
}
Step 2: Wire Up Dark Modeโ
Update your ThemeContext to persist preference and apply the class:
// src/context/ThemeContext.tsx
import { createContext, useState, useEffect, type ReactNode } from "react";
interface ThemeContextType {
theme: "light" | "dark";
toggleTheme: () => void;
}
export const ThemeContext = createContext<ThemeContextType>({
theme: "light",
toggleTheme: () => {},
});
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = localStorage.getItem("taskflow-theme");
if (stored === "dark" || stored === "light") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
});
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem("taskflow-theme", theme);
}, [theme]);
function toggleTheme() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
}
return (
<ThemeContext value={{ theme, toggleTheme }}>
{children}
</ThemeContext>
);
}
๐ React 19: Notice
<ThemeContext value={...}>instead of<ThemeContext.Provider value={...}>. The.Provideris no longer needed!
Step 3: Add Dark Mode to All Componentsโ
Go through each component and add dark: variants:
// Sidebar
<aside className="hidden w-64 flex-col border-r border-gray-200 bg-white p-4
md:flex dark:border-gray-800 dark:bg-gray-900">
// Cards
<div className="rounded-lg border border-gray-200 bg-white p-4
dark:border-gray-700 dark:bg-gray-800">
// Text
<h1 className="text-gray-900 dark:text-gray-100">
<p className="text-gray-500 dark:text-gray-400">
// Inputs
<input className="border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800
dark:text-gray-100 dark:placeholder-gray-500" />
Step 4: Add the Theme Toggle to the Sidebarโ
import ThemeToggle from "./ThemeToggle";
// In Layout.tsx sidebar, before the version text:
<div className="mt-auto flex items-center justify-between border-t
border-gray-200 pt-4 dark:border-gray-800">
<p className="text-xs text-gray-400">TaskFlow v0.10</p>
<ThemeToggle />
</div>
Step 5: Add Priority Color Codingโ
Create src/components/PriorityBadge.tsx:
const priorityConfig = {
low: {
label: "Low",
classes:
"bg-green-50 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-800",
dot: "bg-green-500",
},
medium: {
label: "Medium",
classes:
"bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-950 dark:text-yellow-300 dark:border-yellow-800",
dot: "bg-yellow-500",
},
high: {
label: "High",
classes:
"bg-red-50 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800",
dot: "bg-red-500 animate-pulse",
},
} as const;
export default function PriorityBadge({
priority,
}: {
priority: "low" | "medium" | "high";
}) {
const config = priorityConfig[priority];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5
py-0.5 text-xs font-medium ${config.classes}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${config.dot}`} />
{config.label}
</span>
);
}
Note the animate-pulse on the high priority dot โ a subtle visual indicator that this task needs attention.
Step 6: Add Animated Transitionsโ
Update task cards to animate on entry and hover:
// In Dashboard task list:
{tasks.map((task, index) => (
<Link
key={task.id}
to={`/task/${task.id}`}
className="flex items-center justify-between rounded-lg border
border-gray-200 bg-white p-4 transition-all duration-200
hover:-translate-y-0.5 hover:shadow-md
dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-600
animate-slide-in opacity-0"
style={{ animationDelay: `${index * 50}ms`, animationFillMode: "forwards" }}
>
<div className="flex items-center gap-3">
<span className="font-medium text-gray-900 dark:text-gray-100">
{task.title}
</span>
</div>
<PriorityBadge priority={task.priority} />
</Link>
))}
Step 7: Content-Visibility for Performanceโ
If you have many tasks, wrap the list in a performance-optimized container:
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task.id}
className="[content-visibility:auto] [contain-intrinsic-size:auto_72px]"
>
<TaskRow task={task} />
</div>
))}
</div>
Step 8: Verifyโ
- Toggle dark mode โ entire app switches theme smoothly
- Theme persists across page refreshes (localStorage)
- Priority badges show correct colors with animated dot for high priority
- Task cards animate in with staggered delay
- Hover effects: cards lift slightly with shadow
- Brand color (
brand-500) used for primary actions - Dark mode has proper contrast โ no hard-to-read text
- Mobile layout still works correctly
๐งช Challengeโ
-
System preference sync: Add a "System" option to the theme toggle that follows
prefers-color-scheme. Usewindow.matchMedia("(prefers-color-scheme: dark)")and listen for changes. -
Transition the theme toggle: When switching themes, add a smooth transition to background/text colors:
@layer base {
html {
@apply transition-colors duration-300;
}
} -
Status column layout: On desktop, show tasks in a Kanban-style three-column layout (To Do | In Progress | Done) using CSS Grid. On mobile, show as a single list with tabs.
-
Animated empty state: When there are no tasks, show an animated illustration (use SVG or emoji with
animate-bounce).
๐ Further Readingโ
- Tailwind v4 Documentation โ the definitive reference
- Tailwind Dark Mode โ official dark mode guide
- Container Queries on MDN
- content-visibility on web.dev โ deep dive on the performance property
- Vercel: Optimizing CSS โ best practices for CSS performance
Next up: Chapter 11 โ shadcn/ui Setup & Core Components โ
We'll bring in a professional component library built on top of Tailwind โ consistent, accessible, and fully customizable because you own the code.