Tailwind Custom Class in v4: @utility vs @layer vs @theme
Learn when to use @utility, @layer components, @theme or plain CSS for custom Tailwind v4 classes, with examples from a real Next.js storefront.
To add a Tailwind custom class in v4, use @utility when the class should support variants such as hover: and md:, @layer components for a reusable component style, or @theme when you are adding a design token. Plain CSS is still valid. The important choice is whether the class needs Tailwind's utility behavior.
Choose the right extension point
Tailwind CSS v4 is CSS-first. You no longer need to create a tailwind.config.js merely to add a color or a class. But "custom class" describes four different things, and using the wrong mechanism creates bugs that are difficult to see until you try to override a style.
| What you need | Write it in CSS as | Example use | Supports hover: and md: automatically? |
|---|---|---|---|
| A new utility with Tailwind variants | @utility | no-scrollbar | Yes |
| A reusable, overrideable component style | @layer components | product-card | No; write its states explicitly |
| A new color, font or breakpoint | @theme | bg-primary, xsm:flex | Yes, through generated built-in utilities |
| A layout rule with its own CSS behavior | Ordinary selector | .container | No; use CSS selectors and media queries |
There is also @custom-variant, which adds a prefix, not a standalone class. We use it for dark: in this storefront. These choices are separate from the basics of what a Tailwind class is and from the decision to install a Tailwind plugin.
The examples below come from ASoc's actual Next.js 16 and Tailwind v4 codebase. Where a snippet is a proposed improvement rather than code currently shipped, it is labeled as such. The official reference is Tailwind's adding custom styles guide.
First-party example: a custom class that is deliberately plain CSS
The marketplace's src/app/globals.css has a .container class that is not a registered Tailwind utility. It is a normal selector, with its own gutter and breakpoint rules:
/* src/app/globals.css — excerpt from the shipped stylesheet */
.container {
width: 100%;
margin-inline: auto;
padding-inline: 1rem;
}
@media (min-width: 425px) {
.container {
max-width: 425px;
}
}
@media (min-width: 640px) {
.container {
max-width: 640px;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
The file continues with more breakpoints, including a gutter change at 1440px that does not change max-width. That independent behavior is the reason we did not replace the selector with Tailwind's built-in container utility. Our container deep dive explains the exact difference.
This is a good reason to write ordinary CSS: one stable layout contract is easier to audit than a dozen utility classes copied across every page. It also means that md:container is not a variant of this rule. If you need responsive behavior, put it in the selector's media queries, as this file does.
A subtle detail: normal, unlayered CSS rules participate in the cascade differently from declarations in named layers. Do not assume that adding px-4 beside container will always override the container's own padding-inline; inspect the compiled cascade and the relevant breakpoint. For this site, changing container gutters means editing the container rule itself.
Second example: the scrollbar class and the case for @utility
The same production stylesheet contains a hand-written scrollbar helper:
/* src/app/globals.css — exact shipped rule */
@layer utilities {
/* Hide scrollbar for horizontal carousels */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
That rule is valid CSS. But in v4, a selector inside native @layer utilities is not registered as a Tailwind custom utility. You can use className="no-scrollbar" if the stylesheet is loaded, but should not expect md:no-scrollbar or hover:no-scrollbar to be generated for it.
If we needed those variants, we would replace the existing rule with this @utility version; the next snippet is an illustrative migration, not code currently committed to ASoc:
/* Proposed v4 alternative — do not keep both definitions */
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
Now Tailwind knows no-scrollbar is a utility. When the full class name appears in a scanned source file, it can generate responsive and state variants. That is the key difference between @utility and a plain selector in @layer utilities. The syntax also supports functional utilities with --value(), but a static name is the right fit for a scrollbar helper.
One caution: hiding a scrollbar does not make an overflowing area keyboard accessible. Keep a visible focus path and usable scrolling interaction. A cosmetic class cannot repair an inaccessible carousel.
Third example: a token is not a component class
For brand colors, this repo does not define .btn-primary in CSS. Instead, src/app/globals.css declares a palette:
/* src/app/globals.css — selected shipped declarations */
@theme {
--font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
--color-primary: #465fff;
--color-primary-500: #465fff;
--color-primary-600: #3641f5;
--color-stroke-tertiary: #d0d5dd;
--breakpoint-xsm: 425px;
}
Those names unlock families of Tailwind utilities. For example, --color-primary makes bg-primary, text-primary, border-primary and their variants possible. The token is the reusable contract; each component chooses the actual utilities.
Our src/components/atoms/Button.tsx demonstrates the other half, using complete class strings rather than inventing a CSS component class:
// src/components/atoms/Button.tsx — shipped variant map
const variants: Record<ButtonVariant, string> = {
primary: "bg-primary text-white hover:bg-primary-600",
outline:
"border border-stroke-tertiary bg-white text-text-color hover:bg-gray-50 hover:text-gray-800",
dark: "bg-gray-900 text-white hover:bg-gray-800",
};
This matters for two reasons. First, the brand value lives in one place: changing --color-primary updates the corresponding utilities without rewriting every button. Second, each complete class name is present in source, so Tailwind's scanner can find it. That is more reliable than constructing bg-${color} at runtime.
Use @theme for shared design decisions; use component props and a finite variant map for how a component expresses those decisions. The trade-offs, including literal colors that bypass the token system, are documented in our design-token audit.
Fourth example: a custom variant is a prefix, not a class
This storefront enables class-based dark mode with one line near the top of src/app/globals.css:
/* src/app/globals.css — shipped */
@custom-variant dark (&:where(.dark, .dark *));
When the document has a dark class, a utility such as dark:bg-gray-900 can apply. The dark prefix is defined by @custom-variant; it is not a CSS class you should try to recreate with @utility dark.
This distinction matters in a design system. @theme creates values, @utility creates utility names, and @custom-variant creates conditions under which those utilities apply. A single project can use all three without adding a JavaScript plugin. See Tailwind dark mode for the selector and theme-toggle implications.
A safe step-by-step workflow for a new custom utility
Suppose your team wants to hide a scrollbar only on large screens. Start by checking whether a built-in utility already solves the problem. If not, add a top-level @utility declaration to the CSS entry point that already imports Tailwind.
@import "tailwindcss";
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
This is a standalone example, not a request to replace the site's existing stylesheet. In a real project, keep your existing @theme, @custom-variant, and other rules when making the change.
Then reference the complete class in a source file:
export function HorizontalItems({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-x-auto lg:no-scrollbar" tabIndex={0}>
{children}
</div>
);
}
The JSX above is an illustrative usage example. It shows where the variant belongs, not a claim that this component ships in ASoc. Keyboard focus is a useful starting point, but an actual carousel still needs accessible labeling, controls and focus testing.
Run the project build and inspect the resulting CSS in DevTools. Verify that the lg: rule is present and activates at the intended viewport width. Do not stop at "the class is visible in the HTML": a class name in markup does not prove the compiler generated a matching rule.
If you are using Next.js with Tailwind v4, this repo's postcss.config.mjs uses @tailwindcss/postcss; there is no need to run the old npx tailwindcss init command. If a new @theme token does not appear during development, restart the dev server before debugging further — this project has encountered theme hot-reload issues with its Turbopack setup.
When should you use @apply?
@apply inlines existing Tailwind utilities into a CSS rule. It is useful for third-party markup you cannot edit or a stable component class that genuinely belongs in CSS. It is not required just because two buttons share a color.
For example, a component-level rule can be written as:
/* Illustrative component rule */
@layer components {
.product-card {
@apply rounded-lg border border-gray-200 bg-white p-6;
}
}
The component layer is intentionally overrideable by utilities. A caller can use className="product-card rounded-none" to express a local exception. By contrast, a @utility declaration participates in the utility layer and gets Tailwind's variants. They solve different problems.
If @apply reports an unknown utility inside a CSS Module or a separately compiled component stylesheet, check whether that stylesheet has access to your theme. Tailwind documents @reference for importing the theme for reference without duplicating emitted CSS. Avoid copying an entire global stylesheet into every component to fix one missing token.
The dynamic-class trap
Tailwind scans source text for complete candidate strings. It does not execute JavaScript to discover class names. This is unsafe:
// Avoid: the scanner cannot see a complete bg-* class
const className = `bg-${color}-600`;
Instead, follow the same approach as ASoc's Button atom: enumerate complete variants.
// Illustrative mapping; the real Button map is shown above
const colors = {
blue: "bg-blue-600 hover:bg-blue-700",
red: "bg-red-600 hover:bg-red-700",
} as const;
When the source lives outside the default scan paths, @source can register that location. For an exceptional class that is not present literally in source, @source inline() can force generation. Those are explicit escape hatches, not a reason to build arbitrary utility names at runtime. See Tailwind's source detection documentation.
Troubleshooting custom Tailwind classes
| Symptom | Likely cause | What to check |
|---|---|---|
no-scrollbar works but lg:no-scrollbar does not | Plain @layer selector, not @utility | Register a utility when variants are required |
bg-primary produces no style | Missing or misplaced --color-primary theme token | Confirm the @theme declaration is in the processed CSS entry |
| A custom class appears in HTML but has no CSS | Class not registered or source not scanned | Inspect generated stylesheet and source-detection rules |
bg-${color}-600 works for one color but not another | Runtime string construction | Use a map of complete, literal classes |
@apply fails inside a CSS Module | Separate compilation context cannot see theme | Add the documented @reference |
A container padding utility seems ignored | Unlayered selector competes in the cascade | Inspect the winning declaration and edit the layout rule |
| New theme value does not update during dev | Stale development compilation | Restart the dev server and rebuild |
These are different failure modes. Reinstalling Tailwind is rarely the first useful step. Check the mechanism you chose, the compiled CSS and the exact selector before changing dependencies.
FAQ
Can I still write .my-class { ... } in Tailwind v4?
Yes. Tailwind does not forbid ordinary CSS. Use a plain selector for behavior that should not participate in Tailwind's generated variant system. Use @utility if you need hover:my-class, lg:my-class or other variants.
Is @layer utilities the same as @utility?
No. In v4, @layer is a native CSS cascade layer; it does not automatically register the enclosed selector as a Tailwind utility. @utility registers it, enabling Tailwind's variant handling.
Should a custom button be a CSS class or a React component?
If the button has meaningful props, loading and disabled states, accessibility rules, or multiple visual variants, a component is usually the stronger boundary. ASoc uses a Button atom with a static class map and @theme tokens rather than one global .btn class.
Do I need a plugin or tailwind.config.js for custom classes?
No for most v4 cases. The CSS-first directives handle tokens, utilities and variants directly. Reach for a plugin only when you need a reusable extension that cannot be expressed cleanly in CSS.
Apply the pattern to a real storefront
The useful test of a custom class is not whether the syntax compiles. It is whether another developer can predict where its value comes from, how it responds at a breakpoint, and what wins when a component overrides it.
If you are building an ecommerce UI, inspect the styles and components of ASoc Tune, ASoc Vogue, or ASoc Volt. You can also browse the Next.js shop template category to see where shared tokens and local component styles need to coexist. The goal is to keep the theme reusable without turning every one-off layout rule into a global utility.
Sources: Tailwind CSS — Adding custom styles, Functions and directives, Detecting classes in source files. Repository examples: src/app/globals.css, src/components/atoms/Button.tsx, and postcss.config.mjs (reviewed 2026-10-30).
