HeroUI v3 + Tailwind v4 CSS-First Architecture — Accessibility, Dark Mode, and Bundle Optimization Without tailwind.config.js
When Tailwind CSS v4 came out, I'll be honest — I was a bit thrown off. All those theme settings I'd carefully defined in tailwind.config.js, the component libraries wired in as plugins... I wasn't sure what to make of it. But after using HeroUI v3 in a real project, my thinking changed. The core strength of this combination is a simple, CSS-variable-driven architecture that handles accessibility, dark mode, and bundle size all at once. Having fewer config files, with style layers laid bare in the CSS cascade — it made quite an impression.
HeroUI is a React UI component library rebranded from the former NextUI. With v3, released in 2026, it fully adopts React 19 and Tailwind v4, reducing bundle size and strengthening accessibility — with quite a few things working differently from before.
This article is aimed at frontend developers already using Tailwind and React. I've put together an honest look at the differences from shadcn/ui, what changes when migrating to Tailwind v4, and the pitfalls I personally ran into.
Core Concepts
These core concepts may seem independent, but they're actually connected. The CSS-First architecture exposes OKLCH color tokens as CSS variables, and the Compound Components pattern leverages those variables to enable flexible customization. Let's go through them in order.
CSS-First Architecture: Why tailwind.config.js Is No Longer the Default
The biggest change in Tailwind v4 is how configuration works. Previously, you'd register plugins in tailwind.config.js and define colors and fonts under theme.extend. HeroUI v2 integrated the same way.
In v3, tailwind.config.js is no longer the recommended approach. It hasn't disappeared entirely — it still works for legacy compatibility — but new projects no longer need it. All configuration now lives in a single globals.css file.
@import "tailwindcss";
@import "@heroui/styles";
@theme {
--color-primary: oklch(0.6204 0.195 253.83);
--radius: 0.5rem;
}You bring in Tailwind and HeroUI styles via @import, then define your theme as CSS variables in a @theme block. At first it feels almost too simple — but that simplicity is the real strength. With style layers exposed directly in the CSS cascade, DevTools shows you exactly where each value comes from at a glance. I've spent a lot less time digging through JavaScript config files.
Note that @heroui/styles is a separate package not included in @heroui/react by default. It's split out to support headless mode — using component logic without any styles — so you'll need to install both packages together.
OKLCH Color Tokens: Dark Mode Has Never Been This Easy
In HeroUI v3, all design tokens are defined using the oklch() function.
:root {
--background: oklch(0.9702 0 0);
--foreground: oklch(0.2103 0.0059 285.89);
--accent: oklch(0.6204 0.195 253.83);
--danger: oklch(0.6532 0.2328 25.74);
}
.dark {
--background: oklch(0.15 0.02 285);
--foreground: oklch(0.95 0 0);
--accent: oklch(0.55 0.18 253.83);
}Those long numbers looked cryptic to me at first too, but you get used to them. In oklch(L C H), L is lightness (0–1), C is chroma (practical range is roughly 0–0.4), and H is hue (0–360).
OKLCH is a color space introduced in CSS Color Level 4 that uniformly represents lightness, chroma, and hue as perceived by humans. Colors at the same L value appear at a similar perceived brightness regardless of hue, making it easy to maintain visual balance across a full palette. Compared to manually tweaking dark mode colors in HEX, simply lowering the background L value produces a naturally dark palette.
Dark mode switching is handled entirely with CSS selectors — no JavaScript provider needed. Just add a .dark class or [data-theme="dark"] attribute to <html> and you're done. Since there's no flash of unstyled content during SSR, it works cleanly in Next.js environments too.
Compound Components Pattern: How It Differs from shadcn/ui
HeroUI v3 components use the Compound Components pattern. Rather than shadcn/ui's approach of copying component source and modifying it directly, HeroUI exposes each internal element as a sub-component, enabling customization through composition.
<Modal>
<Modal.Backdrop />
<Modal.Container>
<Modal.Dialog>
<Modal.Header>Title</Modal.Header>
<Modal.Body>Body content</Modal.Body>
<Modal.Footer>
<Button slot="close">Close</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal>The real value of this pattern shows up with partial customization. You can remove just the Modal.Backdrop, or attach a className directly to Modal.Header, without affecting anything else. The shadcn/ui approach gives you complete freedom by modifying source files directly, but comes with the tradeoff of difficulty keeping up with library updates. HeroUI's pattern sits somewhere in between.
Framer Motion Is Gone — You'll Feel the Difference
In v2, all animations depended on Framer Motion. Open the network tab and you'd see the Framer Motion chunk loading just to open a Modal. In v3, that's gone. It's been removed from the default dependencies, replaced with native CSS transitions and keyframes. You keep GPU acceleration while noticeably reducing the runtime JavaScript overhead.
Practical Application
The examples below build from a base setup up through simple components, complex interactions, and theme customization. Each example layers on top of the previous setup, so following along in order will give you the full picture.
Example 1: Initial Project Setup
Installation requires just two packages. @heroui/react handles component logic and @heroui/styles handles styling — they're separated by design.
pnpm add @heroui/react @heroui/stylesThen add the imports to globals.css:
@import "tailwindcss";
@import "@heroui/styles";
@theme {
--color-primary: oklch(0.6204 0.195 253.83);
--color-secondary: oklch(0.5312 0.1784 327.72);
--radius: 0.5rem;
}Then, in Next.js, wrap your root layout.tsx with a Provider:
import { HeroUIProvider } from "@heroui/react";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<HeroUIProvider>{children}</HeroUIProvider>
</body>
</html>
);
}| File | Role |
|---|---|
globals.css |
Tailwind + HeroUI style imports, theme variable definitions |
layout.tsx |
Wrapping the app with HeroUIProvider |
tailwind.config.js |
No longer needed in v4 |
Example 2: Customizing the Button Component
Button is the most frequently used component, so customization scenarios vary widely. Since HeroUI uses tailwind-merge internally, you can override classes via className without worrying about conflicts.
import { Button } from "@heroui/react";
// Using built-in variants
<Button color="primary" variant="solid">Save</Button>
<Button color="primary" variant="bordered">Cancel</Button>
<Button color="danger" variant="light">Delete</Button>
// Direct override with Tailwind classes
<Button className="bg-accent text-white rounded-xl px-8">
Custom Button
</Button>
// Loading state
<Button isLoading color="primary">
Processing
</Button>tailwind-merge is a utility that automatically resolves Tailwind class conflicts. When classes for the same property collide — like
className="bg-red-500 bg-blue-500"— it ensures only the last one applies. Since HeroUI uses it internally,classNameoverrides work as expected.
Example 3: Modal Component — A Complete Open/Close Example
The power of the Compound Components pattern really comes through when you see working code. Here's an example that includes state management and slot="close" behavior:
"use client";
import { useState } from "react";
import { Modal, Button } from "@heroui/react";
export function ConfirmModal() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button color="primary" onPress={() => setIsOpen(true)}>
Open Modal
</Button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Modal.Backdrop />
<Modal.Container>
<Modal.Dialog>
<Modal.Header>Confirmation Required</Modal.Header>
<Modal.Body>Are you sure you want to proceed?</Modal.Body>
<Modal.Footer>
{/* slot="close" lets Modal wire up onClose automatically */}
<Button variant="light" slot="close">Cancel</Button>
<Button
color="primary"
onPress={() => {
// handle action
setIsOpen(false);
}}
>
Confirm
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal>
</>
);
}A Button with slot="close" has onClose wired up automatically by the Modal — no separate handler needed. The fact that you can add className independently to Modal.Header, Modal.Body, and Modal.Footer without affecting anything else is central to how this pattern works.
Example 4: Virtualization for Large Data Tables
Rendering thousands of rows into the DOM causes a steep performance drop. This is a situation that comes up often in production, and previously required adding react-window or TanStack Virtual separately. HeroUI Table's isVirtualized prop lets you enable virtualization — rendering only the rows visible in the viewport — with a single prop.
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@heroui/react";
type User = {
id: number;
name: string;
email: string;
role: string;
};
const columns = [
{ key: "name", label: "Name" },
{ key: "email", label: "Email" },
{ key: "role", label: "Role" },
];
export function UserTable({ users }: { users: User[] }) {
return (
<Table isVirtualized aria-label="User list">
<TableHeader columns={columns}>
{(column) => (
<TableColumn key={column.key}>{column.label}</TableColumn>
)}
</TableHeader>
<TableBody items={users}>
{(user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.role}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}Since the library handles this internally, it's one fewer dependency to manage.
Example 5: Dark Mode Theme Toggle — Safe in Next.js SSR
The CSS variable definitions are the same as shown earlier. The tricky part is the toggle button — touching document directly will throw a document is not defined error in Next.js SSR. It's safer to handle it inside useEffect or combine it with the next-themes library.
/* globals.css */
:root {
--background: oklch(0.9702 0 0);
--foreground: oklch(0.2103 0.0059 285.89);
--accent: oklch(0.6204 0.195 253.83);
}
.dark {
--background: oklch(0.15 0.02 285);
--foreground: oklch(0.95 0 0);
--accent: oklch(0.55 0.18 253.83);
}"use client";
import { useEffect, useState } from "react";
import { Button } from "@heroui/react";
export function DarkModeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Only runs after SSR completes
const html = document.documentElement;
if (isDark) {
html.classList.add("dark");
} else {
html.classList.remove("dark");
}
}, [isDark]);
return (
<Button variant="light" onPress={() => setIsDark((prev) => !prev)}>
{isDark ? "Light Mode" : "Dark Mode"}
</Button>
);
}In real projects, combining this with next-themes tends to be more convenient — it handles system theme detection, localStorage persistence, and initial flash prevention all at once.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Lighter bundle | Removing Framer Motion as a default dependency reduces runtime JS animation overhead |
| Built-in accessibility | Based on React Aria Components; keyboard navigation and screen reader support handled automatically |
| Server Component compatible | Supports React Server Components — an advantage over shadcn/ui |
| Simplified dark mode | No JS provider needed; pure CSS variable switching with no SSR flash |
| AI tool friendly | MCP Server support makes integration with Claude Code and Cursor straightforward |
| Polished defaults | Refined out-of-the-box styling reduces the cost of building a separate design system |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Package dependency | Unlike shadcn/ui's copy-paste approach, you're tied to an npm package | For long-term projects, periodically check the library's maintenance status |
| v2 → v3 breaking changes | Numbered color scales like primary-50 removed, plugin-based integration deprecated |
Read the official migration guide thoroughly before migrating |
| Requires Tailwind v4 | Incompatible with Tailwind v3 and below | Upgrading Tailwind is required when adopting in existing projects |
| OKLCH learning curve | Requires understanding color space concepts and CSS variable layers | Start with figma-to-oklch conversion tools and official theme presets |
| Smaller ecosystem | Fewer community resources and examples compared to shadcn/ui and MUI | Lean on official documentation and GitHub Discussions |
The Most Common Mistakes in Production
-
Leaving the HeroUI plugin in
tailwind.config.js— v3 has switched to CSS file imports, so keeping the old plugin configuration causes conflicts. Replace it entirely with@import "@heroui/styles"inglobals.css. -
Giving up when
classNameoverrides don't work — HeroUI resolves class conflicts usingtailwind-merge. Rather than simply adding aclassName, you need to explicitly override the conflicting class. Try using a more specific Tailwind class before reaching for!important. -
Using HEX colors directly when switching dark mode — Bypassing the OKLCH variable system and hardcoding HEX colors per component will produce unexpected colors when dark mode activates. Keep colors defined as CSS variables and reference them via
var(--color-primary).
Closing Thoughts
The core strength of the HeroUI v3 + Tailwind v4 combination is a simple, CSS-variable-driven architecture that handles accessibility, dark mode, and bundle size all at once. The absence of tailwind.config.js as a default feels unfamiliar at first, but once you use it, you'll find that less configuration and clearer style layers make for a noticeably better experience.
Three steps to get started right now:
-
Try it in a new Next.js project first. Create a project with
pnpm create next-app, install withpnpm add @heroui/react @heroui/styles, and add@import "tailwindcss";and@import "@heroui/styles";toglobals.css— that's your base setup done. -
Get familiar with the Compound Pattern through the Button and Modal components. Build a simple CRUD screen with HeroUI and you'll quickly get a feel for how styling each sub-component independently differs from other approaches.
-
Try editing the OKLCH variables in
:rootdirectly. Change--color-primaryand watch how light and dark mode stay in sync through CSS variables alone — it's the fastest way to internalize how this architecture actually works.
References
- HeroUI v3 Official Docs — Introduction
- HeroUI v3 Release Notes — v3.0.0
- HeroUI v3 Theming Official Docs
- Tailwind v4 Migration Guide | beta.heroui.com
- HeroUI v2.8.0 Blog Post — First Tailwind v4 Support
- HeroUI v3 Complete Guide — BSWEN Blog
- Migrating to Tailwind v4 with HeroUI — Medium
- GitHub Discussion — HeroUI v3 Roadmap
- tailwind-variants GitHub
- HeroUI vs shadcn/ui Comparison — thesys.dev