Sass is CSS with variables, nesting, mixins and imports, compiled to plain CSS at build time. In a Vite project it is one dependency and a file rename — there is no configuration at all.
npm install -D sass// main.tsx — rename the file and change the extension. That is the entire setup.
import './styles/theme.scss';Vite sees .scss, finds sass installed, and compiles it. No loader, no
plugin, no vite.config.ts change.
Every valid CSS file is a valid SCSS file, so converting an existing stylesheet is a rename. You then adopt Sass features where they earn their place, rather than rewriting anything. That is exactly how the file below came about.
Partials and @use
A file whose name starts with an underscore is a partial: Sass compiles it into whatever imports it rather than into a stylesheet of its own.
// src/styles/_tokens.scss
@use 'sass:color';
$pizza-red: #d8102a;
$pizza-black: #231f20;
$pizza-cream: #fff8f0;
// Derived, not typed out. Retune $pizza-red and the hover state follows.
$pizza-red-dark: color.adjust($pizza-red, $lightness: -10%);
$card-radius: 0.75rem;
$transition-fast: 0.15s ease;// src/styles/theme.scss
@use 'tokens' as *;@use, not @import. @import is deprecated and being removed:
it re-evaluated a file every time it was imported, and dumped everything into one global namespace.
@use loads a file once and namespaces its members.
as * drops those members into the current file's namespace so they can be written
$pizza-red rather than tokens.$pizza-red. Without it the namespace is the
filename, which is the safer default in a large project.
Sass variables versus CSS custom properties
This is the part worth understanding properly, because it is not obvious and getting it wrong is the usual cause of "why can't I override this".
$sass-variable | --custom-property | |
|---|---|---|
| Exists at | build time | runtime, in the browser |
| In the output | substituted and gone | a real value the browser holds |
| Can be overridden per subtree | no | yes |
| Visible in DevTools | no | yes |
| Sass can do arithmetic on it | yes | no |
So the palette is declared once in Sass and published as custom properties. The
interpolation syntax #{…} is what puts a Sass value into a CSS one:
:root {
/*
* The Sass variables, re-emitted as custom properties.
*
* This looks redundant and is not. `$pizza-red` vanishes at build time; `--pizza-red` is a real
* value the browser holds and that Bootstrap, DevTools and any component override can reach.
* Declaring the palette once in Sass and publishing it here keeps a single source of truth for
* both halves.
*/
--pizza-red: #{$pizza-red};
--pizza-red-dark: #{$pizza-red-dark};
--pizza-black: #{$pizza-black};
--pizza-cream: #{$pizza-cream};
/* Bootstrap token overrides — every btn-primary, link and focus ring follows these. */
--bs-primary: var(--pizza-red);
--bs-link-color: var(--pizza-red);
--bs-link-hover-color: var(--pizza-red-dark);
}Rule of thumb: a Sass variable if only the build needs it, a custom property if the browser does. Bootstrap 5.3 reads custom properties, so the palette has to be published — and a component that wants to retune one for its own subtree can, which a compiled-away variable could never allow.
color.adjust is the other half of that trade: Sass can compute a darker shade,
the browser cannot compute one from a var(). Note that modern Dart Sass emits the result
as rgb() with percentages rather than a hex code — valid CSS, occasionally surprising in
DevTools.
Nesting
Write a child rule inside its parent and Sass concatenates the selectors:
.pizza-brand {
font-weight: 800;
font-size: 1.5rem;
letter-spacing: -0.5px;
color: #fff;
// Nesting. `span` compiles to `.pizza-brand span`, so the "Hub" half of the wordmark is
// styled next to the rule it belongs to instead of in a separate selector further down.
span {
color: var(--pizza-red);
}
}Note the // comment. Sass supports single-line comments, and they are stripped from
the output — unlike /* … */, which is preserved.
The parent selector
& is the selector of the enclosing block, and it can be suffixed as well as
prefixed. That is what lets a state be written inside the block it modifies:
.product-card {
border: 0;
border-radius: $card-radius;
// `&` is the parent selector. This compiles to `.product-card:hover`.
&:hover {
transform: translateY(-4px);
}
}
.demo-logins {
> summary {
&::-webkit-details-marker { display: none; }
&::before { content: '▸'; }
}
// `&[open] > summary::before` — the parent selector can be suffixed, not just prefixed.
&[open] > summary::before {
transform: rotate(90deg);
}
}Do not over-nest
The classic Sass mistake. Every level adds specificity, and four levels of nesting produces selectors that can only be overridden by more nesting:
// Compiles to `.page .content .card .body .title` — specificity 0,5,0 and impossible to override.
.page {
.content {
.card {
.body {
.title { font-weight: 700; }
}
}
}
}Two or three levels, and nest to express a relationship rather than to mirror the DOM. The component styles above are all one or two deep.
Worth knowing: plain CSS has nesting now, in every current browser. It is one of the main reasons to reach for Sass, and it is no longer exclusive to it.
Mixins
A reusable block of declarations. @content is where the caller's own declarations get
injected, which is what makes mixins useful for wrapping media queries:
/*
* Wrap a block so it only applies to visitors who have NOT asked the OS to reduce animation.
*
* `@content` is where the caller's declarations get injected. Without a mixin this media query
* would be repeated at the bottom of the file, far from the transition it disables — which is
* exactly how the two drift apart.
*/
@mixin motion-safe {
@media (prefers-reduced-motion: no-preference) {
@content;
}
}.product-card {
border: 0;
border-radius: $card-radius;
box-shadow: 0 2px 10px rgb(0 0 0 / 8%);
height: 100%;
@include motion-safe {
transition:
transform $transition-fast,
box-shadow $transition-fast;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgb(0 0 0 / 14%);
}
}
}That produces:
.product-card {
border: 0;
border-radius: 0.75rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
height: 100%;
}
@media (prefers-reduced-motion: no-preference) {
.product-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
}
}Note the direction. The animation is opt-in — declared only inside
no-preference — rather than declared globally and then undone in a
reduce block at the bottom of the file. Nothing to forget to undo.
Mixins are also the standard way to name breakpoints:
$breakpoint-md: 768px;
@mixin md-up {
@media (min-width: $breakpoint-md) { @content; }
}
.sticky-summary {
position: static;
@include md-up {
position: sticky;
top: 1rem;
}
}Sass and CSS Modules together
Name a file *.module.scss and you get both — Sass features and per-component scoping,
with no runtime cost:
// ProductCard.module.scss
@use '../styles/tokens' as *;
.card {
border-radius: $card-radius;
&:hover {
box-shadow: 0 8px 20px rgb(0 0 0 / 14%);
}
}import styles from './ProductCard.module.scss';
<Card className={styles.card}>Class names are hashed, so collisions are impossible and unused classes are visibly dead. See Styling.
The thing only Sass can do
Everything above has a plain-CSS equivalent or nearly one — custom properties replaced variables, browsers gained nesting, and mixins are mostly convenience.
What has no equivalent is recompiling a library's own Sass. Bootstrap is written in Sass and ships its source, so you can override its variables before it compiles, and import only the parts you use:
// Override Bootstrap's own Sass variables BEFORE importing it.
$primary: #d8102a;
$font-family-base: 'Helvetica Neue', Helvetica, Arial, sans-serif;
$border-radius: 0.75rem;
// Required core.
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins';
@import 'bootstrap/scss/root';
@import 'bootstrap/scss/reboot';
// Only what this app actually uses.
@import 'bootstrap/scss/grid';
@import 'bootstrap/scss/buttons';
@import 'bootstrap/scss/card';
@import 'bootstrap/scss/nav';
@import 'bootstrap/scss/navbar';
@import 'bootstrap/scss/modal';
@import 'bootstrap/scss/offcanvas';
@import 'bootstrap/scss/forms';
@import 'bootstrap/scss/utilities/api';Two things this buys that custom properties cannot. Setting $primary retunes
every derived value — hover shades, focus rings, alert and badge variants, the button
variant map — because they are computed from it at build time. And dropping the components you do not
import cuts the stylesheet substantially; this app's full Bootstrap build is 233 kB, and a
component-selective one is typically less than half that.
Bootstrap's own Sass still uses @import rather than @use, which is why
the snippet above does too. That will change with Bootstrap 6.
Sass or not?
The honest answer in 2026 is that plain CSS has caught up on most of what people used Sass for.
Custom properties, nesting and @layer cover the common cases natively, with no build
step.
Sass is still worth it when you want build-time colour arithmetic, mixins with
@content, loops that generate rules, or — the strongest reason — the ability to compile a
Sass-based framework with your own variables. If none of those apply, plain CSS with custom properties
is one fewer dependency.
That is the track
Twenty-six lessons, from creating a project to shipping one. Along the way: components, JSX, props and state; effects, refs and error boundaries; context, reducers, custom hooks and Redux; routing, memoisation, code splitting and styling.
Every example came from the same working application, which is the best next step available — build something with a cart, a form, a list and a route in it, and the parts that felt abstract stop being abstract.
If you want to check what stuck, Interview Questions is twenty-four senior-level questions covering the whole track — including a section on Context — and the five that matter most are flagged.
For reference beyond this track, react.dev is genuinely excellent and worth reading rather than only searching.