UI styling shapes the storefront's look and feel, including layout, spacing, colors, typography, and interactive states. It provides a consistent, accessible, and responsive shopping experience for your storefront across devices.
Technology Stack
Storefront Next uses Tailwind CSS (v4) for utility-first styling and shadcn/ui patterns, and Radix UI for headless accessible components.
Here are the Tailwind-related dependencies in this package.
Verify dependency versions against the template package.json before each release to ensure version numbers are current.
| Dependency | Version | Purpose |
|---|---|---|
| tailwindcss | 4.x.x+ | Utility-first CSS framework |
| @radix-ui/* | Various | Headless UI primitives |
| class-variance-authority | 0.7.1 | Component variant management |
| clsx | 2.1.1 | Conditional class composition |
| tailwind-merge | 3.4.0 | Tailwind class conflict resolution |
Tailwind CSS Rules
- Use Tailwind utility classes in component JSX for layout, spacing, typography, and colors.
- Use the
cn()utility for conditional or combined class names:import { cn } from '@/lib/utils'. Example:cn('rounded p-4', isActive && 'ring-2'). - Follow mobile-first responsive patterns using breakpoint prefixes:
sm:,md:,lg:,xl:,2xl:. - Do not use inline styles (
style={{ ... }}) for styling. - Do not use CSS modules (
.module.css) or separate CSS files for component-level styles. - Global and theme styles belong in
src/theme/only. The entry point issrc/theme/index.css, with tokens split acrosssrc/theme/tokens/, base resets insrc/theme/base.css, and component overrides insrc/theme/overrides/.
Design Tokens
Colors and theme values are defined as CSS variables (design tokens). Use semantic token-based classes instead of hard-coded colors:
- Backgrounds:
bg-background,bg-muted,bg-card - Text:
text-foreground,text-muted-foreground,text-primary - Borders:
border-border - Interactive:
bg-primary,text-primary-foreground,hover:bg-primary/90
Avoid raw color utilities (e.g. bg-[#hex]) so the app stays consistent with the theme.
Global Styles and CSS Variables
The global and theme styles are in src/theme/ only. The entry point is src/theme/index.css, with tokens split across src/theme/tokens/, base resets in src/theme/base.css, and component overrides in src/theme/overrides/.
@import "tailwindcss";
@import "tw-animate-css";
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
/* ... more semantic tokens */
}
:root {
--radius: 0.625rem;
--background: #ffffff;
--foreground: #3f3f46;
--primary: #2563eb;
--primary-foreground: #ffffff;
--secondary: #f3f4f6;
--muted: #f9fafb;
--accent: #dbeafe;
--destructive: #b91c1c;
--border: #fafafa;
--ring: #3b82f6;
/* ... more tokens */
}
shadcn/ui
Presentational UI components are built on Radix UI primitives with shadcn/ui as the styling layer. They live in src/components/ui/.
Adding Components
Add new components only via the official CLI so they are ejected with the correct config and Tailwind setup:
npx shadcn@latest add <component-name>
This ejects the component into src/components/ui/ with the right dependencies and styles.
Rules
- Do add and customize shadcn components by editing the files in
src/components/ui/. - Do not create custom components inside
src/components/ui/; keep that directory for ejected shadcn components only. - Do not manually copy components from the shadcn docs; always use the CLI so configuration (e.g.
components.json) stays in sync.
Keeping src/components/ui/ limited to ejected shadcn components makes upgrades and maintenance predictable. For custom UI, use src/components/ (or another feature directory) and compose or wrap shadcn components as needed.
UI Component Architecture
Components are stored in src/components/ui/ and follow the shadcn/ui pattern.
- Built on Radix UI primitives for accessibility.
- Styled with Tailwind utility classes.
- Variants managed with
class-variance-authority(cva). - Classes composed using
cn()utility (clsx + tailwind-merge).
The cn() Utility
A helper function that combines clsx for conditional classes with tailwind-merge to resolve conflicting utilities.
// src/lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Component Styling Patterns
Style patterns for components include variant-based components with CVA, compound components, and direct utility class usage.
Variant-Based Components with CVA
With CVA, you can create variants of an existing component by extending its Tailwind classes without overriding them. Here's a minimal example.
// src/components/ui/button.tsx (simplified example)
import { cn } from "@/lib/utils";
type ButtonVariant = "default" | "outline";
type ButtonSize = "default" | "sm";
const variantClasses: Record<ButtonVariant, string> = {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border bg-background hover:bg-accent hover:text-accent-foreground",
};
const sizeClasses: Record<ButtonSize, string> = {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
};
function Button({
variant = "default",
size = "default",
className,
...props
}: React.ComponentProps<"button"> & {
variant?: ButtonVariant;
size?: ButtonSize;
}) {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50",
variantClasses[variant],
sizeClasses[size],
className,
)}
{...props}
/>
);
}
Compound Components
Multi-part components are organized as separate functions:
// src/components/ui/card.tsx
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
Direct Utility Class Usage
Feature components apply Tailwind classes directly.
<Card
className={cn(
"group border rounded-xl overflow-hidden w-full min-w-0 max-w-full",
"flex flex-col-reverse h-full shadow-sm gap-0 py-0",
"transition-all duration-200 hover:shadow-md",
className,
)}
>
{/* ... */}
</Card>
Responsive Design
Tailwind uses prefix-based responsive modifiers.
<div className="px-4 sm:px-6 lg:px-8 py-8">
<div className="grid md:grid-cols-2 gap-6">{/* Content */}</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">{/* Buttons */}</div>
</div>
Color Utility Enforcement
Hardcoded Tailwind color utilities like bg-red and text-green are blocked via an ESLint rule. Use semantic tokens (for example, bg-primary, text-foreground) or CSS variable classes instead.
Component Library and Icons
- Radix UI: Use Radix primitives for accessible behavior (focus, keyboard, ARIA).
- Icons: Use Lucide React and React Simple Icons for iconography.
CSS-Only Decorative Icons
When you want a purely decorative icon in front of (or after) an element whose component you shouldn't fork — a shared title, a label rendered deep in a shadcn primitive — add it with a ::before or ::after pseudo-element in theme CSS instead of editing the JSX. This keeps component-level styling out of components and lets you hook a stable data-slot or structural selector rather than threading a prop through.
The tokens, selectors, and icons below are examples only. Adapt them to your own storefront. The file paths (src/theme/tokens/core.css, and src/theme/base.css) are where global tokens and base rules live.
Use mask + background-color, not content: url(...). A masked SVG is tintable: background-color: currentColor paints the icon in the element's text color, so it tracks light/dark and theme changes automatically. A content: url(...) image renders at its baked-in colors and can't inherit currentColor.
Define the SVG once as a token so it's reusable and themeable. Percent-encode the SVG so characters like #, %, <, >, and quotes survive the data URI intact — an unencoded # or % truncates the URI and the mask silently fails to load. Inside a mask, the SVG's alpha channel is what matters; the stroke color is never painted, so use a literal black as the stroke value (not currentColor, which doesn't resolve inside a mask). The visible color comes from background-color on the pseudo-element.
/* src/theme/tokens/core.css */
--icon-star: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M11.525%202.295a.53.53%200%200%201%20.95%200l2.31%204.679a2.123%202.123%200%200%200%201.595%201.16l5.166.756a.53.53%200%200%201%20.294.904l-3.736%203.638a2.123%202.123%200%200%200-.611%201.878l.882%205.14a.53.53%200%200%201-.771.56l-4.618-2.428a2.122%202.122%200%200%200-1.973%200L6.28%2021.28a.53.53%200%200%201-.77-.56l.881-5.139a2.122%202.122%200%200%200-.611-1.879L2.045%209.865a.53.53%200%200%201%20.294-.904l5.166-.755a2.122%202.122%200%200%200%201.597-1.16z%22%2F%3E%3C%2Fsvg%3E");
--icon-check: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M20%206%209%2017l-5-5%22%2F%3E%3C%2Fsvg%3E");
Apply the icon using ::before or ::after in src/theme/base.css. Set content: "" to activate the pseudo-element, then apply the mask and let background-color: currentColor tint it to match the surrounding text color.
/* src/theme/base.css */
[data-slot="section-title"] {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
[data-slot="section-title"]::before {
content: "";
display: inline-block;
flex-shrink: 0;
width: 1rem;
height: 1rem;
background-color: currentColor;
-webkit-mask: var(--icon-star) center / contain no-repeat;
mask: var(--icon-star) center / contain no-repeat;
}
Swap icons on state by toggling only the mask. Have the component set a data-* attribute on an ancestor when state changes, then add a rule that overrides just the mask image — the size and tint stay in place.
[data-slot="section-title"][data-complete]::before {
-webkit-mask: var(--icon-check) center / contain no-repeat;
mask: var(--icon-check) center / contain no-repeat;
}
note: Keep the decorated structure stable. Decoration hooked to structural selectors (data-slot, :first-child, > span) breaks if that structure shifts. Keep anchored elements mounted — render them empty rather than conditionally removing them — so the icon doesn't detach. If you decorate by position, don't conditionally add or remove the siblings around the decorated element.
Accessibility and Design System
- Use semantic HTML (
<button>,<nav>,<main>, etc.) and appropriate ARIA where needed. - Ensure keyboard navigation and visible focus states for interactive elements.
- Aim for WCAG compliance (contrast, focus order, labels).
- Keep spacing and typography consistent with the design system defined in
src/theme/and Tailwind config.
Reusing Styles: When to Extract
Tailwind's utility-first approach means most styling lives inline in JSX. Before extracting a reusable abstraction, read the official guide on managing reuse — it covers multi-cursor editing, loops, and component extraction as the preferred strategies before reaching for CSS abstractions.
Use a React component (the default choice) when:
- The pattern involves markup structure—multiple elements, slots, children
- There is logic, state, or event handling
- It accepts props that change behavior or content
- It composes other components (shadcn, Radix, etc.)
Use a CSS component class (@layer components in src/theme/base.css) only when:
- The pattern is pure layout/styling—padding, max-width, centering, typography presets
- There is no logic, state, or props—just a bag of CSS properties
- It needs to be applied to many different HTML elements across the codebase (divs, sections, wrappers)
- Utilities need to override it in specific contexts (the components layer is lower specificity than utilities)
Example: section-container—consolidates px-4 sm:px-8 lg:px-16 max-w-screen-2xl mx-auto into one class, used by 30+ files. A page can add max-w-4xl alongside it and the utility wins.
Rule of thumb: if you can express it as a single className string with no JSX children, it's a CSS class. If it renders elements or accepts props, it's a React component.
/* src/theme/base.css — CSS component class */
@layer components {
.section-container {
@apply px-4 sm:px-8 lg:px-16 max-w-screen-2xl mx-auto;
}
}
/* React component — has structure, props, and children */
function CategoryBanner({ title, image }: CategoryBannerProps) {
return (
<div className="section-container">
<img src={image} alt="" />
<h1>{title}</h1>
</div>
);
}
note: Don't use @utility for multi-property compositions that need to be overridable. The utility layer has the highest specificity, so any override attempt (e.g., adding max-w-4xl alongside a @utility class) would lose. Use @layer components instead.
Summary
Quick reference:
| Do | Don't |
|---|---|
| Tailwind utility classes | Inline styles, CSS modules, component-level .css files |
cn() for conditional classes |
Manual string concatenation for className |
Design tokens (bg-background, text-muted-foreground) |
Hard-coded colors |
npx shadcn@latest add <name> |
Manually copying or creating components in src/components/ui/ |
Global/theme styles in src/theme/ |
Scattered or duplicate global CSS |