Sass vs. Tailwind: 133 Lines and One Arbitrary Selector
Sass's four features, checked one at a time against this codebase's real @theme block, group-hover usage, and the single [&_selector] Tailwind still reaches for.
Sass adds variables, nesting, mixins and file splitting on top of CSS you still write by hand; Tailwind replaces hand-written CSS with utility classes compiled from a token set, so most of what Sass is for stops being a question. This storefront ships zero .scss files and one 133-line stylesheet. Here is where each of Sass's four features actually goes, checked against real code, and the one place the utility model still reaches for something Sass would have given for free.
The comparison at a glance
| Sass | Tailwind CSS v4 (this repo) | |
|---|---|---|
| Variables | $variable, scoped by file | @theme tokens, real CSS custom properties |
| Nesting | Native, any depth | None — composed via group/peer variants, or an arbitrary [&_selector] escape hatch |
| Mixins | @mixin / @include | @apply (used twice in this codebase) or a component function |
| File splitting | @use partials, one concept per file | Not needed at 133 lines; one globals.css |
| Build step | A compiler (sass, dart-sass, a bundler loader) | A PostCSS plugin already required for Tailwind itself |
| Output | Whatever you wrote, verbatim | Only the classes actually referenced in markup |
| Runtime cost | None — compiles to plain CSS | None — compiles to plain CSS |
The last row is why this isn't the Chakra UI question. Styling React components covers the axis that actually matters for a CSS-in-JS library — whether it needs a Client Component boundary. Sass and Tailwind both compile away before the browser sees anything; the real difference is what each one asks a human to author.
Variables: $brand vs @theme
A Sass variable is a compile-time constant scoped to whichever files @use it:
// hypothetical: styles/_tokens.scss
$color-primary: #465fff;
$color-primary-600: #3641f5;
// styles/button.scss
@use "tokens" as *;
.btn-primary { background: $color-primary; }
This repo's equivalent is @theme in src/app/globals.css, and it does one more thing than a Sass variable can: it emits a real CSS custom property and the utility that reads it, from the same declaration.
/* src/app/globals.css */
@theme {
--color-primary: #465fff;
--color-primary-600: #3641f5;
/* … 41 tokens total, audited in Tailwind design tokens */
}
bg-primary and text-primary-600 exist because that block exists — no separate class definition, and no @use import at any call site. The token audit (41 declared, against Tailwind's 419 shipped defaults) is its own post: Tailwind design tokens.
Nesting: eleven group-hover call sites, and one real gap
Sass nesting writes the parent–child relationship where you'd read it:
// hypothetical
.card {
&:hover .icon { opacity: 1; }
}
Tailwind has no nesting primitive, because a utility class styles the element it's on, not a descendant. The compositional answer is group/group-hover, used in 11 component files in this codebase — the parent gets group, the child reaches up with group-hover:opacity-100. It reads correctly in the markup: the relationship lives at the two elements it actually connects, not in a third stylesheet.
// TemplateCard.tsx — the hover preview overlay
<div className="group/media relative">
<div className="invisible opacity-0 transition group-hover/media:visible group-hover/media:opacity-100">
That covers "style this element when its ancestor is hovered." It does not cover "style whatever the CMS put inside this box," and this codebase has exactly one place that needs it: legal prose rendered from MDX.
// src/components/templates/LegalTemplate.tsx
<div className="mt-8 space-y-6 text-base leading-7 text-text-color dark:text-white/80
[&_h2]:mt-10 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:text-title-color
[&_li]:ml-5 [&_li]:list-disc [&_table]:w-full [&_table]:text-sm">
/terms, /privacy and /refund-policy render Markdown-shaped content whose <h2>, <li> and <table> tags this component never touches directly — there's nothing to put a class on. [&_h2]:mt-10 is Tailwind's arbitrary-variant escape hatch, and it is doing exactly what a Sass .legal-prose h2 { margin-top: 2.5rem; } nested rule would do. One selector, in one file, is the entire nesting gap this repo has ever hit. Every other parent–child relationship in 91 components is expressible as group/peer, because every other one is between elements this codebase authored, not markup it received.
Mixins: @apply, used exactly twice
A Sass mixin captures a reusable declaration block:
// hypothetical
@mixin base-body { background: white; font: inherit; color: #344054; }
body { @include base-body; }
Tailwind's @apply is the direct equivalent, and this codebase's entire usage is the base body style:
/* src/app/globals.css */
@layer base {
body {
@apply bg-white font-sans text-base font-normal text-gray-700;
}
.dark body {
@apply bg-gray-900 text-gray-300;
}
}
Two rules, both about the one element every page shares. Nothing else in the stylesheet reaches for @apply, because a mixin's usual job — a reusable declaration block applied at several call sites — is instead a reusable component: instead of @include button-primary on three different class names, there's one <Button variant="primary"> atom. The unit of reuse moved from a CSS block to a component prop, which sidesteps needing the CSS-level tool at all.
File splitting: 133 lines don't need @use
Sass earns @use and partials at scale — a design system with hundreds of rules genuinely needs _colors.scss, _typography.scss, _mixins.scss split apart and composed. This repo's entire hand-written stylesheet is 133 lines: a @theme token block, one responsive .container rule, a dark-mode @custom-variant, one @keyframes, and a two-selector utility layer for hiding scrollbars. There's nothing to split. If the token block grew to the size that made one file unwieldy, @theme supports the same @import composition Sass partials do — the ceiling exists, this codebase is nowhere near it.
The compiled output
Both approaches produce plain CSS with no runtime cost — the meaningful difference is what ships, not how it executes. A production build of this site (npm run build) emits one CSS file for the entire 111-product catalog, all eight core routes, and every component that renders on any of them:
.next/static/chunks/2cdc99m3g_kds.css 75,791 bytes raw / 13,662 bytes gzipped
That's the whole site's styling, not a per-page slice — Tailwind's compiler walks every className string that's actually referenced and emits only those utilities. A Sass build has no equivalent step: it compiles whatever selectors you wrote, whether or not any markup still uses them, which is why Sass codebases accumulate a separate dead-CSS-detection tool (PurgeCSS, historically bolted onto Tailwind v2 for the same reason v3+ don't need it).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
A new bg-primary utility doesn't exist after adding a token | Tailwind v4 + Turbopack doesn't hot-reload @theme edits | Restart npm run dev — nothing errors, the class just never generates |
@apply on a class that doesn't exist yet | The referenced utility isn't in the token set or core Tailwind | Check @theme first; @apply can't invent a utility, only reference one |
An [&_h2] rule doesn't fire | The descendant selector's specificity lost to a more specific class already on the element | Arbitrary variants compile to a plain CSS selector — normal cascade rules still apply, unlike a Sass @extend |
Reaching for @mixin-style duplication across five components | The instinct is right, the tool is wrong here | Extract a component, not a class — see Styling React components for the Server Component constraint that makes this the correct default |
A Sass migration guide says "just replace $var with a CSS custom property" | True for the variable, not for nesting or mixins | Only Tailwind's @theme gives you the class and the property from one declaration — a bare :root custom property doesn't |
Frequently asked questions
Can Sass and Tailwind be used together?
Yes — Tailwind's own docs don't forbid it, and some teams run Sass for globals/animations while Tailwind handles component classes. This repo doesn't, because there was never a rule that needed a preprocessor: one @theme block replaced the variables, group/peer replaced the nesting, and @apply covers the two spots that wanted a mixin.
Does Tailwind CSS need a build step like Sass does?
Yes, but it's one you already have. Tailwind compiles through PostCSS (@tailwindcss/postcss in this repo's postcss.config.mjs), which Next.js runs as part of next build regardless. Adding Sass would mean a second, separate compiler in the pipeline; Tailwind's is the one already there.
What does Tailwind lose without nesting?
Almost nothing for markup you author yourself — group/peer covers ancestor-triggered styling, and co-locating the class on the element it affects is arguably clearer than a nested rule three levels deep in a .scss file. The real gap is markup you don't author: this codebase's one [&_selector] case is legal prose from Markdown, where there's no JSX to attach a class to.
Is Sass still worth learning if a project uses Tailwind?
For maintaining an existing Sass codebase, yes. For starting a new one, the honest comparison is: Sass gives you better hand-written CSS, and Tailwind gives you a reason to write less of it by hand in the first place. Whether that trade is worth it depends on how much of the styling is one-off versus how much is a design system with a token set — the second case is what @theme was built for.
Templates in this post
ASoc Remit, a payments-platform landing page, ships the same token-driven @theme setup as this site — a brand palette and a marketing layout with zero .scss anywhere in its build. ASoc Script, an AI copywriting landing page, and ASoc Seeker, an AI keyword-research landing page, both lean on group-based hover interaction for their feature sections rather than nested selectors.
Browse the full sets: Next.js landing page templates and Tailwind landing page templates.
